diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index d3553db6584..00000000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,9 +0,0 @@
-version: 2
-updates:
- - package-ecosystem: "pip"
- directory: "/"
- schedule:
- interval: "weekly"
- timezone: "Asia/Shanghai"
- day: "friday"
- target-branch: "v3"
\ No newline at end of file
diff --git a/.github/workflows/build-and-push-python-pg.yml b/.github/workflows/build-and-push-python-pg.yml
index b0d9eb4ccce..fda7b40110c 100644
--- a/.github/workflows/build-and-push-python-pg.yml
+++ b/.github/workflows/build-and-push-python-pg.yml
@@ -4,9 +4,9 @@ on:
workflow_dispatch:
inputs:
architecture:
- description: 'Architecture'
+ description: "Architecture"
required: true
- default: 'linux/amd64'
+ default: "linux/amd64"
type: choice
options:
- linux/amd64
@@ -25,15 +25,15 @@ jobs:
run: |
DOCKER_IMAGE=ghcr.io/1panel-dev/maxkb-base
DOCKER_PLATFORMS=${{ github.event.inputs.architecture }}
- TAG_NAME=python3.11-pg17.10-20260525
+ TAG_NAME=python3.13-pg17.11-20260902
DOCKER_IMAGE_TAGS="--tag ${DOCKER_IMAGE}:${TAG_NAME}"
- echo ::set-output name=docker_image::${DOCKER_IMAGE}
- echo ::set-output name=version::${TAG_NAME}
- echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} --no-cache \
+ echo "docker_image=${DOCKER_IMAGE}" >> $GITHUB_OUTPUT
+ echo "version=${TAG_NAME}" >> $GITHUB_OUTPUT
+ echo "buildx_args=--platform ${DOCKER_PLATFORMS} --no-cache \
--build-arg VERSION=${TAG_NAME} \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg VCS_REF=${GITHUB_SHA::8} \
- ${DOCKER_IMAGE_TAGS} .
+ ${DOCKER_IMAGE_TAGS} ." >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
@@ -48,4 +48,4 @@ jobs:
password: ${{ secrets.GH_TOKEN }}
- name: Docker Buildx (build-and-push)
run: |
- docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} -f installer/Dockerfile-base
\ No newline at end of file
+ docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} -f installer/Dockerfile-base
diff --git a/.github/workflows/build-and-push-vector-model.yml b/.github/workflows/build-and-push-vector-model.yml
index cbf6a5204c9..0a7c1e5524b 100644
--- a/.github/workflows/build-and-push-vector-model.yml
+++ b/.github/workflows/build-and-push-vector-model.yml
@@ -4,13 +4,13 @@ on:
workflow_dispatch:
inputs:
dockerImageTag:
- description: 'Docker Image Tag'
- default: 'v2.0.3'
+ description: "Docker Image Tag"
+ default: "v2.0.3"
required: true
architecture:
- description: 'Architecture'
+ description: "Architecture"
required: true
- default: 'linux/amd64'
+ default: "linux/amd64"
type: choice
options:
- linux/amd64
@@ -32,13 +32,13 @@ jobs:
DOCKER_PLATFORMS=${{ github.event.inputs.architecture }}
TAG_NAME=${{ github.event.inputs.dockerImageTag }}
DOCKER_IMAGE_TAGS="--tag ${DOCKER_IMAGE}:${TAG_NAME} --tag ${DOCKER_IMAGE}:latest"
- echo ::set-output name=docker_image::${DOCKER_IMAGE}
- echo ::set-output name=version::${TAG_NAME}
- echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} --no-cache \
+ echo "docker_image=${DOCKER_IMAGE}" >> $GITHUB_OUTPUT
+ echo "version=${TAG_NAME}" >> $GITHUB_OUTPUT
+ echo "buildx_args=--platform ${DOCKER_PLATFORMS} --no-cache \
--build-arg VERSION=${TAG_NAME} \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg VCS_REF=${GITHUB_SHA::8} \
- ${DOCKER_IMAGE_TAGS} .
+ ${DOCKER_IMAGE_TAGS} ." >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
@@ -53,4 +53,4 @@ jobs:
password: ${{ secrets.GH_TOKEN }}
- name: Docker Buildx (build-and-push)
run: |
- docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} -f installer/Dockerfile-vector-model
\ No newline at end of file
+ docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }} -f installer/Dockerfile-vector-model
diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml
index 50b882fddc0..95b2368bd46 100644
--- a/.github/workflows/build-and-push.yml
+++ b/.github/workflows/build-and-push.yml
@@ -7,7 +7,7 @@ on:
inputs:
dockerImageTag:
description: 'Image Tag'
- default: 'v2.10.0-dev'
+ default: 'v3.0.0-dev'
required: true
dockerImageTagWithLatest:
description: '是否发布latest tag(正式发版时选择,测试版本切勿选择)'
diff --git a/.github/workflows/python-format-check.yml b/.github/workflows/python-format-check.yml
new file mode 100644
index 00000000000..d6634362adb
--- /dev/null
+++ b/.github/workflows/python-format-check.yml
@@ -0,0 +1,47 @@
+name: Python Format Check
+
+on:
+ pull_request:
+ types: [opened, reopened]
+
+jobs:
+ pre-commit:
+ name: Ruff format changed files
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install pre-commit
+ run: python -m pip install pre-commit==4.6.1
+
+ - name: Check changed files
+ env:
+ BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
+ HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
+ if git show-ref --verify --quiet "refs/remotes/origin/${DEFAULT_BRANCH}"; then
+ BASE_SHA=$(git merge-base "$HEAD_SHA" "origin/${DEFAULT_BRANCH}")
+ elif git cat-file -e "${HEAD_SHA}^" 2>/dev/null; then
+ BASE_SHA=$(git rev-parse "${HEAD_SHA}^")
+ else
+ pre-commit run --all-files
+ git diff --cached --exit-code
+ exit 0
+ fi
+ fi
+
+ pre-commit run --from-ref "$BASE_SHA" --to-ref "$HEAD_SHA"
+
+ # The local hook formats and stages files. A clean index proves that
+ # every changed Python file was already formatted before CI ran.
+ git diff --cached --exit-code
diff --git a/.gitignore b/.gitignore
index 91e78cc6947..32f4aaf3ec9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,8 @@
# VS Code
.vscode
+.claude/
+.codex/
*.project
*.factorypath
@@ -190,4 +192,5 @@ apps/models_provider/impl/tencent_model_provider/model/stt.py
tmp/
config.yml
.SANDBOX_BANNED_HOSTS
-copilot-instructions.md
\ No newline at end of file
+copilot-instructions.md
+.ai/
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 00000000000..3e75dd19aa1
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,8 @@
+repos:
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.15.21
+ hooks:
+ - id: ruff-format
+ name: ruff format and stage
+ entry: bash -c 'ruff format "$@" && git add -- "$@"' --
+ require_serial: true
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000000..bea2e73223e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,123 @@
+# MaxKB-v3
+
+MaxKB (Max Knowledge Brain) is an open-source platform for building enterprise AI agents with RAG pipelines, agentic workflows, and MCP tool-use capabilities.
+
+## Agent Instructions
+
+- This file is the shared source of repository instructions for all coding agents.
+- For changes under `ui/`, read and follow `ui/AGENTS.md` before editing or reviewing frontend code.
+- Keep tool-specific entry files such as `CLAUDE.md` small; shared project rules belong here or in the applicable nested `AGENTS.md`.
+
+## Tech Stack
+
+**Backend:** Python 3.13, Django 6.0, Django REST Framework, LangChain 1.3, LangGraph 1.2, Celery 5.6, uv (package manager)
+
+**Frontend:** Vue 3.5, TypeScript, Vite 8, Element Plus, Pinia, Vue Router
+
+**Infrastructure:** PostgreSQL 17.10 + pgvector, Redis 7
+
+## Project Structure
+
+```
+apps/ # Django backend
+ application/ # AI agents & applications
+ knowledge/ # Knowledge base & document management
+ chat/ # Chat functionality & MCP integration
+ models_provider/ # LLM provider integrations (OpenAI, Anthropic, etc.)
+ common/ # Shared utilities, auth, caching, chunking
+ maxkb/ # Django settings, URL routing, config
+ ops/ # Celery task queue configuration
+ users/ # User management
+ tools/ # Tool/function management
+ui/ # Vue.js frontend
+ src/
+ api/ # API client services
+ views/ # Page views
+ components/ # Vue components
+ workflow/ # Workflow UI
+installer/ # Docker build scripts and startup scripts
+main.py # Application entry point
+```
+
+## Development Setup
+
+### Backend
+
+```bash
+# Install dependencies (requires Python 3.13)
+python -m uv pip install -r pyproject.toml
+
+# Run database migrations
+cd apps && python manage.py migrate
+
+# Start dev server
+python main.py dev
+# or: cd apps && python manage.py runserver 0.0.0.0:8080
+```
+
+### Frontend
+
+```bash
+cd ui
+npm install
+npm run dev # dev server with hot reload
+npm run build # production build
+npm run type-check # TypeScript check
+npm run lint # ESLint
+npm run format # Prettier
+```
+
+### Service Management
+
+```bash
+python main.py start all -d # start all services (web + celery)
+python main.py start web -d # web only
+python main.py start task -d # celery worker
+python main.py start local_model -d # local model service
+```
+
+### Docker
+
+```bash
+docker build -f installer/Dockerfile -t maxkb:latest .
+```
+
+## Configuration
+
+Key environment variables (see `.env`):
+
+| Variable | Description |
+| ------------------------------------------------------- | ---------------------- |
+| `MAXKB_DB_HOST` / `MAXKB_DB_PORT` | PostgreSQL connection |
+| `MAXKB_DB_NAME` / `MAXKB_DB_USER` / `MAXKB_DB_PASSWORD` | PostgreSQL credentials |
+| `MAXKB_REDIS_HOST` / `MAXKB_REDIS_PORT` | Redis connection |
+
+Settings files:
+
+- `apps/maxkb/conf.py` — main config manager (reads env vars)
+- `apps/maxkb/settings/base/web.py` — Django settings
+- `apps/maxkb/settings/lib.py` — Celery/Redis settings
+
+## Testing
+
+```bash
+cd apps && python manage.py test
+```
+
+## Code Style
+
+- Python: Ruff linter, line length 120 (`pyproject.toml`)
+- TypeScript/Vue: ESLint + Prettier (`ui/`)
+- Migrations in `apps/*/migrations/`
+
+## Key Concepts
+
+- **Knowledge Base**: Documents are chunked, embedded, and stored in PostgreSQL with pgvector for semantic search.
+- **Applications/Agents**: Built on top of knowledge bases; support workflow-based and RAG-based configurations.
+- **Models Provider**: Abstraction layer in `apps/models_provider/` supporting 10+ LLM providers via LangChain.
+- **MCP**: Model Context Protocol tools integrated via `langchain-mcp-adapters` in `apps/chat/mcp/`.
+- **Task Queue**: Heavy document processing runs async via Celery workers.
+
+## Internationalization
+
+Translations in `apps/locales/` (zh_CN, en_US, zh_Hant) and `ui/src/locales/`.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000000..7d0efdf7c9f
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,3 @@
+@AGENTS.md
+
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7e0cfbc7770..c901d5efeb1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,6 +14,18 @@ This [development guideline](https://github.com/1Panel-dev/MaxKB/wiki/3-%E5%BC%8
Note: If you split your pull request to small changes, please make sure any of the changes goes to master will not break anything. Otherwise, it can not be merged until this feature complete.
+## Code formatting
+
+This project uses [pre-commit](https://pre-commit.com/) and Ruff to format Python code. After cloning the repository and setting up the development environment, install the Git hook once:
+
+```bash
+uv sync --group dev
+uv run pre-commit install --install-hooks
+```
+
+
+The hook runs automatically on each `git commit` and stages files changed by the formatter. The same check also runs in CI for every pull request, so all formatting changes must be committed before the pull request can be merged.
+
## Report issues
It is a great way to contribute by reporting an issue. Well-written and complete bug reports are always welcome! Please open an issue and follow the template to fill in required information.
@@ -27,4 +39,4 @@ When reporting issues, always include:
* Snapshots or log files if needed
Because the issues are open to the public, when submitting files, be sure to remove any sensitive information, e.g. user name, password, IP address, and company name. You can
-replace those parts with "REDACTED" or other strings like "****".
\ No newline at end of file
+replace those parts with "REDACTED" or other strings like "****".
diff --git a/README.md b/README.md
index 93f8e95ea70..d5177bf8e37 100644
--- a/README.md
+++ b/README.md
@@ -54,10 +54,6 @@ Access MaxKB web interface at `http://your_server_ip:8080` with default admin cr
- LLM Framework:[LangChain](https://www.langchain.com/)
- Database:[PostgreSQL + pgvector](https://www.postgresql.org/)
-## Star History
-
-[](https://star-history.com/#1Panel-dev/MaxKB&Date)
-
## License
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
diff --git a/README_CN.md b/README_CN.md
index da5db24891d..fda3ab9b65a 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -4,7 +4,7 @@
-
+
diff --git a/SECURITY.md b/SECURITY.md
index 22be037d008..88a06f8f61a 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,17 +1,15 @@
# 安全说明
-如果您发现安全问题,请直接联系我们:
+如果您发现安全问题,请[提交给我们](https://github.com/1Panel-dev/MaxKB/security/advisories/new)。
-- support@fit2cloud.com
-- 400-052-0755
+谢绝只有静态代码分析,未经完整PoC验证的安全建议。
感谢您的支持!
# Security Policy
-All security bugs should be reported to the contact as below:
+Any security issue please [submit a vulnerability report](https://github.com/1Panel-dev/MaxKB/security/advisories/new).
-- support@fit2cloud.com
-- 400-052-0755
+We do not accept security recommendations based solely on static code analysis without complete PoC validation.
-Thanks for your support!
\ No newline at end of file
+Thanks for your support!
diff --git a/apps/application/chat_pipeline/I_base_chat_pipeline.py b/apps/application/chat_pipeline/I_base_chat_pipeline.py
deleted file mode 100644
index f231c2c4514..00000000000
--- a/apps/application/chat_pipeline/I_base_chat_pipeline.py
+++ /dev/null
@@ -1,185 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: I_base_chat_pipeline.py
- @date:2024/1/9 17:25
- @desc:
-"""
-import time
-from abc import abstractmethod
-from typing import Type
-import uuid_utils.compat as uuid
-from rest_framework import serializers
-
-from knowledge.models import Paragraph
-
-
-class ParagraphPipelineModel:
-
- def __init__(self, _id: str, document_id: str, knowledge_id: str, content: str, title: str, status: str,
- is_active: bool, comprehensive_score: float, similarity: float, knowledge_name: str,
- document_name: str,
- hit_handling_method: str, directly_return_similarity: float, knowledge_type, meta: dict = None):
- self.id = _id
- self.document_id = document_id
- self.knowledge_id = knowledge_id
- self.content = content
- self.title = title
- self.status = status
- self.is_active = is_active
- self.comprehensive_score = comprehensive_score
- self.similarity = similarity
- self.knowledge_name = knowledge_name
- self.document_name = document_name
- self.hit_handling_method = hit_handling_method
- self.directly_return_similarity = directly_return_similarity
- self.meta = meta
- self.knowledge_type = knowledge_type
-
- def to_dict(self):
- return {
- 'id': self.id,
- 'document_id': self.document_id,
- 'knowledge_id': self.knowledge_id,
- 'content': self.content,
- 'title': self.title,
- 'status': self.status,
- 'is_active': self.is_active,
- 'comprehensive_score': self.comprehensive_score,
- 'similarity': self.similarity,
- 'knowledge_name': self.knowledge_name,
- 'document_name': self.document_name,
- 'knowledge_type': self.knowledge_type,
- 'meta': self.meta,
- }
-
- class builder:
- def __init__(self):
- self.similarity = None
- self.paragraph = {}
- self.comprehensive_score = None
- self.document_name = None
- self.knowledge_name = None
- self.knowledge_type = None
- self.hit_handling_method = None
- self.directly_return_similarity = 0.9
- self.meta = {}
-
- def add_paragraph(self, paragraph):
- if isinstance(paragraph, Paragraph):
- self.paragraph = {'id': paragraph.id,
- 'document_id': paragraph.document_id,
- 'knowledge_id': paragraph.knowledge_id,
- 'content': paragraph.content,
- 'title': paragraph.title,
- 'status': paragraph.status,
- 'is_active': paragraph.is_active,
- }
- else:
- self.paragraph = paragraph
- return self
-
- def add_knowledge_name(self, knowledge_name):
- self.knowledge_name = knowledge_name
- return self
-
- def add_knowledge_type(self, knowledge_type):
- self.knowledge_type = knowledge_type
- return self
-
- def add_document_name(self, document_name):
- self.document_name = document_name
- return self
-
- def add_hit_handling_method(self, hit_handling_method):
- self.hit_handling_method = hit_handling_method
- return self
-
- def add_directly_return_similarity(self, directly_return_similarity):
- self.directly_return_similarity = directly_return_similarity
- return self
-
- def add_comprehensive_score(self, comprehensive_score: float):
- self.comprehensive_score = comprehensive_score
- return self
-
- def add_similarity(self, similarity: float):
- self.similarity = similarity
- return self
-
- def add_meta(self, meta: dict):
- self.meta = meta
- return self
-
- def build(self):
- return ParagraphPipelineModel(str(self.paragraph.get('id')), str(self.paragraph.get('document_id')),
- str(self.paragraph.get('knowledge_id')),
- self.paragraph.get('content'), self.paragraph.get('title'),
- self.paragraph.get('status'),
- self.paragraph.get('is_active'),
- self.comprehensive_score, self.similarity, self.knowledge_name,
- self.document_name, self.hit_handling_method, self.directly_return_similarity,
- self.knowledge_type,
- self.meta)
-
-
-class IBaseChatPipelineStep:
- def __init__(self):
- # 当前步骤上下文,用于存储当前步骤信息
- self.context = {}
- self.status = 200
- self.err_message = ''
-
- @abstractmethod
- def get_step_serializer(self, manage) -> Type[serializers.Serializer]:
- pass
-
- def valid_args(self, manage):
- step_serializer_clazz = self.get_step_serializer(manage)
- step_serializer = step_serializer_clazz(data=manage.context)
- step_serializer.is_valid(raise_exception=True)
- self.context['step_args'] = step_serializer.data
-
- def run(self, manage):
- """
-
- :param manage: 步骤管理器
- :return: 执行结果
- """
- try:
- start_time = time.time()
- self.context['start_time'] = start_time
- # 校验参数,
- self.valid_args(manage)
- self._run(manage)
- self.context['run_time'] = time.time() - start_time
- except Exception as e:
- self.err_message = str(e)
- self.status = 500
- chat_record_id = manage.context.get('chat_record_id') or str(uuid.uuid7())
- manage.context['message_tokens'] = 0
- manage.context['answer_tokens'] = 0
- end_time = time.time()
- manage.context['run_time'] = end_time - (manage.context.get('start_time') or end_time)
- post_response_handler = manage.context.get('post_response_handler')
- post_response_handler.handler(manage.context.get('chat_id'), chat_record_id,
- manage.context.get('paragraph_list') or [],
- manage.context.get('problem_text'),
- str(e), manage, self, manage.context.get('padding_problem_text'),
- reasoning_content='')
-
- raise e
-
- def _run(self, manage):
- pass
-
- def execute(self, **kwargs):
- pass
-
- def get_details(self, manage, **kwargs):
- """
- 运行详情
- :return: 步骤详情
- """
- return None
diff --git a/apps/application/chat_pipeline/__init__.py b/apps/application/chat_pipeline/__init__.py
deleted file mode 100644
index 719a7e29c90..00000000000
--- a/apps/application/chat_pipeline/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py.py
- @date:2024/1/9 17:23
- @desc:
-"""
diff --git a/apps/application/chat_pipeline/pipeline_manage.py b/apps/application/chat_pipeline/pipeline_manage.py
deleted file mode 100644
index 206df8a399e..00000000000
--- a/apps/application/chat_pipeline/pipeline_manage.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: pipeline_manage.py
- @date:2024/1/9 17:40
- @desc:
-"""
-import time
-from functools import reduce
-from typing import List, Type, Dict
-
-from application.chat_pipeline.I_base_chat_pipeline import IBaseChatPipelineStep
-from common.handle.base_to_response import BaseToResponse
-from common.handle.impl.response.system_to_response import SystemToResponse
-
-
-class PipelineManage:
- def __init__(self, step_list: List[Type[IBaseChatPipelineStep]],
- base_to_response: BaseToResponse = SystemToResponse(),
- debug=False):
- # 步骤执行器
- self.step_list = [step() for step in step_list]
- self.run_step_list = []
- # 上下文
- self.context = {'message_tokens': 0, 'answer_tokens': 0}
- self.base_to_response = base_to_response
- self.debug = debug
-
- def run(self, context: Dict = None):
- self.context['start_time'] = time.time()
- if context is not None:
- for key, value in context.items():
- self.context[key] = value
- for step in self.step_list:
- self.run_step_list.append(step)
- step.run(self)
-
- def get_details(self):
- return reduce(lambda x, y: {**x, **y}, [{item.get('step_type'): item} for item in
- filter(lambda r: r is not None,
- [row.get_details(self) for row in self.run_step_list])], {})
-
- def get_base_to_response(self):
- return self.base_to_response
-
- class builder:
- def __init__(self):
- self.step_list: List[Type[IBaseChatPipelineStep]] = []
- self.base_to_response = SystemToResponse()
- self.debug = False
-
- def append_step(self, step: Type[IBaseChatPipelineStep]):
- self.step_list.append(step)
- return self
-
- def add_base_to_response(self, base_to_response: BaseToResponse):
- self.base_to_response = base_to_response
- return self
-
- def add_debug(self, debug):
- self.debug = debug
- return self
-
- def build(self):
- return PipelineManage(step_list=self.step_list, base_to_response=self.base_to_response, debug=self.debug)
diff --git a/apps/application/chat_pipeline/step/__init__.py b/apps/application/chat_pipeline/step/__init__.py
deleted file mode 100644
index 5d9549cdc64..00000000000
--- a/apps/application/chat_pipeline/step/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py.py
- @date:2024/1/9 18:23
- @desc:
-"""
diff --git a/apps/application/chat_pipeline/step/chat_step/__init__.py b/apps/application/chat_pipeline/step/chat_step/__init__.py
deleted file mode 100644
index 5d9549cdc64..00000000000
--- a/apps/application/chat_pipeline/step/chat_step/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py.py
- @date:2024/1/9 18:23
- @desc:
-"""
diff --git a/apps/application/chat_pipeline/step/chat_step/i_chat_step.py b/apps/application/chat_pipeline/step/chat_step/i_chat_step.py
deleted file mode 100644
index 1c2ede64b40..00000000000
--- a/apps/application/chat_pipeline/step/chat_step/i_chat_step.py
+++ /dev/null
@@ -1,121 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_chat_step.py
- @date:2024/1/9 18:17
- @desc: 对话
-"""
-from abc import abstractmethod
-from typing import Type, List
-
-from django.utils.translation import gettext_lazy as _
-from langchain.chat_models.base import BaseChatModel
-from langchain_core.messages import BaseMessage
-from rest_framework import serializers
-
-from application.chat_pipeline.I_base_chat_pipeline import IBaseChatPipelineStep, ParagraphPipelineModel
-from application.chat_pipeline.pipeline_manage import PipelineManage
-from application.serializers.application import NoReferencesSetting
-from common.field.common import InstanceField
-
-
-class ModelField(serializers.Field):
- def to_internal_value(self, data):
- if not isinstance(data, BaseChatModel):
- self.fail(_('Model type error'), value=data)
- return data
-
- def to_representation(self, value):
- return value
-
-
-class MessageField(serializers.Field):
- def to_internal_value(self, data):
- if not isinstance(data, BaseMessage):
- self.fail(_('Message type error'), value=data)
- return data
-
- def to_representation(self, value):
- return value
-
-
-class PostResponseHandler:
- @abstractmethod
- def handler(self, chat_id, chat_record_id, paragraph_list: List[ParagraphPipelineModel], problem_text: str,
- answer_text,
- manage, step, padding_problem_text: str = None, **kwargs):
- pass
-
-
-class IChatStep(IBaseChatPipelineStep):
- class InstanceSerializer(serializers.Serializer):
- # 对话列表
- message_list = serializers.ListField(required=True, child=MessageField(required=True),
- label=_("Conversation list"))
- model_id = serializers.UUIDField(required=False, allow_null=True, label=_("Model id"))
- # 段落列表
- paragraph_list = serializers.ListField(label=_("Paragraph List"))
- # 对话id
- chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
- # 用户问题
- problem_text = serializers.CharField(required=True, label=_("User Questions"))
- # 后置处理器
- post_response_handler = InstanceField(model_type=PostResponseHandler,
- label=_("Post-processor"))
- # 补全问题
- padding_problem_text = serializers.CharField(required=False,
- label=_("Completion Question"))
- # 是否使用流的形式输出
- stream = serializers.BooleanField(required=False, label=_("Streaming Output"))
- chat_user_id = serializers.CharField(required=True, label=_("Chat user id"))
- chat_record_id = serializers.CharField(required=False, label=_("Chat record id"))
-
- chat_user_type = serializers.CharField(required=True, label=_("Chat user Type"))
- # 未查询到引用分段
- no_references_setting = NoReferencesSetting(required=True,
- label=_("No reference segment settings"))
-
- workspace_id = serializers.CharField(required=True, label=_("Workspace ID"))
-
- model_setting = serializers.DictField(required=True, allow_null=True,
- label=_("Model settings"))
-
- model_params_setting = serializers.DictField(required=False, allow_null=True,
- label=_("Model parameter settings"))
- mcp_tool_ids = serializers.JSONField(label="MCP工具ID列表", required=False, default=list)
- mcp_servers = serializers.JSONField(label="MCP服务列表", required=False, default=dict)
- mcp_source = serializers.CharField(label="MCP Source", required=False, default="referencing")
- tool_ids = serializers.JSONField(label="工具ID列表", required=False, default=list)
- application_ids = serializers.JSONField(label="应用ID列表", required=False, default=list)
- skill_tool_ids = serializers.JSONField(label="技能ID列表", required=False, default=list)
- mcp_output_enable = serializers.BooleanField(label="MCP输出是否启用", required=False, default=True)
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
- message_list: List = self.initial_data.get('message_list')
- for message in message_list:
- if not isinstance(message, BaseMessage):
- raise Exception(_("message type error"))
-
- def get_step_serializer(self, manage: PipelineManage) -> Type[serializers.Serializer]:
- return self.InstanceSerializer
-
- def _run(self, manage: PipelineManage):
- chat_result = self.execute(**self.context['step_args'], manage=manage)
- manage.context['chat_result'] = chat_result
-
- @abstractmethod
- def execute(self, message_list: List[BaseMessage],
- chat_id, problem_text,
- post_response_handler: PostResponseHandler,
- model_id: str = None,
- workspace_id: str = None,
- paragraph_list=None,
- manage: PipelineManage = None,
- padding_problem_text: str = None, stream: bool = True, chat_user_id=None, chat_user_type=None,
- no_references_setting=None, model_params_setting=None, model_setting=None,
- mcp_tool_ids=None, mcp_servers='', mcp_source="referencing",
- tool_ids=None, application_ids=None, skill_tool_ids=None, mcp_output_enable=True,
- **kwargs):
- pass
diff --git a/apps/application/chat_pipeline/step/chat_step/impl/base_chat_step.py b/apps/application/chat_pipeline/step/chat_step/impl/base_chat_step.py
index 914f20b524e..e69de29bb2d 100644
--- a/apps/application/chat_pipeline/step/chat_step/impl/base_chat_step.py
+++ b/apps/application/chat_pipeline/step/chat_step/impl/base_chat_step.py
@@ -1,798 +0,0 @@
-# coding=utf-8
-"""
-@project: maxkb
-@Author:虎
-@file: base_chat_step.py
-@date:2024/1/9 18:25
-@desc: 对话step Base实现
-"""
-
-import json
-import time
-import traceback
-from typing import List
-
-import uuid_utils.compat as uuid
-from application.chat_pipeline.I_base_chat_pipeline import ParagraphPipelineModel
-from application.chat_pipeline.pipeline_manage import PipelineManage
-from application.chat_pipeline.step.chat_step.i_chat_step import IChatStep, PostResponseHandler
-from application.flow.tools import Reasoning, get_tools, mcp_response_generator
-from application.long_term_memory import extract_long_term_memory
-from application.models import (
- Application,
- ApplicationAccessToken,
- ApplicationApiKey,
- ApplicationChatUserStats,
- ApplicationLongTermMemory,
- ChatUserType,
-)
-from common.exception.app_exception import AppApiException
-from common.utils.logger import maxkb_logger
-from common.utils.rsa_util import rsa_long_decrypt
-from common.utils.shared_resource_auth import filter_authorized_ids
-from common.utils.tool_code import ToolExecutor
-from django.db.models import QuerySet
-from django.http import StreamingHttpResponse
-from django.utils.translation import gettext as _
-from langchain.chat_models.base import BaseChatModel
-from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage, SystemMessage
-from models_provider.tools import get_model_instance_by_model_workspace_id
-from rest_framework import status
-from tools.models import Tool, ToolType
-
-
-def add_access_num(chat_user_id=None, chat_user_type=None, application_id=None):
- if [ChatUserType.ANONYMOUS_USER.value, ChatUserType.CHAT_USER.value].__contains__(
- chat_user_type
- ) and application_id is not None:
- application_public_access_client = (
- QuerySet(ApplicationChatUserStats)
- .filter(chat_user_id=chat_user_id, chat_user_type=chat_user_type, application_id=application_id)
- .first()
- )
- if application_public_access_client is not None:
- application_public_access_client.access_num = application_public_access_client.access_num + 1
- application_public_access_client.intraday_access_num = (
- application_public_access_client.intraday_access_num + 1
- )
- application_public_access_client.save()
-
-
-def write_context(step, manage, request_token, response_token, all_text):
- step.context["message_tokens"] = request_token
- step.context["answer_tokens"] = response_token
- current_time = time.time()
- step.context["answer_text"] = all_text
- step.context["run_time"] = current_time - step.context["start_time"]
- manage.context["run_time"] = current_time - manage.context["start_time"]
- manage.context["message_tokens"] = manage.context["message_tokens"] + request_token
- manage.context["answer_tokens"] = manage.context["answer_tokens"] + response_token
-
-
-def event_content(
- response,
- chat_id,
- chat_record_id,
- paragraph_list: List[ParagraphPipelineModel],
- post_response_handler: PostResponseHandler,
- manage,
- step,
- chat_model,
- message_list: List[BaseMessage],
- problem_text: str,
- padding_problem_text: str = None,
- chat_user_id=None,
- chat_user_type=None,
- is_ai_chat: bool = None,
- model_setting=None,
-):
- if model_setting is None:
- model_setting = {}
- reasoning_content_enable = model_setting.get("reasoning_content_enable", False)
- reasoning_content_start = model_setting.get("reasoning_content_start", "")
- reasoning_content_end = model_setting.get("reasoning_content_end", "")
- reasoning = Reasoning(reasoning_content_start, reasoning_content_end)
- all_text = ""
- reasoning_content = ""
- try:
- response_reasoning_content = False
- for chunk in response:
- reasoning_chunk = reasoning.get_reasoning_content(chunk)
- content_chunk = reasoning_chunk.get("content")
- if "reasoning_content" in chunk.additional_kwargs:
- response_reasoning_content = True
- reasoning_content_chunk = chunk.additional_kwargs.get("reasoning_content", "")
- else:
- reasoning_content_chunk = reasoning_chunk.get("reasoning_content")
- content_chunk = reasoning._normalize_content(content_chunk)
- all_text += content_chunk
- if reasoning_content_chunk is None:
- reasoning_content_chunk = ""
- reasoning_content += reasoning_content_chunk
- yield manage.get_base_to_response().to_stream_chunk_response(
- chat_id,
- str(chat_record_id),
- "ai-chat-node",
- [],
- content_chunk,
- False,
- 0,
- 0,
- {
- "node_is_end": False,
- "view_type": "many_view",
- "node_type": "ai-chat-node",
- "real_node_id": "ai-chat-node",
- "reasoning_content": reasoning_content_chunk if reasoning_content_enable else "",
- },
- )
- reasoning_chunk = reasoning.get_end_reasoning_content()
- all_text += reasoning_chunk.get("content")
- reasoning_content_chunk = ""
- if not response_reasoning_content:
- reasoning_content_chunk = reasoning_chunk.get("reasoning_content")
- yield manage.get_base_to_response().to_stream_chunk_response(
- chat_id,
- str(chat_record_id),
- "ai-chat-node",
- [],
- reasoning_chunk.get("content"),
- False,
- 0,
- 0,
- {
- "node_is_end": False,
- "view_type": "many_view",
- "node_type": "ai-chat-node",
- "real_node_id": "ai-chat-node",
- "reasoning_content": reasoning_content_chunk if reasoning_content_enable else "",
- },
- )
- # 获取token
- if is_ai_chat:
- try:
- request_token = chat_model.get_num_tokens_from_messages(message_list)
- response_token = chat_model.get_num_tokens(all_text)
- except Exception as e:
- request_token = 0
- response_token = 0
- else:
- request_token = 0
- response_token = 0
- write_context(step, manage, request_token, response_token, all_text)
- post_response_handler.handler(
- chat_id,
- chat_record_id,
- paragraph_list,
- problem_text,
- all_text,
- manage,
- step,
- padding_problem_text,
- reasoning_content=reasoning_content if reasoning_content_enable else "",
- )
- yield manage.get_base_to_response().to_stream_chunk_response(
- chat_id,
- str(chat_record_id),
- "ai-chat-node",
- [],
- "",
- True,
- request_token,
- response_token,
- {"node_is_end": True, "view_type": "many_view", "node_type": "ai-chat-node"},
- )
- if not manage.debug:
- add_access_num(chat_user_id, chat_user_type, manage.context.get("application_id"))
- except BaseException as e:
- if isinstance(e, GeneratorExit):
- maxkb_logger.error(f"Generator was closed (client disconnected)")
- else:
- maxkb_logger.error(f"{str(e)}:{traceback.format_exc()}")
- all_text = "Exception:" + str(e)
- write_context(step, manage, 0, 0, all_text)
- post_response_handler.handler(
- chat_id,
- chat_record_id,
- paragraph_list,
- problem_text,
- all_text,
- manage,
- step,
- padding_problem_text,
- reasoning_content=reasoning_content if reasoning_content_enable else "",
- )
- if not manage.debug:
- add_access_num(chat_user_id, chat_user_type, manage.context.get("application_id"))
- yield manage.get_base_to_response().to_stream_chunk_response(
- chat_id,
- str(chat_record_id),
- "ai-chat-node",
- [],
- all_text,
- False,
- 0,
- 0,
- {
- "node_is_end": False,
- "view_type": "many_view",
- "node_type": "ai-chat-node",
- "real_node_id": "ai-chat-node",
- "reasoning_content": "",
- },
- )
-
-
-class BaseChatStep(IChatStep):
- def execute(
- self,
- message_list: List[BaseMessage],
- chat_id,
- problem_text,
- post_response_handler: PostResponseHandler,
- model_id: str = None,
- workspace_id: str = None,
- paragraph_list=None,
- manage: PipelineManage = None,
- padding_problem_text: str = None,
- stream: bool = True,
- chat_user_id=None,
- chat_user_type=None,
- no_references_setting=None,
- model_params_setting=None,
- model_setting=None,
- mcp_tool_ids=None,
- mcp_servers="",
- mcp_source="referencing",
- tool_ids=None,
- application_ids=None,
- skill_tool_ids=None,
- mcp_output_enable=True,
- **kwargs,
- ):
- chat_model = (
- get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {}))
- if model_id is not None
- else None
- )
- if stream:
- return self.execute_stream(
- message_list,
- chat_id,
- problem_text,
- post_response_handler,
- chat_model,
- paragraph_list,
- manage,
- padding_problem_text,
- chat_user_id,
- chat_user_type,
- no_references_setting,
- model_setting,
- mcp_tool_ids,
- mcp_servers,
- mcp_source,
- tool_ids,
- application_ids,
- skill_tool_ids,
- workspace_id,
- mcp_output_enable,
- )
- else:
- return self.execute_block(
- message_list,
- chat_id,
- problem_text,
- post_response_handler,
- chat_model,
- paragraph_list,
- manage,
- padding_problem_text,
- chat_user_id,
- chat_user_type,
- no_references_setting,
- model_setting,
- mcp_tool_ids,
- mcp_servers,
- mcp_source,
- tool_ids,
- application_ids,
- skill_tool_ids,
- workspace_id,
- mcp_output_enable,
- )
-
- def get_details(self, manage, **kwargs):
- # 提取长期记忆
- extract_long_term_memory.apply_async(
- args=(
- manage.context.get("workspace_id"),
- manage.context.get("application_id"),
- manage.context.get("chat_user_id"),
- ),
- countdown=1,
- )
- return {
- "status": self.status,
- "err_message": self.err_message,
- "step_type": "chat_step",
- "run_time": self.context.get("run_time") or 0,
- "model_id": str(manage.context["model_id"]),
- "message_list": self.reset_message_list(
- self.context["step_args"].get("message_list"), self.context.get("answer_text")
- ),
- "message_tokens": self.context.get("message_tokens"),
- "answer_tokens": self.context.get("answer_tokens"),
- "cost": 0,
- }
-
- @staticmethod
- def reset_message_list(message_list: List[BaseMessage], answer_text):
- result = [
- {
- "role": "user"
- if isinstance(message, HumanMessage)
- else ("system" if isinstance(message, SystemMessage) else "ai"),
- "content": message.content,
- }
- for message in message_list
- ]
- result.append({"role": "ai", "content": answer_text})
- return result
-
- def _handle_mcp_request(
- self,
- mcp_source,
- mcp_servers,
- mcp_tool_ids,
- tool_ids,
- application_ids,
- skill_tool_ids,
- mcp_output_enable,
- chat_model,
- system_prompt,
- message_list,
- agent_id,
- chat_id,
- workspace_id,
- ):
-
- mcp_servers_config = {}
-
- # 迁移过来mcp_source是None
- if mcp_source is None:
- mcp_source = "custom"
- # 兼容老数据
- if not mcp_tool_ids:
- mcp_tool_ids = []
- if mcp_source == "custom" and mcp_servers:
- mcp_servers_config = json.loads(mcp_servers)
- elif mcp_tool_ids:
- mcp_tools = QuerySet(Tool).filter(id__in=mcp_tool_ids).values()
- for mcp_tool in mcp_tools:
- if mcp_tool and mcp_tool["is_active"]:
- mcp_servers_config = {**mcp_servers_config, **json.loads(mcp_tool["code"])}
- # 校验代码是否包括禁止的关键字
- ToolExecutor().validate_mcp_transport(json.dumps(mcp_servers_config))
-
- tool_init_params = {}
- tools = get_tools("APPLICATION", agent_id, tool_ids, workspace_id)
- if tool_ids and len(tool_ids) > 0: # 如果有工具ID,则将其转换为MCP
- self.context["tool_ids"] = tool_ids
- for tool_id in tool_ids:
- tool = QuerySet(Tool).filter(id=tool_id, tool_type=ToolType.CUSTOM).first()
- if tool is None or tool.is_active is False:
- continue
- executor = ToolExecutor()
- if tool.init_params is not None:
- tool_init_params = json.loads(rsa_long_decrypt(tool.init_params))
- else:
- tool_init_params = {i["field"]: i.get("default_value") for i in tool.init_field_list}
- tool_config = executor.get_tool_mcp_config(tool, tool_init_params)
-
- mcp_servers_config[str(tool.id)] = tool_config
-
- if application_ids and len(application_ids) > 0:
- self.context["application_ids"] = application_ids
- for application_id in application_ids:
- app = QuerySet(Application).filter(id=application_id, is_publish=True).first()
- if app is None:
- continue
- app_key = QuerySet(ApplicationApiKey).filter(application_id=application_id, is_active=True).first()
- if app_key is not None:
- api_key = app_key.secret_key
- application_access_token = (
- QuerySet(ApplicationAccessToken).filter(application_id=app_key.application_id).first()
- )
- if application_access_token is not None and application_access_token.authentication:
- raise AppApiException(
- 500,
- _("Agent 【{name}】 access token authentication is not supported for agent tool").format(
- name=app.name
- ),
- )
- else:
- raise AppApiException(
- 500, _("Agent Key is required for agent tool 【{name}】").format(name=app.name)
- )
- executor = ToolExecutor()
- app_config = executor.get_app_mcp_config(api_key)
- mcp_servers_config[app.name] = app_config
-
- if skill_tool_ids and len(skill_tool_ids) > 0:
- self.context["skill_tool_ids"] = skill_tool_ids
- skill_file_items = []
-
- for tool_id in skill_tool_ids:
- tool = QuerySet(Tool).filter(id=tool_id, is_active=True).first()
- if tool is None or tool.is_active is False:
- continue
- init_params_default_value = {i["field"]: i.get("default_value") for i in tool.init_field_list}
- if tool.init_params is not None:
- params = init_params_default_value | json.loads(rsa_long_decrypt(tool.init_params))
- else:
- params = init_params_default_value
-
- skill_file_items.append({"tool_id": str(tool.id), "file_id": tool.code, "params": params})
- mcp_servers_config["skills"] = skill_file_items
-
- if len(mcp_servers_config) > 0 or len(tools) > 0:
- source_id = agent_id
- source_type = "APPLICATION"
- return mcp_response_generator(
- chat_model,
- system_prompt,
- message_list,
- json.dumps(mcp_servers_config),
- mcp_output_enable,
- tool_init_params,
- source_id,
- source_type,
- chat_id,
- tools,
- )
-
- return None
-
- def get_stream_result(
- self,
- message_list: List[BaseMessage],
- chat_model: BaseChatModel = None,
- paragraph_list=None,
- no_references_setting=None,
- problem_text=None,
- mcp_tool_ids=None,
- mcp_servers="",
- mcp_source="referencing",
- tool_ids=None,
- application_ids=None,
- skill_tool_ids=None,
- workspace_id=None,
- mcp_output_enable=True,
- agent_id=None,
- chat_id=None,
- chat_user_id=None,
- chat_user_type=None,
- ):
- if paragraph_list is None:
- paragraph_list = []
- directly_return_chunk_list = [
- AIMessageChunk(content=paragraph.content)
- for paragraph in paragraph_list
- if (
- paragraph.hit_handling_method == "directly_return"
- and paragraph.similarity >= paragraph.directly_return_similarity
- )
- ]
- if directly_return_chunk_list is not None and len(directly_return_chunk_list) > 0:
- return iter(directly_return_chunk_list), False
- elif len(paragraph_list) == 0 and no_references_setting.get("status") == "designated_answer":
- return iter(
- [AIMessageChunk(content=no_references_setting.get("value").replace("{question}", problem_text))]
- ), False
- if chat_model is None:
- return iter(
- [
- AIMessageChunk(
- _(
- "Sorry, the AI model is not configured. Please go to the application to set up the AI model first."
- )
- )
- ]
- ), False
- else:
- user_system_prompt = None
- filtered_message_list = []
- long_term_memory = (
- QuerySet(ApplicationLongTermMemory).filter(chat_user_id=chat_user_id, application_id=agent_id).first()
- )
- if long_term_memory is not None:
- memory = long_term_memory.memory
- else:
- memory = ""
-
- # print(chat_user_id, chat_user_type)
- for msg in message_list:
- if isinstance(msg, SystemMessage):
- if isinstance(msg.content, str):
- user_system_prompt = msg.content.replace("{memory}", memory)
- msg.content = user_system_prompt
- elif isinstance(msg.content, list):
- user_system_prompt = "".join(
- item.get("text", "") if isinstance(item, dict) else str(item) for item in msg.content
- )
- else:
- user_system_prompt = str(msg.content)
- else:
- filtered_message_list.append(msg)
- # 过滤tool_id
- all_tool_ids = list(set((mcp_tool_ids or []) + (tool_ids or []) + (skill_tool_ids or [])))
- authorized_set = set(filter_authorized_ids("tool", all_tool_ids, workspace_id))
-
- mcp_tool_ids = [i for i in (mcp_tool_ids or []) if i in authorized_set]
- tool_ids = [i for i in (tool_ids or []) if i in authorized_set]
- skill_tool_ids = [i for i in (skill_tool_ids or []) if i in authorized_set]
- # 处理 MCP 请求
- mcp_result = self._handle_mcp_request(
- mcp_source,
- mcp_servers,
- mcp_tool_ids,
- tool_ids,
- application_ids,
- skill_tool_ids,
- mcp_output_enable,
- chat_model,
- user_system_prompt,
- filtered_message_list,
- agent_id,
- chat_id,
- workspace_id,
- )
- if mcp_result:
- return mcp_result, True
- return chat_model.stream(message_list), True
-
- def execute_stream(
- self,
- message_list: List[BaseMessage],
- chat_id,
- problem_text,
- post_response_handler: PostResponseHandler,
- chat_model: BaseChatModel = None,
- paragraph_list=None,
- manage: PipelineManage = None,
- padding_problem_text: str = None,
- chat_user_id=None,
- chat_user_type=None,
- no_references_setting=None,
- model_setting=None,
- mcp_tool_ids=None,
- mcp_servers="",
- mcp_source="referencing",
- tool_ids=None,
- application_ids=None,
- skill_tool_ids=None,
- workspace_id=None,
- mcp_output_enable=True,
- ):
- chat_result, is_ai_chat = self.get_stream_result(
- message_list,
- chat_model,
- paragraph_list,
- no_references_setting,
- problem_text,
- mcp_tool_ids,
- mcp_servers,
- mcp_source,
- tool_ids,
- application_ids,
- skill_tool_ids,
- workspace_id,
- mcp_output_enable,
- manage.context.get("application_id"),
- chat_id,
- chat_user_id,
- chat_user_type,
- )
- chat_record_id = (
- self.context.get("step_args", {}).get("chat_record_id")
- if self.context.get("step_args", {}).get("chat_record_id")
- else uuid.uuid7()
- )
- r = StreamingHttpResponse(
- streaming_content=event_content(
- chat_result,
- chat_id,
- chat_record_id,
- paragraph_list,
- post_response_handler,
- manage,
- self,
- chat_model,
- message_list,
- problem_text,
- padding_problem_text,
- chat_user_id,
- chat_user_type,
- is_ai_chat,
- model_setting,
- ),
- content_type="text/event-stream;charset=utf-8",
- )
-
- r["Cache-Control"] = "no-cache"
- return r
-
- def get_block_result(
- self,
- message_list: List[BaseMessage],
- chat_model: BaseChatModel = None,
- paragraph_list=None,
- no_references_setting=None,
- problem_text=None,
- mcp_tool_ids=None,
- mcp_servers="",
- mcp_source="referencing",
- tool_ids=None,
- application_ids=None,
- skill_tool_ids=None,
- workspace_id=None,
- mcp_output_enable=True,
- application_id=None,
- chat_id=None,
- ):
- if paragraph_list is None:
- paragraph_list = []
- directly_return_chunk_list = [
- AIMessageChunk(content=paragraph.content)
- for paragraph in paragraph_list
- if (
- paragraph.hit_handling_method == "directly_return"
- and paragraph.similarity >= paragraph.directly_return_similarity
- )
- ]
- if directly_return_chunk_list is not None and len(directly_return_chunk_list) > 0:
- return directly_return_chunk_list[0], False
- elif len(paragraph_list) == 0 and no_references_setting.get("status") == "designated_answer":
- return AIMessage(no_references_setting.get("value").replace("{question}", problem_text)), False
- if chat_model is None:
- return AIMessage(
- _("Sorry, the AI model is not configured. Please go to the application to set up the AI model first.")
- ), False
- else:
- # 过滤tool_id
- all_tool_ids = list(set((mcp_tool_ids or []) + (tool_ids or []) + (skill_tool_ids or [])))
- authorized_set = set(filter_authorized_ids("tool", all_tool_ids, workspace_id))
-
- mcp_tool_ids = [i for i in (mcp_tool_ids or []) if i in authorized_set]
- tool_ids = [i for i in (tool_ids or []) if i in authorized_set]
- skill_tool_ids = [i for i in (skill_tool_ids or []) if i in authorized_set]
- # 处理 MCP 请求
- mcp_result = self._handle_mcp_request(
- mcp_source,
- mcp_servers,
- mcp_tool_ids,
- tool_ids,
- application_ids,
- skill_tool_ids,
- mcp_output_enable,
- chat_model,
- "",
- message_list,
- application_id,
- chat_id,
- workspace_id,
- )
- if mcp_result:
- return mcp_result, True
- return chat_model.invoke(message_list), True
-
- def execute_block(
- self,
- message_list: List[BaseMessage],
- chat_id,
- problem_text,
- post_response_handler: PostResponseHandler,
- chat_model: BaseChatModel = None,
- paragraph_list=None,
- manage: PipelineManage = None,
- padding_problem_text: str = None,
- chat_user_id=None,
- chat_user_type=None,
- no_references_setting=None,
- model_setting=None,
- mcp_tool_ids=None,
- mcp_servers="",
- mcp_source="referencing",
- tool_ids=None,
- application_ids=None,
- skill_tool_ids=None,
- workspace_id=None,
- mcp_output_enable=True,
- ):
- reasoning_content_enable = model_setting.get("reasoning_content_enable", False)
- reasoning_content_start = model_setting.get("reasoning_content_start", "")
- reasoning_content_end = model_setting.get("reasoning_content_end", "")
- reasoning = Reasoning(reasoning_content_start, reasoning_content_end)
- chat_record_id = uuid.uuid7()
- # 调用模型
- try:
- chat_result, is_ai_chat = self.get_block_result(
- message_list,
- chat_model,
- paragraph_list,
- no_references_setting,
- problem_text,
- mcp_tool_ids,
- mcp_servers,
- mcp_source,
- tool_ids,
- application_ids,
- skill_tool_ids,
- workspace_id,
- mcp_output_enable,
- manage.context.get("application_id"),
- chat_id,
- )
- if is_ai_chat:
- request_token = chat_model.get_num_tokens_from_messages(message_list)
- response_token = chat_model.get_num_tokens(chat_result.content)
- else:
- request_token = 0
- response_token = 0
- write_context(self, manage, request_token, response_token, chat_result.content)
- reasoning_result = reasoning.get_reasoning_content(chat_result)
- reasoning_result_end = reasoning.get_end_reasoning_content()
- content = reasoning_result.get("content") + reasoning_result_end.get("content")
- if "reasoning_content" in chat_result.response_metadata:
- reasoning_content = chat_result.response_metadata.get("reasoning_content", "") or ""
- else:
- reasoning_content = (reasoning_result.get("reasoning_content") or "") + (
- reasoning_result_end.get("reasoning_content") or ""
- )
- post_response_handler.handler(
- chat_id,
- chat_record_id,
- paragraph_list,
- problem_text,
- content,
- manage,
- self,
- padding_problem_text,
- reasoning_content=reasoning_content,
- )
- if not manage.debug:
- add_access_num(chat_user_id, chat_user_type, manage.context.get("application_id"))
- return manage.get_base_to_response().to_block_response(
- str(chat_id),
- str(chat_record_id),
- content,
- True,
- request_token,
- response_token,
- {
- "reasoning_content": reasoning_content if reasoning_content_enable else "",
- "answer_list": [
- {"content": content, "reasoning_content": reasoning_content if reasoning_content_enable else ""}
- ],
- },
- )
- except Exception as e:
- all_text = "Exception:" + str(e)
- write_context(self, manage, 0, 0, all_text)
- post_response_handler.handler(
- chat_id,
- chat_record_id,
- paragraph_list,
- problem_text,
- all_text,
- manage,
- self,
- padding_problem_text,
- reasoning_content="",
- )
- if not manage.debug:
- add_access_num(chat_user_id, chat_user_type, manage.context.get("application_id"))
- return manage.get_base_to_response().to_block_response(
- str(chat_id), str(chat_record_id), all_text, True, 0, 0, _status=status.HTTP_500_INTERNAL_SERVER_ERROR
- )
diff --git a/apps/application/chat_pipeline/step/generate_human_message_step/__init__.py b/apps/application/chat_pipeline/step/generate_human_message_step/__init__.py
deleted file mode 100644
index 5d9549cdc64..00000000000
--- a/apps/application/chat_pipeline/step/generate_human_message_step/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py.py
- @date:2024/1/9 18:23
- @desc:
-"""
diff --git a/apps/application/chat_pipeline/step/generate_human_message_step/i_generate_human_message_step.py b/apps/application/chat_pipeline/step/generate_human_message_step/i_generate_human_message_step.py
deleted file mode 100644
index 0d49e9a5e2f..00000000000
--- a/apps/application/chat_pipeline/step/generate_human_message_step/i_generate_human_message_step.py
+++ /dev/null
@@ -1,82 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_generate_human_message_step.py
- @date:2024/1/9 18:15
- @desc: 生成对话模板
-"""
-from abc import abstractmethod
-from typing import Type, List
-
-from django.utils.translation import gettext_lazy as _
-from langchain_core.messages import BaseMessage
-from rest_framework import serializers
-
-from application.chat_pipeline.I_base_chat_pipeline import IBaseChatPipelineStep, ParagraphPipelineModel
-from application.chat_pipeline.pipeline_manage import PipelineManage
-from application.models import ChatRecord
-from application.serializers.application import NoReferencesSetting
-from common.field.common import InstanceField
-
-
-class IGenerateHumanMessageStep(IBaseChatPipelineStep):
- class InstanceSerializer(serializers.Serializer):
- # 问题
- problem_text = serializers.CharField(required=True, label=_("question"))
- # 段落列表
- paragraph_list = serializers.ListField(child=InstanceField(model_type=ParagraphPipelineModel, required=True),
- label=_("Paragraph List"))
- # 历史对答
- history_chat_record = serializers.ListField(child=InstanceField(model_type=ChatRecord, required=True),
- label=_("History Questions"))
- # 多轮对话数量
- dialogue_number = serializers.IntegerField(required=True, label=_("Number of multi-round conversations"))
- # 最大携带知识库段落长度
- max_paragraph_char_number = serializers.IntegerField(required=True,
- label=_("Maximum length of the knowledge base paragraph"))
- # 模板
- prompt = serializers.CharField(required=True, label=_("Prompt word"))
- system = serializers.CharField(required=False, allow_null=True, allow_blank=True,
- label=_("System prompt words (role)"))
- # 补齐问题
- padding_problem_text = serializers.CharField(required=False,
- label=_("Completion problem"))
- # 未查询到引用分段
- no_references_setting = NoReferencesSetting(required=True,
- label=_("No reference segment settings"))
-
- def get_step_serializer(self, manage: PipelineManage) -> Type[serializers.Serializer]:
- return self.InstanceSerializer
-
- def _run(self, manage: PipelineManage):
- message_list = self.execute(**self.context['step_args'])
- manage.context['message_list'] = message_list
-
- @abstractmethod
- def execute(self,
- problem_text: str,
- paragraph_list: List[ParagraphPipelineModel],
- history_chat_record: List[ChatRecord],
- dialogue_number: int,
- max_paragraph_char_number: int,
- prompt: str,
- padding_problem_text: str = None,
- no_references_setting=None,
- system=None,
- **kwargs) -> List[BaseMessage]:
- """
-
- :param problem_text: 原始问题文本
- :param paragraph_list: 段落列表
- :param history_chat_record: 历史对话记录
- :param dialogue_number: 多轮对话数量
- :param max_paragraph_char_number: 最大段落长度
- :param prompt: 模板
- :param padding_problem_text 用户修改文本
- :param kwargs: 其他参数
- :param no_references_setting: 无引用分段设置
- :param system 系统提示称
- :return:
- """
- pass
diff --git a/apps/application/chat_pipeline/step/generate_human_message_step/impl/base_generate_human_message_step.py b/apps/application/chat_pipeline/step/generate_human_message_step/impl/base_generate_human_message_step.py
deleted file mode 100644
index 2fc62897eee..00000000000
--- a/apps/application/chat_pipeline/step/generate_human_message_step/impl/base_generate_human_message_step.py
+++ /dev/null
@@ -1,79 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_generate_human_message_step.py.py
- @date:2024/1/10 17:50
- @desc:
-"""
-from typing import List, Dict
-
-from langchain_core.messages import SystemMessage, BaseMessage, HumanMessage
-
-from application.chat_pipeline.I_base_chat_pipeline import ParagraphPipelineModel
-from application.chat_pipeline.step.generate_human_message_step.i_generate_human_message_step import \
- IGenerateHumanMessageStep
-from application.models import ChatRecord
-from common.utils.common import flat_map
-
-
-class BaseGenerateHumanMessageStep(IGenerateHumanMessageStep):
-
- def execute(self, problem_text: str,
- paragraph_list: List[ParagraphPipelineModel],
- history_chat_record: List[ChatRecord],
- dialogue_number: int,
- max_paragraph_char_number: int,
- prompt: str,
- padding_problem_text: str = None,
- no_references_setting=None,
- system=None,
- **kwargs) -> List[BaseMessage]:
- prompt = prompt if (paragraph_list is not None and len(paragraph_list) > 0) else no_references_setting.get(
- 'value')
- exec_problem_text = padding_problem_text if padding_problem_text is not None else problem_text
- start_index = len(history_chat_record) - dialogue_number
- history_message = [[history_chat_record[index].get_human_message(), history_chat_record[index].get_ai_message()]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))]
- if system is not None and len(system) > 0:
- return [SystemMessage(system), *flat_map(history_message),
- self.to_human_message(prompt, exec_problem_text, max_paragraph_char_number, paragraph_list,
- no_references_setting)]
-
- return [*flat_map(history_message),
- self.to_human_message(prompt, exec_problem_text, max_paragraph_char_number, paragraph_list,
- no_references_setting)]
-
- @staticmethod
- def to_human_message(prompt: str,
- problem: str,
- max_paragraph_char_number: int,
- paragraph_list: List[ParagraphPipelineModel],
- no_references_setting: Dict):
- if paragraph_list is None or len(paragraph_list) == 0:
- if no_references_setting.get('status') == 'ai_questioning':
- return HumanMessage(
- content=no_references_setting.get('value').replace('{question}', problem))
- else:
- return HumanMessage(content=prompt.replace('{data}', "").replace('{question}', problem))
- temp_len = 0
- data_list = []
- for p in paragraph_list:
- content = f"{p.title}:{p.content}"
- temp_len += len(content)
- if temp_len > max_paragraph_char_number:
- row_data = content[0:max_paragraph_char_number - temp_len]
- data_list.append(f"{row_data}")
- break
- else:
- data_list.append(f"{content}")
- data = "\n".join(data_list)
- return HumanMessage(content=prompt.replace('{data}', data).replace('{question}', problem))
-
- def get_details(self, manage, **kwargs):
- return {
- 'status': self.status,
- 'err_message': self.err_message,
- 'step_type': 'generate_human_message',
- }
diff --git a/apps/application/chat_pipeline/step/reset_problem_step/__init__.py b/apps/application/chat_pipeline/step/reset_problem_step/__init__.py
deleted file mode 100644
index 5d9549cdc64..00000000000
--- a/apps/application/chat_pipeline/step/reset_problem_step/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py.py
- @date:2024/1/9 18:23
- @desc:
-"""
diff --git a/apps/application/chat_pipeline/step/reset_problem_step/i_reset_problem_step.py b/apps/application/chat_pipeline/step/reset_problem_step/i_reset_problem_step.py
deleted file mode 100644
index a0e06204364..00000000000
--- a/apps/application/chat_pipeline/step/reset_problem_step/i_reset_problem_step.py
+++ /dev/null
@@ -1,55 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_reset_problem_step.py
- @date:2024/1/9 18:12
- @desc: 重写处理问题
-"""
-from abc import abstractmethod
-from typing import Type, List
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.chat_pipeline.I_base_chat_pipeline import IBaseChatPipelineStep
-from application.chat_pipeline.pipeline_manage import PipelineManage
-from application.models import ChatRecord
-from common.field.common import InstanceField
-
-
-class IResetProblemStep(IBaseChatPipelineStep):
- class InstanceSerializer(serializers.Serializer):
- # 问题文本
- problem_text = serializers.CharField(required=True, label=_("question"))
- # 历史对答
- history_chat_record = serializers.ListField(child=InstanceField(model_type=ChatRecord, required=True),
- label=_("History Questions"))
- # 大语言模型
- model_id = serializers.UUIDField(required=False, allow_null=True, label=_("Model id"))
- workspace_id = serializers.CharField(required=True, label=_("User ID"))
- problem_optimization_prompt = serializers.CharField(required=False, max_length=102400,
- label=_("Question completion prompt"))
-
- def get_step_serializer(self, manage: PipelineManage) -> Type[serializers.Serializer]:
- return self.InstanceSerializer
-
- def _run(self, manage: PipelineManage):
- padding_problem = self.execute(**self.context.get('step_args'))
- # 用户输入问题
- source_problem_text = self.context.get('step_args').get('problem_text')
- self.context['problem_text'] = source_problem_text
- self.context['padding_problem_text'] = padding_problem
- manage.context['problem_text'] = source_problem_text
- manage.context['padding_problem_text'] = padding_problem
- # 累加tokens
- manage.context['message_tokens'] = manage.context.get('message_tokens', 0) + self.context.get('message_tokens',
- 0)
- manage.context['answer_tokens'] = manage.context.get('answer_tokens', 0) + self.context.get('answer_tokens', 0)
-
- @abstractmethod
- def execute(self, problem_text: str, history_chat_record: List[ChatRecord] = None, model_id: str = None,
- problem_optimization_prompt=None,
- workspace_id=None,
- **kwargs):
- pass
diff --git a/apps/application/chat_pipeline/step/reset_problem_step/impl/base_reset_problem_step.py b/apps/application/chat_pipeline/step/reset_problem_step/impl/base_reset_problem_step.py
deleted file mode 100644
index 47368ae65f5..00000000000
--- a/apps/application/chat_pipeline/step/reset_problem_step/impl/base_reset_problem_step.py
+++ /dev/null
@@ -1,69 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_reset_problem_step.py
- @date:2024/1/10 14:35
- @desc:
-"""
-from typing import List
-
-from django.utils.translation import gettext as _
-from langchain_core.messages import HumanMessage
-
-from application.chat_pipeline.step.reset_problem_step.i_reset_problem_step import IResetProblemStep
-from application.models import ChatRecord
-from common.utils.split_model import flat_map
-from models_provider.tools import get_model_instance_by_model_workspace_id
-
-prompt = _(
- "() contains the user's question. Answer the guessed user's question based on the context ({question}) Requirement: Output a complete question and put it in the tag")
-
-
-class BaseResetProblemStep(IResetProblemStep):
- def execute(self, problem_text: str, history_chat_record: List[ChatRecord] = None, model_id: str = None,
- problem_optimization_prompt=None,
- workspace_id=None,
- **kwargs) -> str:
- chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id) if model_id is not None else None
- if chat_model is None:
- return problem_text
- start_index = len(history_chat_record) - 3
- history_message = [[history_chat_record[index].get_human_message(), history_chat_record[index].get_ai_message()]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))]
- reset_prompt = problem_optimization_prompt if problem_optimization_prompt else prompt
- message_list = [*flat_map(history_message),
- HumanMessage(content=reset_prompt.replace('{question}', problem_text))]
- response = chat_model.invoke(message_list)
- padding_problem = problem_text
- if response.content.__contains__("") and response.content.__contains__(''):
- padding_problem_data = response.content[
- response.content.index('') + 6:response.content.index('')]
- if padding_problem_data is not None and len(padding_problem_data.strip()) > 0:
- padding_problem = padding_problem_data
- elif len(response.content) > 0:
- padding_problem = response.content
-
- try:
- request_token = chat_model.get_num_tokens_from_messages(message_list)
- response_token = chat_model.get_num_tokens(padding_problem)
- except Exception as e:
- request_token = 0
- response_token = 0
- self.context['message_tokens'] = request_token
- self.context['answer_tokens'] = response_token
- return padding_problem
-
- def get_details(self, manage, **kwargs):
- return {'status': self.status,
- 'err_message': self.err_message,
- 'step_type': 'problem_padding',
- 'run_time': self.context['run_time'],
- 'model_id': str(manage.context['model_id']) if 'model_id' in manage.context else None,
- 'message_tokens': self.context.get('message_tokens', 0),
- 'answer_tokens': self.context.get('answer_tokens', 0),
- 'cost': 0,
- 'padding_problem_text': self.context.get('padding_problem_text'),
- 'problem_text': self.context.get("step_args").get('problem_text'),
- }
diff --git a/apps/application/chat_pipeline/step/search_dataset_step/__init__.py b/apps/application/chat_pipeline/step/search_dataset_step/__init__.py
deleted file mode 100644
index 023c4bc387d..00000000000
--- a/apps/application/chat_pipeline/step/search_dataset_step/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py.py
- @date:2024/1/9 18:24
- @desc:
-"""
diff --git a/apps/application/chat_pipeline/step/search_dataset_step/i_search_dataset_step.py b/apps/application/chat_pipeline/step/search_dataset_step/i_search_dataset_step.py
deleted file mode 100644
index 373dc33d44b..00000000000
--- a/apps/application/chat_pipeline/step/search_dataset_step/i_search_dataset_step.py
+++ /dev/null
@@ -1,77 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_search_dataset_step.py
- @date:2024/1/9 18:10
- @desc: 检索知识库
-"""
-import re
-from abc import abstractmethod
-from typing import List, Type
-
-from django.core import validators
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.chat_pipeline.I_base_chat_pipeline import IBaseChatPipelineStep, ParagraphPipelineModel
-from application.chat_pipeline.pipeline_manage import PipelineManage
-
-
-class ISearchDatasetStep(IBaseChatPipelineStep):
- class InstanceSerializer(serializers.Serializer):
- # 原始问题文本
- problem_text = serializers.CharField(required=True, label=_("question"))
- # 系统补全问题文本
- padding_problem_text = serializers.CharField(required=False,
- label=_("System completes question text"))
- # 需要查询的数据集id列表
- knowledge_id_list = serializers.ListField(required=True, child=serializers.UUIDField(required=True),
- label=_("Dataset id list"))
- # 需要排除的文档id
- exclude_document_id_list = serializers.ListField(required=True, child=serializers.UUIDField(required=True),
- label=_("List of document ids to exclude"))
- # 需要排除向量id
- exclude_paragraph_id_list = serializers.ListField(required=True, child=serializers.UUIDField(required=True),
- label=_("List of exclusion vector ids"))
- # 需要查询的条数
- top_n = serializers.IntegerField(required=True,
- label=_("Reference segment number"))
- # 相似度 0-1之间
- similarity = serializers.FloatField(required=True, max_value=1, min_value=0,
- label=_("Similarity"))
- search_mode = serializers.CharField(required=True, validators=[
- validators.RegexValidator(regex=re.compile("^embedding|keywords|blend$"),
- message=_("The type only supports embedding|keywords|blend"), code=500)
- ], label=_("Retrieval Mode"))
- workspace_id = serializers.CharField(required=True, label=_("Workspace ID"))
-
- def get_step_serializer(self, manage: PipelineManage) -> Type[InstanceSerializer]:
- return self.InstanceSerializer
-
- def _run(self, manage: PipelineManage):
- paragraph_list = self.execute(**self.context['step_args'], manage=manage)
- manage.context['paragraph_list'] = paragraph_list
- self.context['paragraph_list'] = paragraph_list
-
- @abstractmethod
- def execute(self, problem_text: str, knowledge_id_list: list[str], exclude_document_id_list: list[str],
- exclude_paragraph_id_list: list[str], top_n: int, similarity: float, padding_problem_text: str = None,
- search_mode: str = None,
- workspace_id=None,
- manage: PipelineManage = None,
- **kwargs) -> List[ParagraphPipelineModel]:
- """
- 关于 用户和补全问题 说明: 补全问题如果有就使用补全问题去查询 反之就用用户原始问题查询
- :param similarity: 相关性
- :param top_n: 查询多少条
- :param problem_text: 用户问题
- :param knowledge_id_list: 需要查询的数据集id列表
- :param exclude_document_id_list: 需要排除的文档id
- :param exclude_paragraph_id_list: 需要排除段落id
- :param padding_problem_text 补全问题
- :param search_mode 检索模式
- :param workspace_id 工作空间id
- :return: 段落列表
- """
- pass
diff --git a/apps/application/chat_pipeline/step/search_dataset_step/impl/base_search_dataset_step.py b/apps/application/chat_pipeline/step/search_dataset_step/impl/base_search_dataset_step.py
deleted file mode 100644
index e57eebd9f65..00000000000
--- a/apps/application/chat_pipeline/step/search_dataset_step/impl/base_search_dataset_step.py
+++ /dev/null
@@ -1,150 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_search_dataset_step.py
- @date:2024/1/10 10:33
- @desc:
-"""
-import os
-from typing import List, Dict
-
-from django.db.models import QuerySet
-from django.utils.translation import gettext_lazy as _
-from rest_framework.utils.formatting import lazy_format
-
-from application.chat_pipeline.I_base_chat_pipeline import ParagraphPipelineModel
-from application.chat_pipeline.step.search_dataset_step.i_search_dataset_step import ISearchDatasetStep
-from common.config.embedding_config import VectorStore, ModelManage
-from common.constants.permission_constants import RoleConstants
-from common.database_model_manage.database_model_manage import DatabaseModelManage
-from common.db.search import native_search
-from common.utils.common import get_file_content
-from knowledge.models import Paragraph, Knowledge
-from knowledge.models import SearchMode
-from maxkb.conf import PROJECT_DIR
-from models_provider.models import Model
-from models_provider.tools import get_model, get_model_by_id, get_model_default_params
-
-
-def reset_meta(meta):
- if not meta.get('allow_download', False):
- return {'allow_download': False}
- return meta
-
-
-def get_embedding_id(knowledge_id_list):
- knowledge_list = QuerySet(Knowledge).filter(id__in=knowledge_id_list)
- if len(set([knowledge.embedding_model_id for knowledge in knowledge_list])) > 1:
- raise Exception(
- _("The vector model of the associated knowledge base is inconsistent and the segmentation cannot be recalled."))
- if len(knowledge_list) == 0:
- raise Exception(_("The knowledge base setting is wrong, please reset the knowledge base"))
- return knowledge_list[0].embedding_model_id
-
-
-class BaseSearchDatasetStep(ISearchDatasetStep):
-
- def execute(self, problem_text: str, knowledge_id_list: list[str], exclude_document_id_list: list[str],
- exclude_paragraph_id_list: list[str], top_n: int, similarity: float, padding_problem_text: str = None,
- search_mode: str = None,
- workspace_id=None,
- manage=None,
- **kwargs) -> List[ParagraphPipelineModel]:
- get_knowledge_list_of_authorized = DatabaseModelManage.get_model('get_knowledge_list_of_authorized')
- chat_user_type = manage.context.get('chat_user_type')
- if get_knowledge_list_of_authorized is not None and RoleConstants.CHAT_USER.value.name == chat_user_type:
- knowledge_id_list = get_knowledge_list_of_authorized(manage.context.get('chat_user_id'),
- knowledge_id_list)
- if len(knowledge_id_list) == 0:
- return []
- exec_problem_text = padding_problem_text if padding_problem_text is not None else problem_text
- model_id = get_embedding_id(knowledge_id_list)
- model = get_model_by_id(model_id, workspace_id)
- if model.model_type != "EMBEDDING":
- raise Exception(_("Model does not exist"))
- self.context['model_name'] = model.name
- default_params = get_model_default_params(model)
- embedding_model = ModelManage.get_model(model_id, lambda _id: get_model(model, **{**default_params}))
- embedding_value = embedding_model.embed_query(exec_problem_text)
- vector = VectorStore.get_embedding_vector()
- embedding_list = vector.query(exec_problem_text, embedding_value, knowledge_id_list, None,
- exclude_document_id_list,
- exclude_paragraph_id_list, True, top_n, similarity, SearchMode(search_mode))
- if embedding_list is None:
- return []
- paragraph_list = self.list_paragraph(embedding_list, vector)
- result = [self.reset_paragraph(paragraph, embedding_list) for paragraph in paragraph_list]
- return result
-
- @staticmethod
- def reset_paragraph(paragraph: Dict, embedding_list: List) -> ParagraphPipelineModel:
- filter_embedding_list = [embedding for embedding in embedding_list if
- str(embedding.get('paragraph_id')) == str(paragraph.get('id'))]
- if filter_embedding_list is not None and len(filter_embedding_list) > 0:
- find_embedding = filter_embedding_list[-1]
- return (ParagraphPipelineModel.builder()
- .add_paragraph(paragraph)
- .add_similarity(find_embedding.get('similarity'))
- .add_comprehensive_score(find_embedding.get('comprehensive_score'))
- .add_knowledge_name(paragraph.get('knowledge_name'))
- .add_knowledge_type(paragraph.get('knowledge_type'))
- .add_document_name(paragraph.get('document_name'))
- .add_hit_handling_method(paragraph.get('hit_handling_method'))
- .add_directly_return_similarity(paragraph.get('directly_return_similarity'))
- .add_meta(reset_meta(paragraph.get('meta')))
- .build())
-
- @staticmethod
- def get_similarity(paragraph, embedding_list: List):
- filter_embedding_list = [embedding for embedding in embedding_list if
- str(embedding.get('paragraph_id')) == str(paragraph.get('id'))]
- if filter_embedding_list is not None and len(filter_embedding_list) > 0:
- find_embedding = filter_embedding_list[-1]
- return find_embedding.get('comprehensive_score')
- return 0
-
- @staticmethod
- def list_paragraph(embedding_list: List, vector):
- paragraph_id_list = [row.get('paragraph_id') for row in embedding_list]
- if paragraph_id_list is None or len(paragraph_id_list) == 0:
- return []
- paragraph_list = native_search(QuerySet(Paragraph).filter(id__in=paragraph_id_list),
- get_file_content(
- os.path.join(PROJECT_DIR, "apps", "application", 'sql',
- 'list_knowledge_paragraph_by_paragraph_id.sql')),
- with_table_name=True)
- # 如果向量库中存在脏数据 直接删除
- if len(paragraph_list) != len(paragraph_id_list):
- exist_paragraph_list = [row.get('id') for row in paragraph_list]
- for paragraph_id in paragraph_id_list:
- if not exist_paragraph_list.__contains__(paragraph_id):
- vector.delete_by_paragraph_id(paragraph_id)
- # 如果存在直接返回的则取直接返回段落
- hit_handling_method_paragraph = [paragraph for paragraph in paragraph_list if
- (paragraph.get(
- 'hit_handling_method') == 'directly_return' and BaseSearchDatasetStep.get_similarity(
- paragraph, embedding_list) >= paragraph.get(
- 'directly_return_similarity'))]
- if len(hit_handling_method_paragraph) > 0:
- # 找到评分最高的
- return [sorted(hit_handling_method_paragraph,
- key=lambda p: BaseSearchDatasetStep.get_similarity(p, embedding_list))[-1]]
- return paragraph_list
-
- def get_details(self, manage, **kwargs):
- step_args = self.context.get('step_args') or {}
-
- return {
- 'status': self.status,
- 'err_message': self.err_message,
- 'step_type': 'search_step',
- 'paragraph_list': [row.to_dict() for row in (self.context.get('paragraph_list') or [])],
- 'run_time': self.context.get('run_time') or 0,
- 'problem_text': step_args.get(
- 'padding_problem_text') if 'padding_problem_text' in step_args else step_args.get('problem_text'),
- 'model_name': self.context.get('model_name'),
- 'message_tokens': 0,
- 'answer_tokens': 0,
- 'cost': 0
- }
diff --git a/apps/application/flow/__init__.py b/apps/application/flow/__init__.py
deleted file mode 100644
index 328e8f8ec5f..00000000000
--- a/apps/application/flow/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py.py
- @date:2024/6/7 14:43
- @desc:
-"""
diff --git a/apps/application/flow/backend/sandbox_shell.py b/apps/application/flow/backend/sandbox_shell.py
deleted file mode 100644
index 6ed92781393..00000000000
--- a/apps/application/flow/backend/sandbox_shell.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import getpass
-import os
-import re
-
-from deepagents.backends import LocalShellBackend
-from deepagents.backends.protocol import ExecuteResponse
-from maxkb.const import CONFIG
-
-_enable_sandbox = bool(int(CONFIG.get("SANDBOX", 0)))
-_run_user = "sandbox" if _enable_sandbox else getpass.getuser()
-_sandbox_python_sys_path = CONFIG.get_sandbox_python_package_paths().replace(",", ":")
-
-
-class SandboxShellBackend(LocalShellBackend):
- def __init__(self, root_dir: str, **kwargs):
- if "env" not in kwargs and not kwargs.get("inherit_env", False):
- env = os.environ.copy()
- python_path = env.get("PYTHONPATH", "")
-
- # 将 sandbox Python 包路径分解为列表,检查每个路径是否已存在
- existing_paths = set(python_path.split(os.pathsep))
- sandbox_paths = _sandbox_python_sys_path.split(os.pathsep) if _sandbox_python_sys_path else []
- new_paths = [p for p in sandbox_paths if p and p not in existing_paths]
-
- if new_paths:
- env["PYTHONPATH"] = (
- f"{os.pathsep.join(new_paths)}{os.pathsep}{python_path}"
- if python_path
- else os.pathsep.join(new_paths)
- )
-
- kwargs["env"] = env
- super().__init__(root_dir=root_dir, **kwargs)
-
- def _translate_virtual_paths(self, command: str) -> str:
- """Translate virtual absolute paths in the command to real filesystem paths.
-
- In virtual_mode=True, file tools (ls, glob, read_file) return virtual absolute
- paths like /skills/foo.py which map to {root_dir}/skills/foo.py. But execute()
- runs a real shell where /skills/foo.py does not exist. This method replaces
- any path token that exists under root_dir with its real path, while leaving
- genuine system paths (e.g. /usr/bin/python3) untouched.
- """
- root = str(self.cwd)
-
- def translate(m: re.Match) -> str:
- virtual_path = m.group(0)
- real_path = root + virtual_path
- return real_path if os.path.lexists(real_path) else virtual_path
-
- # Match absolute-path-like tokens: / followed by a non-whitespace sequence
- # that isn't clearly a flag (e.g. avoid matching -/something).
- # Only translate when virtual_mode is active.
- return re.sub(r'(?<:,]*', translate, command)
-
- def execute(
- self,
- command: str,
- *,
- timeout: int | None = None,
- ) -> ExecuteResponse:
- if self.virtual_mode:
- command = self._translate_virtual_paths(command)
-
- if _enable_sandbox:
- # 用 runuser 在子进程里切换用户,父进程凭据保持不变,
- # 避免父进程 ruid/euid 不一致导致 execve 报 Permission denied
- command = (
- "env -i LD_PRELOAD=/opt/maxkb-app/sandbox/lib/sandbox.so "
- f'PATH="${{PATH}}" PYTHONPATH="${{PYTHONPATH}}" gosu {_run_user} {command}'
- )
- # command = f"runuser -u {_run_user} -- env -i PATH=${{PATH}} {command}"
-
- # print(f"Executing command in sandbox: {command}")
- return super().execute(command=command, timeout=timeout)
diff --git a/apps/application/flow/common.py b/apps/application/flow/common.py
deleted file mode 100644
index d7520cf690c..00000000000
--- a/apps/application/flow/common.py
+++ /dev/null
@@ -1,284 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: common.py
- @date:2024/12/11 17:57
- @desc:
-"""
-from enum import Enum
-from typing import List, Dict
-
-from django.db.models import QuerySet
-from django.utils.translation import gettext as _
-from rest_framework.exceptions import ErrorDetail, ValidationError
-
-from common.exception.app_exception import AppApiException
-from common.utils.common import group_by
-from models_provider.models import Model
-from models_provider.tools import get_model_credential
-from tools.models.tool import Tool
-
-end_nodes = ['ai-chat-node', 'reply-node', 'function-node', 'function-lib-node', 'application-node',
- 'image-understand-node', 'speech-to-text-node', 'text-to-speech-node', 'image-generate-node',
- 'variable-assign-node']
-
-
-class Answer:
- def __init__(self, content, view_type, runtime_node_id, chat_record_id, child_node, real_node_id,
- reasoning_content):
- self.view_type = view_type
- self.content = content
- self.reasoning_content = reasoning_content
- self.runtime_node_id = runtime_node_id
- self.chat_record_id = chat_record_id
- self.child_node = child_node
- self.real_node_id = real_node_id
-
- def to_dict(self):
- return {'view_type': self.view_type, 'content': self.content, 'runtime_node_id': self.runtime_node_id,
- 'chat_record_id': self.chat_record_id,
- 'child_node': self.child_node,
- 'reasoning_content': self.reasoning_content,
- 'real_node_id': self.real_node_id}
-
-
-class NodeChunk:
- def __init__(self):
- self.status = 0
- self.chunk_list = []
-
- def add_chunk(self, chunk):
- self.chunk_list.append(chunk)
-
- def end(self, chunk=None):
- if chunk is not None:
- self.add_chunk(chunk)
- self.status = 200
-
- def is_end(self):
- return self.status == 200
-
-
-class Edge:
- def __init__(self, _id: str, _type: str, sourceNodeId: str, targetNodeId: str, **keywords):
- self.id = _id
- self.type = _type
- self.sourceNodeId = sourceNodeId
- self.targetNodeId = targetNodeId
- for keyword in keywords:
- self.__setattr__(keyword, keywords.get(keyword))
-
-
-class Node:
- def __init__(self, _id: str, _type: str, x: int, y: int, properties: dict, **kwargs):
- self.id = _id
- self.type = _type
- self.x = x
- self.y = y
- self.properties = properties
- for keyword in kwargs:
- self.__setattr__(keyword, kwargs.get(keyword))
-
-
-class EdgeNode:
- edge: Edge
- node: Node
-
- def __init__(self, edge, node):
- self.edge = edge
- self.node = node
-
-
-class WorkflowMode(Enum):
- APPLICATION = "application"
-
- APPLICATION_LOOP = "application-loop"
-
- KNOWLEDGE = "knowledge"
-
- KNOWLEDGE_LOOP = "knowledge-loop"
-
- TOOL = "tool"
-
- TOOL_LOOP = "tool-loop"
-
-
-class Workflow:
- """
- 节点列表
- """
- nodes: List[Node]
- """
- 线列表
- """
- edges: List[Edge]
- """
- 节点id:node
- """
- node_map: Dict[str, Node]
- """
- 节点id:当前节点id上面的所有节点
- """
- up_node_map: Dict[str, List[EdgeNode]]
- """
- 节点id:当前节点id下面的所有节点
- """
- next_node_map: Dict[str, List[EdgeNode]]
-
- workflow_mode: WorkflowMode
-
- def __init__(self, nodes: List[Node], edges: List[Edge],
- workflow_mode: WorkflowMode = WorkflowMode.APPLICATION.value):
- self.nodes = nodes
- self.edges = edges
- self.node_map = {node.id: node for node in nodes}
-
- self.up_node_map = {key: [EdgeNode(edge, self.node_map.get(edge.sourceNodeId)) for
- edge in edges] for
- key, edges in
- group_by(edges, key=lambda edge: edge.targetNodeId).items()}
-
- self.next_node_map = {key: [EdgeNode(edge, self.node_map.get(edge.targetNodeId)) for edge in edges] for
- key, edges in
- group_by(edges, key=lambda edge: edge.sourceNodeId).items()}
- self.workflow_mode = workflow_mode
-
- def get_node(self, node_id):
- """
- 根据node_id 获取节点信息
- @param node_id: node_id
- @return: 节点信息
- """
- return self.node_map.get(node_id)
-
- def get_up_edge_nodes(self, node_id) -> List[EdgeNode]:
- """
- 根据节点id 获取当前连接前置节点和连线
- @param node_id: 节点id
- @return: 节点连线列表
- """
- return self.up_node_map.get(node_id)
-
- def get_next_edge_nodes(self, node_id) -> List[EdgeNode]:
- """
- 根据节点id 获取当前连接目标节点和连线
- @param node_id: 节点id
- @return: 节点连线列表
- """
- return self.next_node_map.get(node_id)
-
- def get_up_nodes(self, node_id) -> List[Node]:
- """
- 根据节点id 获取当前连接前置节点
- @param node_id: 节点id
- @return: 节点列表
- """
- return [en.node for en in (self.up_node_map.get(node_id) or [])]
-
- def get_next_nodes(self, node_id) -> List[Node]:
- """
- 根据节点id 获取当前连接目标节点
- @param node_id: 节点id
- @return: 节点列表
- """
- return [en.node for en in self.next_node_map.get(node_id, [])]
-
- @staticmethod
- def new_instance(flow_obj: Dict, workflow_mode: WorkflowMode = WorkflowMode.APPLICATION):
- nodes = flow_obj.get('nodes')
- edges = flow_obj.get('edges')
- nodes = [Node(node.get('id'), node.get('type'), **node)
- for node in nodes]
- edges = [Edge(edge.get('id'), edge.get('type'), **edge) for edge in edges]
- return Workflow(nodes, edges, workflow_mode)
-
- def get_start_node(self):
- return self.get_node('start-node')
-
- def get_search_node(self):
- return [node for node in self.nodes if node.type == 'search-dataset-node']
-
- def is_valid(self):
- """
- 校验工作流数据
- """
- self.is_valid_model_params()
- self.is_valid_start_node()
- self.is_valid_base_node()
- self.is_valid_work_flow()
-
- def is_valid_node_params(self, node: Node):
- from application.flow.step_node import get_node
- get_node(node.type, self.workflow_mode)(node, None, None)
-
- def is_valid_node(self, node: Node):
- self.is_valid_node_params(node)
- if node.type == 'condition-node':
- branch_list = node.properties.get('node_data').get('branch')
- for branch in branch_list:
- source_anchor_id = f"{node.id}_{branch.get('id')}_right"
- edge_list = [edge for edge in self.edges if edge.sourceAnchorId == source_anchor_id]
- if len(edge_list) == 0:
- raise AppApiException(500,
- _('The branch {branch} of the {node} node needs to be connected').format(
- node=node.properties.get("stepName"), branch=branch.get("type")))
-
- else:
- edge_list = [edge for edge in self.edges if edge.sourceNodeId == node.id]
- if len(edge_list) == 0 and not end_nodes.__contains__(node.type):
- raise AppApiException(500, _("{node} Nodes cannot be considered as end nodes").format(
- node=node.properties.get("stepName")))
-
- def is_valid_work_flow(self, up_node=None):
- if up_node is None:
- up_node = self.get_start_node()
- self.is_valid_node(up_node)
- next_nodes = self.get_next_nodes(up_node)
- for next_node in next_nodes:
- self.is_valid_work_flow(next_node)
-
- def is_valid_start_node(self):
- start_node_list = [node for node in self.nodes if node.id == 'start-node']
- if len(start_node_list) == 0:
- raise AppApiException(500, _('The starting node is required'))
- if len(start_node_list) > 1:
- raise AppApiException(500, _('There can only be one starting node'))
-
- def is_valid_model_params(self):
- node_list = [node for node in self.nodes if (
- node.type == 'ai-chat-node' or node.type == 'question-node' or node.type == 'parameter-extraction-node')]
- for node in node_list:
- if (node.properties.get('node_data', {}).get('model_id_type') or 'custom') == 'reference':
- continue
- model = QuerySet(Model).filter(id=node.properties.get('node_data', {}).get('model_id')).first()
- if model is None:
- raise ValidationError(ErrorDetail(
- _('The node {node} model does not exist').format(node=node.properties.get("stepName"))))
- credential = get_model_credential(model.provider, model.model_type, model.model_name)
- model_params_setting = node.properties.get('node_data', {}).get('model_params_setting')
- model_params_setting_form = credential.get_model_params_setting_form(
- model.model_name)
- if model_params_setting is None:
- model_params_setting = model_params_setting_form.get_default_form_data()
- node.properties.get('node_data', {})['model_params_setting'] = model_params_setting
- if node.properties.get('status', 200) != 200:
- raise ValidationError(
- ErrorDetail(_("Node {node} is unavailable").format(node=node.properties.get("stepName"))))
- node_list = [node for node in self.nodes if (node.type == 'function-lib-node')]
- for node in node_list:
- function_lib_id = node.properties.get('node_data', {}).get('function_lib_id')
- if function_lib_id is None:
- raise ValidationError(ErrorDetail(
- _('The library ID of node {node} cannot be empty').format(node=node.properties.get("stepName"))))
- f_lib = QuerySet(Tool).filter(id=function_lib_id).first()
- if f_lib is None:
- raise ValidationError(ErrorDetail(_("The function library for node {node} is not available").format(
- node=node.properties.get("stepName"))))
-
- def is_valid_base_node(self):
- base_node_list = [node for node in self.nodes if node.id == 'base-node']
- if len(base_node_list) == 0:
- raise AppApiException(500, _('Basic information node is required'))
- if len(base_node_list) > 1:
- raise AppApiException(500, _('There can only be one basic information node'))
diff --git a/apps/application/flow/default_workflow.json b/apps/application/flow/default_workflow.json
deleted file mode 100644
index 48ac23c4dc6..00000000000
--- a/apps/application/flow/default_workflow.json
+++ /dev/null
@@ -1,451 +0,0 @@
-{
- "nodes": [
- {
- "id": "base-node",
- "type": "base-node",
- "x": 360,
- "y": 2810,
- "properties": {
- "config": {
-
- },
- "height": 825.6,
- "stepName": "基本信息",
- "node_data": {
- "desc": "",
- "name": "maxkbapplication",
- "prologue": "您好,我是 MaxKB 小助手,您可以向我提出 MaxKB 使用问题。\n- MaxKB 主要功能有什么?\n- MaxKB 支持哪些大语言模型?\n- MaxKB 支持哪些文档类型?"
- },
- "input_field_list": [
-
- ]
- }
- },
- {
- "id": "start-node",
- "type": "start-node",
- "x": 430,
- "y": 3660,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "用户问题",
- "value": "question"
- }
- ],
- "globalFields": [
- {
- "label": "当前时间",
- "value": "time"
- }
- ]
- },
- "fields": [
- {
- "label": "用户问题",
- "value": "question"
- }
- ],
- "height": 276,
- "stepName": "开始",
- "globalFields": [
- {
- "label": "当前时间",
- "value": "time"
- }
- ]
- }
- },
- {
- "id": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "type": "search-dataset-node",
- "x": 840,
- "y": 3210,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "检索结果的分段列表",
- "value": "paragraph_list"
- },
- {
- "label": "满足直接回答的分段列表",
- "value": "is_hit_handling_method_list"
- },
- {
- "label": "检索结果",
- "value": "data"
- },
- {
- "label": "满足直接回答的分段内容",
- "value": "directly_return"
- }
- ]
- },
- "height": 794,
- "stepName": "知识库检索",
- "node_data": {
- "dataset_id_list": [
-
- ],
- "dataset_setting": {
- "top_n": 3,
- "similarity": 0.6,
- "search_mode": "embedding",
- "max_paragraph_char_number": 5000
- },
- "question_reference_address": [
- "start-node",
- "question"
- ],
- "source_dataset_id_list": [
-
- ]
- }
- }
- },
- {
- "id": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "type": "condition-node",
- "x": 1490,
- "y": 3210,
- "properties": {
- "width": 600,
- "config": {
- "fields": [
- {
- "label": "分支名称",
- "value": "branch_name"
- }
- ]
- },
- "height": 543.675,
- "stepName": "判断器",
- "node_data": {
- "branch": [
- {
- "id": "1009",
- "type": "IF",
- "condition": "and",
- "conditions": [
- {
- "field": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "is_hit_handling_method_list"
- ],
- "value": "1",
- "compare": "len_ge"
- }
- ]
- },
- {
- "id": "4908",
- "type": "ELSE IF 1",
- "condition": "and",
- "conditions": [
- {
- "field": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "paragraph_list"
- ],
- "value": "1",
- "compare": "len_ge"
- }
- ]
- },
- {
- "id": "161",
- "type": "ELSE",
- "condition": "and",
- "conditions": [
-
- ]
- }
- ]
- },
- "branch_condition_list": [
- {
- "index": 0,
- "height": 121.225,
- "id": "1009"
- },
- {
- "index": 1,
- "height": 121.225,
- "id": "4908"
- },
- {
- "index": 2,
- "height": 44,
- "id": "161"
- }
- ]
- }
- },
- {
- "id": "4ffe1086-25df-4c85-b168-979b5bbf0a26",
- "type": "reply-node",
- "x": 2170,
- "y": 2480,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "内容",
- "value": "answer"
- }
- ]
- },
- "height": 378,
- "stepName": "指定回复",
- "node_data": {
- "fields": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "directly_return"
- ],
- "content": "",
- "reply_type": "referencing",
- "is_result": true
- }
- }
- },
- {
- "id": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb",
- "type": "ai-chat-node",
- "x": 2160,
- "y": 3200,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "AI 回答内容",
- "value": "answer"
- }
- ]
- },
- "height": 763,
- "stepName": "AI 对话",
- "node_data": {
- "prompt": "已知信息:\n{{知识库检索.data}}\n问题:\n{{开始.question}}",
- "system": "",
- "model_id": "",
- "dialogue_number": 0,
- "is_result": true
- }
- }
- },
- {
- "id": "309d0eef-c597-46b5-8d51-b9a28aaef4c7",
- "type": "ai-chat-node",
- "x": 2160,
- "y": 3970,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "AI 回答内容",
- "value": "answer"
- }
- ]
- },
- "height": 763,
- "stepName": "AI 对话1",
- "node_data": {
- "prompt": "{{开始.question}}",
- "system": "",
- "model_id": "",
- "dialogue_number": 0,
- "is_result": true
- }
- }
- }
- ],
- "edges": [
- {
- "id": "7d0f166f-c472-41b2-b9a2-c294f4c83d73",
- "type": "app-edge",
- "sourceNodeId": "start-node",
- "targetNodeId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "startPoint": {
- "x": 590,
- "y": 3660
- },
- "endPoint": {
- "x": 680,
- "y": 3210
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 590,
- "y": 3660
- },
- {
- "x": 700,
- "y": 3660
- },
- {
- "x": 570,
- "y": 3210
- },
- {
- "x": 680,
- "y": 3210
- }
- ],
- "sourceAnchorId": "start-node_right",
- "targetAnchorId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5_left"
- },
- {
- "id": "35cb86dd-f328-429e-a973-12fd7218b696",
- "type": "app-edge",
- "sourceNodeId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "targetNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "startPoint": {
- "x": 1000,
- "y": 3210
- },
- "endPoint": {
- "x": 1200,
- "y": 3210
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1000,
- "y": 3210
- },
- {
- "x": 1110,
- "y": 3210
- },
- {
- "x": 1090,
- "y": 3210
- },
- {
- "x": 1200,
- "y": 3210
- }
- ],
- "sourceAnchorId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5_right",
- "targetAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_left"
- },
- {
- "id": "e8f6cfe6-7e48-41cd-abd3-abfb5304d0d8",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "4ffe1086-25df-4c85-b168-979b5bbf0a26",
- "startPoint": {
- "x": 1780,
- "y": 3073.775
- },
- "endPoint": {
- "x": 2010,
- "y": 2480
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3073.775
- },
- {
- "x": 1890,
- "y": 3073.775
- },
- {
- "x": 1900,
- "y": 2480
- },
- {
- "x": 2010,
- "y": 2480
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_1009_right",
- "targetAnchorId": "4ffe1086-25df-4c85-b168-979b5bbf0a26_left"
- },
- {
- "id": "994ff325-6f7a-4ebc-b61b-10e15519d6d2",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb",
- "startPoint": {
- "x": 1780,
- "y": 3203
- },
- "endPoint": {
- "x": 2000,
- "y": 3200
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3203
- },
- {
- "x": 1890,
- "y": 3203
- },
- {
- "x": 1890,
- "y": 3200
- },
- {
- "x": 2000,
- "y": 3200
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_4908_right",
- "targetAnchorId": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb_left"
- },
- {
- "id": "19270caf-bb9f-4ba7-9bf8-200aa70fecd5",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "309d0eef-c597-46b5-8d51-b9a28aaef4c7",
- "startPoint": {
- "x": 1780,
- "y": 3293.6124999999997
- },
- "endPoint": {
- "x": 2000,
- "y": 3970
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3293.6124999999997
- },
- {
- "x": 1890,
- "y": 3293.6124999999997
- },
- {
- "x": 1890,
- "y": 3970
- },
- {
- "x": 2000,
- "y": 3970
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_161_right",
- "targetAnchorId": "309d0eef-c597-46b5-8d51-b9a28aaef4c7_left"
- }
- ]
-}
\ No newline at end of file
diff --git a/apps/application/flow/default_workflow_en.json b/apps/application/flow/default_workflow_en.json
deleted file mode 100644
index 17c397306b9..00000000000
--- a/apps/application/flow/default_workflow_en.json
+++ /dev/null
@@ -1,451 +0,0 @@
-{
- "nodes": [
- {
- "id": "base-node",
- "type": "base-node",
- "x": 360,
- "y": 2810,
- "properties": {
- "config": {
-
- },
- "height": 825.6,
- "stepName": "Base",
- "node_data": {
- "desc": "",
- "name": "maxkbapplication",
- "prologue": "Hello, I am the MaxKB assistant. You can ask me about MaxKB usage issues.\n-What are the main functions of MaxKB?\n-What major language models does MaxKB support?\n-What document types does MaxKB support?"
- },
- "input_field_list": [
-
- ]
- }
- },
- {
- "id": "start-node",
- "type": "start-node",
- "x": 430,
- "y": 3660,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "User Question",
- "value": "question"
- }
- ],
- "globalFields": [
- {
- "label": "Current Time",
- "value": "time"
- }
- ]
- },
- "fields": [
- {
- "label": "User Question",
- "value": "question"
- }
- ],
- "height": 276,
- "stepName": "Start",
- "globalFields": [
- {
- "label": "Current Time",
- "value": "time"
- }
- ]
- }
- },
- {
- "id": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "type": "search-dataset-node",
- "x": 840,
- "y": 3210,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "List of Retrieved Paragraphs",
- "value": "paragraph_list"
- },
- {
- "label": "List of Paragraphs Satisfying Direct Answer",
- "value": "is_hit_handling_method_list"
- },
- {
- "label": "Search Results",
- "value": "data"
- },
- {
- "label": "Content of Paragraphs Satisfying Direct Answer",
- "value": "directly_return"
- }
- ]
- },
- "height": 794,
- "stepName": "Knowledge Search",
- "node_data": {
- "dataset_id_list": [
-
- ],
- "dataset_setting": {
- "top_n": 3,
- "similarity": 0.6,
- "search_mode": "embedding",
- "max_paragraph_char_number": 5000
- },
- "question_reference_address": [
- "start-node",
- "question"
- ],
- "source_dataset_id_list": [
-
- ]
- }
- }
- },
- {
- "id": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "type": "condition-node",
- "x": 1490,
- "y": 3210,
- "properties": {
- "width": 600,
- "config": {
- "fields": [
- {
- "label": "Branch Name",
- "value": "branch_name"
- }
- ]
- },
- "height": 543.675,
- "stepName": "Conditional Branch",
- "node_data": {
- "branch": [
- {
- "id": "1009",
- "type": "IF",
- "condition": "and",
- "conditions": [
- {
- "field": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "is_hit_handling_method_list"
- ],
- "value": "1",
- "compare": "len_ge"
- }
- ]
- },
- {
- "id": "4908",
- "type": "ELSE IF 1",
- "condition": "and",
- "conditions": [
- {
- "field": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "paragraph_list"
- ],
- "value": "1",
- "compare": "len_ge"
- }
- ]
- },
- {
- "id": "161",
- "type": "ELSE",
- "condition": "and",
- "conditions": [
-
- ]
- }
- ]
- },
- "branch_condition_list": [
- {
- "index": 0,
- "height": 121.225,
- "id": "1009"
- },
- {
- "index": 1,
- "height": 121.225,
- "id": "4908"
- },
- {
- "index": 2,
- "height": 44,
- "id": "161"
- }
- ]
- }
- },
- {
- "id": "4ffe1086-25df-4c85-b168-979b5bbf0a26",
- "type": "reply-node",
- "x": 2170,
- "y": 2480,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "Content",
- "value": "answer"
- }
- ]
- },
- "height": 378,
- "stepName": "Specified Reply",
- "node_data": {
- "fields": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "directly_return"
- ],
- "content": "",
- "reply_type": "referencing",
- "is_result": true
- }
- }
- },
- {
- "id": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb",
- "type": "ai-chat-node",
- "x": 2160,
- "y": 3200,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "AI Answer Content",
- "value": "answer"
- }
- ]
- },
- "height": 763,
- "stepName": "AI Chat",
- "node_data": {
- "prompt": "Known information:\n{{Knowledge Search.data}}\nQuestion:\n{{Start.question}}",
- "system": "",
- "model_id": "",
- "dialogue_number": 0,
- "is_result": true
- }
- }
- },
- {
- "id": "309d0eef-c597-46b5-8d51-b9a28aaef4c7",
- "type": "ai-chat-node",
- "x": 2160,
- "y": 3970,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "AI Answer Content",
- "value": "answer"
- }
- ]
- },
- "height": 763,
- "stepName": "AI Chat1",
- "node_data": {
- "prompt": "{{Start.question}}",
- "system": "",
- "model_id": "",
- "dialogue_number": 0,
- "is_result": true
- }
- }
- }
- ],
- "edges": [
- {
- "id": "7d0f166f-c472-41b2-b9a2-c294f4c83d73",
- "type": "app-edge",
- "sourceNodeId": "start-node",
- "targetNodeId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "startPoint": {
- "x": 590,
- "y": 3660
- },
- "endPoint": {
- "x": 680,
- "y": 3210
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 590,
- "y": 3660
- },
- {
- "x": 700,
- "y": 3660
- },
- {
- "x": 570,
- "y": 3210
- },
- {
- "x": 680,
- "y": 3210
- }
- ],
- "sourceAnchorId": "start-node_right",
- "targetAnchorId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5_left"
- },
- {
- "id": "35cb86dd-f328-429e-a973-12fd7218b696",
- "type": "app-edge",
- "sourceNodeId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "targetNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "startPoint": {
- "x": 1000,
- "y": 3210
- },
- "endPoint": {
- "x": 1200,
- "y": 3210
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1000,
- "y": 3210
- },
- {
- "x": 1110,
- "y": 3210
- },
- {
- "x": 1090,
- "y": 3210
- },
- {
- "x": 1200,
- "y": 3210
- }
- ],
- "sourceAnchorId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5_right",
- "targetAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_left"
- },
- {
- "id": "e8f6cfe6-7e48-41cd-abd3-abfb5304d0d8",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "4ffe1086-25df-4c85-b168-979b5bbf0a26",
- "startPoint": {
- "x": 1780,
- "y": 3073.775
- },
- "endPoint": {
- "x": 2010,
- "y": 2480
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3073.775
- },
- {
- "x": 1890,
- "y": 3073.775
- },
- {
- "x": 1900,
- "y": 2480
- },
- {
- "x": 2010,
- "y": 2480
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_1009_right",
- "targetAnchorId": "4ffe1086-25df-4c85-b168-979b5bbf0a26_left"
- },
- {
- "id": "994ff325-6f7a-4ebc-b61b-10e15519d6d2",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb",
- "startPoint": {
- "x": 1780,
- "y": 3203
- },
- "endPoint": {
- "x": 2000,
- "y": 3200
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3203
- },
- {
- "x": 1890,
- "y": 3203
- },
- {
- "x": 1890,
- "y": 3200
- },
- {
- "x": 2000,
- "y": 3200
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_4908_right",
- "targetAnchorId": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb_left"
- },
- {
- "id": "19270caf-bb9f-4ba7-9bf8-200aa70fecd5",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "309d0eef-c597-46b5-8d51-b9a28aaef4c7",
- "startPoint": {
- "x": 1780,
- "y": 3293.6124999999997
- },
- "endPoint": {
- "x": 2000,
- "y": 3970
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3293.6124999999997
- },
- {
- "x": 1890,
- "y": 3293.6124999999997
- },
- {
- "x": 1890,
- "y": 3970
- },
- {
- "x": 2000,
- "y": 3970
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_161_right",
- "targetAnchorId": "309d0eef-c597-46b5-8d51-b9a28aaef4c7_left"
- }
- ]
-}
\ No newline at end of file
diff --git a/apps/application/flow/default_workflow_zh.json b/apps/application/flow/default_workflow_zh.json
deleted file mode 100644
index 48ac23c4dc6..00000000000
--- a/apps/application/flow/default_workflow_zh.json
+++ /dev/null
@@ -1,451 +0,0 @@
-{
- "nodes": [
- {
- "id": "base-node",
- "type": "base-node",
- "x": 360,
- "y": 2810,
- "properties": {
- "config": {
-
- },
- "height": 825.6,
- "stepName": "基本信息",
- "node_data": {
- "desc": "",
- "name": "maxkbapplication",
- "prologue": "您好,我是 MaxKB 小助手,您可以向我提出 MaxKB 使用问题。\n- MaxKB 主要功能有什么?\n- MaxKB 支持哪些大语言模型?\n- MaxKB 支持哪些文档类型?"
- },
- "input_field_list": [
-
- ]
- }
- },
- {
- "id": "start-node",
- "type": "start-node",
- "x": 430,
- "y": 3660,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "用户问题",
- "value": "question"
- }
- ],
- "globalFields": [
- {
- "label": "当前时间",
- "value": "time"
- }
- ]
- },
- "fields": [
- {
- "label": "用户问题",
- "value": "question"
- }
- ],
- "height": 276,
- "stepName": "开始",
- "globalFields": [
- {
- "label": "当前时间",
- "value": "time"
- }
- ]
- }
- },
- {
- "id": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "type": "search-dataset-node",
- "x": 840,
- "y": 3210,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "检索结果的分段列表",
- "value": "paragraph_list"
- },
- {
- "label": "满足直接回答的分段列表",
- "value": "is_hit_handling_method_list"
- },
- {
- "label": "检索结果",
- "value": "data"
- },
- {
- "label": "满足直接回答的分段内容",
- "value": "directly_return"
- }
- ]
- },
- "height": 794,
- "stepName": "知识库检索",
- "node_data": {
- "dataset_id_list": [
-
- ],
- "dataset_setting": {
- "top_n": 3,
- "similarity": 0.6,
- "search_mode": "embedding",
- "max_paragraph_char_number": 5000
- },
- "question_reference_address": [
- "start-node",
- "question"
- ],
- "source_dataset_id_list": [
-
- ]
- }
- }
- },
- {
- "id": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "type": "condition-node",
- "x": 1490,
- "y": 3210,
- "properties": {
- "width": 600,
- "config": {
- "fields": [
- {
- "label": "分支名称",
- "value": "branch_name"
- }
- ]
- },
- "height": 543.675,
- "stepName": "判断器",
- "node_data": {
- "branch": [
- {
- "id": "1009",
- "type": "IF",
- "condition": "and",
- "conditions": [
- {
- "field": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "is_hit_handling_method_list"
- ],
- "value": "1",
- "compare": "len_ge"
- }
- ]
- },
- {
- "id": "4908",
- "type": "ELSE IF 1",
- "condition": "and",
- "conditions": [
- {
- "field": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "paragraph_list"
- ],
- "value": "1",
- "compare": "len_ge"
- }
- ]
- },
- {
- "id": "161",
- "type": "ELSE",
- "condition": "and",
- "conditions": [
-
- ]
- }
- ]
- },
- "branch_condition_list": [
- {
- "index": 0,
- "height": 121.225,
- "id": "1009"
- },
- {
- "index": 1,
- "height": 121.225,
- "id": "4908"
- },
- {
- "index": 2,
- "height": 44,
- "id": "161"
- }
- ]
- }
- },
- {
- "id": "4ffe1086-25df-4c85-b168-979b5bbf0a26",
- "type": "reply-node",
- "x": 2170,
- "y": 2480,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "内容",
- "value": "answer"
- }
- ]
- },
- "height": 378,
- "stepName": "指定回复",
- "node_data": {
- "fields": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "directly_return"
- ],
- "content": "",
- "reply_type": "referencing",
- "is_result": true
- }
- }
- },
- {
- "id": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb",
- "type": "ai-chat-node",
- "x": 2160,
- "y": 3200,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "AI 回答内容",
- "value": "answer"
- }
- ]
- },
- "height": 763,
- "stepName": "AI 对话",
- "node_data": {
- "prompt": "已知信息:\n{{知识库检索.data}}\n问题:\n{{开始.question}}",
- "system": "",
- "model_id": "",
- "dialogue_number": 0,
- "is_result": true
- }
- }
- },
- {
- "id": "309d0eef-c597-46b5-8d51-b9a28aaef4c7",
- "type": "ai-chat-node",
- "x": 2160,
- "y": 3970,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "AI 回答内容",
- "value": "answer"
- }
- ]
- },
- "height": 763,
- "stepName": "AI 对话1",
- "node_data": {
- "prompt": "{{开始.question}}",
- "system": "",
- "model_id": "",
- "dialogue_number": 0,
- "is_result": true
- }
- }
- }
- ],
- "edges": [
- {
- "id": "7d0f166f-c472-41b2-b9a2-c294f4c83d73",
- "type": "app-edge",
- "sourceNodeId": "start-node",
- "targetNodeId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "startPoint": {
- "x": 590,
- "y": 3660
- },
- "endPoint": {
- "x": 680,
- "y": 3210
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 590,
- "y": 3660
- },
- {
- "x": 700,
- "y": 3660
- },
- {
- "x": 570,
- "y": 3210
- },
- {
- "x": 680,
- "y": 3210
- }
- ],
- "sourceAnchorId": "start-node_right",
- "targetAnchorId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5_left"
- },
- {
- "id": "35cb86dd-f328-429e-a973-12fd7218b696",
- "type": "app-edge",
- "sourceNodeId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "targetNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "startPoint": {
- "x": 1000,
- "y": 3210
- },
- "endPoint": {
- "x": 1200,
- "y": 3210
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1000,
- "y": 3210
- },
- {
- "x": 1110,
- "y": 3210
- },
- {
- "x": 1090,
- "y": 3210
- },
- {
- "x": 1200,
- "y": 3210
- }
- ],
- "sourceAnchorId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5_right",
- "targetAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_left"
- },
- {
- "id": "e8f6cfe6-7e48-41cd-abd3-abfb5304d0d8",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "4ffe1086-25df-4c85-b168-979b5bbf0a26",
- "startPoint": {
- "x": 1780,
- "y": 3073.775
- },
- "endPoint": {
- "x": 2010,
- "y": 2480
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3073.775
- },
- {
- "x": 1890,
- "y": 3073.775
- },
- {
- "x": 1900,
- "y": 2480
- },
- {
- "x": 2010,
- "y": 2480
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_1009_right",
- "targetAnchorId": "4ffe1086-25df-4c85-b168-979b5bbf0a26_left"
- },
- {
- "id": "994ff325-6f7a-4ebc-b61b-10e15519d6d2",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb",
- "startPoint": {
- "x": 1780,
- "y": 3203
- },
- "endPoint": {
- "x": 2000,
- "y": 3200
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3203
- },
- {
- "x": 1890,
- "y": 3203
- },
- {
- "x": 1890,
- "y": 3200
- },
- {
- "x": 2000,
- "y": 3200
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_4908_right",
- "targetAnchorId": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb_left"
- },
- {
- "id": "19270caf-bb9f-4ba7-9bf8-200aa70fecd5",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "309d0eef-c597-46b5-8d51-b9a28aaef4c7",
- "startPoint": {
- "x": 1780,
- "y": 3293.6124999999997
- },
- "endPoint": {
- "x": 2000,
- "y": 3970
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3293.6124999999997
- },
- {
- "x": 1890,
- "y": 3293.6124999999997
- },
- {
- "x": 1890,
- "y": 3970
- },
- {
- "x": 2000,
- "y": 3970
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_161_right",
- "targetAnchorId": "309d0eef-c597-46b5-8d51-b9a28aaef4c7_left"
- }
- ]
-}
\ No newline at end of file
diff --git a/apps/application/flow/default_workflow_zh_Hant.json b/apps/application/flow/default_workflow_zh_Hant.json
deleted file mode 100644
index 9cac9a54dc6..00000000000
--- a/apps/application/flow/default_workflow_zh_Hant.json
+++ /dev/null
@@ -1,451 +0,0 @@
-{
- "nodes": [
- {
- "id": "base-node",
- "type": "base-node",
- "x": 360,
- "y": 2810,
- "properties": {
- "config": {
-
- },
- "height": 825.6,
- "stepName": "基本資訊",
- "node_data": {
- "desc": "",
- "name": "maxkbapplication",
- "prologue": "您好,我是 MaxKB 小助手,您可以向我提出 MaxKB 使用問題。\n- MaxKB 主要功能有哪些?\n- MaxKB 支援哪些大型語言模型?\n- MaxKB 支援哪些文件類型?"
- },
- "input_field_list": [
-
- ]
- }
- },
- {
- "id": "start-node",
- "type": "start-node",
- "x": 430,
- "y": 3660,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "用戶問題",
- "value": "question"
- }
- ],
- "globalFields": [
- {
- "label": "當前時間",
- "value": "time"
- }
- ]
- },
- "fields": [
- {
- "label": "用戶問題",
- "value": "question"
- }
- ],
- "height": 276,
- "stepName": "開始",
- "globalFields": [
- {
- "label": "當前時間",
- "value": "time"
- }
- ]
- }
- },
- {
- "id": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "type": "search-dataset-node",
- "x": 840,
- "y": 3210,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "檢索結果的分段列表",
- "value": "paragraph_list"
- },
- {
- "label": "滿足直接回答的分段列表",
- "value": "is_hit_handling_method_list"
- },
- {
- "label": "檢索結果",
- "value": "data"
- },
- {
- "label": "滿足直接回答的分段內容",
- "value": "directly_return"
- }
- ]
- },
- "height": 794,
- "stepName": "知識庫檢索",
- "node_data": {
- "dataset_id_list": [
-
- ],
- "dataset_setting": {
- "top_n": 3,
- "similarity": 0.6,
- "search_mode": "embedding",
- "max_paragraph_char_number": 5000
- },
- "question_reference_address": [
- "start-node",
- "question"
- ],
- "source_dataset_id_list": [
-
- ]
- }
- }
- },
- {
- "id": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "type": "condition-node",
- "x": 1490,
- "y": 3210,
- "properties": {
- "width": 600,
- "config": {
- "fields": [
- {
- "label": "分支名稱",
- "value": "branch_name"
- }
- ]
- },
- "height": 543.675,
- "stepName": "判斷器",
- "node_data": {
- "branch": [
- {
- "id": "1009",
- "type": "IF",
- "condition": "and",
- "conditions": [
- {
- "field": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "is_hit_handling_method_list"
- ],
- "value": "1",
- "compare": "len_ge"
- }
- ]
- },
- {
- "id": "4908",
- "type": "ELSE IF 1",
- "condition": "and",
- "conditions": [
- {
- "field": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "paragraph_list"
- ],
- "value": "1",
- "compare": "len_ge"
- }
- ]
- },
- {
- "id": "161",
- "type": "ELSE",
- "condition": "and",
- "conditions": [
-
- ]
- }
- ]
- },
- "branch_condition_list": [
- {
- "index": 0,
- "height": 121.225,
- "id": "1009"
- },
- {
- "index": 1,
- "height": 121.225,
- "id": "4908"
- },
- {
- "index": 2,
- "height": 44,
- "id": "161"
- }
- ]
- }
- },
- {
- "id": "4ffe1086-25df-4c85-b168-979b5bbf0a26",
- "type": "reply-node",
- "x": 2170,
- "y": 2480,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "內容",
- "value": "answer"
- }
- ]
- },
- "height": 378,
- "stepName": "指定回覆",
- "node_data": {
- "fields": [
- "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "directly_return"
- ],
- "content": "",
- "reply_type": "referencing",
- "is_result": true
- }
- }
- },
- {
- "id": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb",
- "type": "ai-chat-node",
- "x": 2160,
- "y": 3200,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "AI 回答內容",
- "value": "answer"
- }
- ]
- },
- "height": 763,
- "stepName": "AI 對話",
- "node_data": {
- "prompt": "已知資訊:\n{{知識庫檢索.data}}\n問題:\n{{開始.question}}",
- "system": "",
- "model_id": "",
- "dialogue_number": 0,
- "is_result": true
- }
- }
- },
- {
- "id": "309d0eef-c597-46b5-8d51-b9a28aaef4c7",
- "type": "ai-chat-node",
- "x": 2160,
- "y": 3970,
- "properties": {
- "config": {
- "fields": [
- {
- "label": "AI 回答內容",
- "value": "answer"
- }
- ]
- },
- "height": 763,
- "stepName": "AI 對話1",
- "node_data": {
- "prompt": "{{開始.question}}",
- "system": "",
- "model_id": "",
- "dialogue_number": 0,
- "is_result": true
- }
- }
- }
- ],
- "edges": [
- {
- "id": "7d0f166f-c472-41b2-b9a2-c294f4c83d73",
- "type": "app-edge",
- "sourceNodeId": "start-node",
- "targetNodeId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "startPoint": {
- "x": 590,
- "y": 3660
- },
- "endPoint": {
- "x": 680,
- "y": 3210
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 590,
- "y": 3660
- },
- {
- "x": 700,
- "y": 3660
- },
- {
- "x": 570,
- "y": 3210
- },
- {
- "x": 680,
- "y": 3210
- }
- ],
- "sourceAnchorId": "start-node_right",
- "targetAnchorId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5_left"
- },
- {
- "id": "35cb86dd-f328-429e-a973-12fd7218b696",
- "type": "app-edge",
- "sourceNodeId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5",
- "targetNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "startPoint": {
- "x": 1000,
- "y": 3210
- },
- "endPoint": {
- "x": 1200,
- "y": 3210
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1000,
- "y": 3210
- },
- {
- "x": 1110,
- "y": 3210
- },
- {
- "x": 1090,
- "y": 3210
- },
- {
- "x": 1200,
- "y": 3210
- }
- ],
- "sourceAnchorId": "b931efe5-5b66-46e0-ae3b-0160cb18eeb5_right",
- "targetAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_left"
- },
- {
- "id": "e8f6cfe6-7e48-41cd-abd3-abfb5304d0d8",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "4ffe1086-25df-4c85-b168-979b5bbf0a26",
- "startPoint": {
- "x": 1780,
- "y": 3073.775
- },
- "endPoint": {
- "x": 2010,
- "y": 2480
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3073.775
- },
- {
- "x": 1890,
- "y": 3073.775
- },
- {
- "x": 1900,
- "y": 2480
- },
- {
- "x": 2010,
- "y": 2480
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_1009_right",
- "targetAnchorId": "4ffe1086-25df-4c85-b168-979b5bbf0a26_left"
- },
- {
- "id": "994ff325-6f7a-4ebc-b61b-10e15519d6d2",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb",
- "startPoint": {
- "x": 1780,
- "y": 3203
- },
- "endPoint": {
- "x": 2000,
- "y": 3200
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3203
- },
- {
- "x": 1890,
- "y": 3203
- },
- {
- "x": 1890,
- "y": 3200
- },
- {
- "x": 2000,
- "y": 3200
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_4908_right",
- "targetAnchorId": "f1f1ee18-5a02-46f6-b4e6-226253cdffbb_left"
- },
- {
- "id": "19270caf-bb9f-4ba7-9bf8-200aa70fecd5",
- "type": "app-edge",
- "sourceNodeId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b",
- "targetNodeId": "309d0eef-c597-46b5-8d51-b9a28aaef4c7",
- "startPoint": {
- "x": 1780,
- "y": 3293.6124999999997
- },
- "endPoint": {
- "x": 2000,
- "y": 3970
- },
- "properties": {
-
- },
- "pointsList": [
- {
- "x": 1780,
- "y": 3293.6124999999997
- },
- {
- "x": 1890,
- "y": 3293.6124999999997
- },
- {
- "x": 1890,
- "y": 3970
- },
- {
- "x": 2000,
- "y": 3970
- }
- ],
- "sourceAnchorId": "fc60863a-dec2-4854-9e5a-7a44b7187a2b_161_right",
- "targetAnchorId": "309d0eef-c597-46b5-8d51-b9a28aaef4c7_left"
- }
- ]
-}
\ No newline at end of file
diff --git a/apps/application/flow/i_step_node.py b/apps/application/flow/i_step_node.py
deleted file mode 100644
index c0fb5e68ef9..00000000000
--- a/apps/application/flow/i_step_node.py
+++ /dev/null
@@ -1,380 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_step_node.py
- @date:2024/6/3 14:57
- @desc:
-"""
-import time
-import uuid
-from abc import abstractmethod
-from hashlib import sha1
-from typing import Type, Dict, List
-
-from django.core import cache
-from django.db.models import QuerySet
-from rest_framework import serializers
-from rest_framework.exceptions import ValidationError, ErrorDetail
-
-from application.flow.common import Answer, NodeChunk
-from application.long_term_memory import extract_long_term_memory
-from application.models import ApplicationChatUserStats
-from application.models import ChatRecord, ChatUserType
-from common.field.common import InstanceField
-from knowledge.models.knowledge_action import KnowledgeAction, State
-from tools.models import ToolRecord
-
-chat_cache = cache
-
-
-def write_context(step_variable: Dict, global_variable: Dict, node, workflow):
- if step_variable is not None:
- for key in step_variable:
- node.context[key] = step_variable[key]
- if workflow.is_result(node, NodeResult(step_variable, global_variable)) and 'answer' in step_variable:
- answer = step_variable['answer']
- yield answer
- node.answer_text = answer
- if global_variable is not None:
- for key in global_variable:
- workflow.context[key] = global_variable[key]
- node.context['run_time'] = time.time() - node.context['start_time']
-
-
-def is_interrupt(node, step_variable: Dict, global_variable: Dict):
- return node.type == 'form-node' and not node.context.get('is_submit', False)
-
-
-class WorkFlowPostHandler:
- def __init__(self, chat_info):
- self.chat_info = chat_info
-
- def handler(self, workflow):
- workflow_body = workflow.get_body()
- question = workflow_body.get('question')
- chat_record_id = workflow_body.get('chat_record_id')
- chat_id = workflow_body.get('chat_id')
- details = workflow.get_runtime_details()
- message_tokens = sum([row.get('message_tokens') for row in details.values() if
- 'message_tokens' in row and row.get('message_tokens') is not None])
- answer_tokens = sum([row.get('answer_tokens') for row in details.values() if
- 'answer_tokens' in row and row.get('answer_tokens') is not None])
- answer_text_list = workflow.get_answer_text_list()
- answer_text = '\n\n'.join(
- '\n\n'.join([a.get('content') for a in answer]) for answer in
- answer_text_list)
- if workflow.chat_record is not None:
- chat_record = workflow.chat_record
- chat_record.problem_text = question
- chat_record.answer_text = answer_text
- chat_record.details = details
- chat_record.message_tokens = message_tokens
- chat_record.answer_tokens = answer_tokens
- chat_record.answer_text_list = answer_text_list
- chat_record.run_time = time.time() - workflow.context['start_time']
- else:
- chat_record = ChatRecord(id=chat_record_id,
- chat_id=chat_id,
- problem_text=question,
- answer_text=answer_text,
- details=details,
- message_tokens=message_tokens,
- answer_tokens=answer_tokens,
- answer_text_list=answer_text_list,
- run_time=time.time() - workflow.context.get('start_time') if workflow.context.get(
- 'start_time') is not None else 0,
- index=0,
- ip_address=self.chat_info.ip_address,
- source=self.chat_info.source)
-
- self.chat_info.append_chat_record(chat_record)
- self.chat_info.set_cache()
-
- if not self.chat_info.debug and [ChatUserType.ANONYMOUS_USER.value, ChatUserType.CHAT_USER.value].__contains__(
- workflow_body.get('chat_user_type')):
- application_public_access_client = (QuerySet(ApplicationChatUserStats)
- .filter(chat_user_id=workflow_body.get('chat_user_id'),
- chat_user_type=workflow_body.get('chat_user_type'),
- application_id=self.chat_info.application_id).first())
- if application_public_access_client is not None:
- application_public_access_client.access_num = application_public_access_client.access_num + 1
- application_public_access_client.intraday_access_num = application_public_access_client.intraday_access_num + 1
- application_public_access_client.save()
- self.chat_info = None
-
- extract_long_term_memory.apply_async(
- args=(
- workflow_body.get('workspace_id'),
- workflow_body.get('application_id'),
- workflow_body.get('chat_user_id'),
- ),
- countdown=1,
- )
-
-
-class KnowledgeWorkflowPostHandler(WorkFlowPostHandler):
- def __init__(self, chat_info, knowledge_action_id):
- super().__init__(chat_info)
- self.knowledge_action_id = knowledge_action_id
-
- def handler(self, workflow):
- state = get_workflow_state(workflow)
- QuerySet(KnowledgeAction).filter(id=self.knowledge_action_id).update(
- state=state,
- run_time=time.time() - workflow.context.get('start_time') if workflow.context.get(
- 'start_time') is not None else 0)
-
-
-def get_tool_workflow_state(workflow):
- if workflow.is_the_task_interrupted():
- return State.REVOKED
- details = workflow.get_runtime_details()
- node_list = details.values()
- all_node = [*node_list, *get_loop_workflow_node(node_list)]
- err = any([True for value in all_node if value.get('status') == 500 and not value.get('enableException')])
- if err:
- return State.FAILURE
- return State.SUCCESS
-
-
-class ToolWorkflowCallPostHandler(WorkFlowPostHandler):
- def __init__(self, chat_info, tool_id):
- super().__init__(chat_info)
- self.tool_id = tool_id
-
- def handler(self, workflow):
- self.chat_info = None
- self.tool_id = None
-
-
-class ToolWorkflowPostHandler(WorkFlowPostHandler):
- def __init__(self, chat_info, tool_id):
- super().__init__(chat_info)
- self.tool_id = tool_id
-
- def handler(self, workflow):
- state = get_tool_workflow_state(workflow)
- record = ToolRecord(id=self.chat_info.tool_record_id, tool_id=self.tool_id,
- workspace_id=self.chat_info.workspace_id,
- source_type=self.chat_info.source_type,
- source_id=self.chat_info.source_id,
- state=state,
- run_time=time.time() - workflow.context.get('start_time') if workflow.context.get(
- 'start_time') is not None else 0,
- meta={
- 'input_field_list': workflow.get_input_field_list(),
- 'output_field_list': workflow.get_output_field_list(),
- 'input': workflow.get_input(),
- 'output': workflow.out_context,
- 'details': workflow.get_runtime_details(),
- 'answer_text_list': workflow.get_answer_text_list()
- })
- self.chat_info.set_record(record)
- self.chat_info = None
- self.tool_id = None
-
-
-def get_loop_workflow_node(node_list):
- result = []
- for item in node_list:
- if item.get('type') == 'loop-node':
- for loop_item in item.get('loop_node_data') or []:
- for inner_item in loop_item.values():
- result.append(inner_item)
- return result
-
-
-def get_workflow_state(workflow):
- if workflow.is_the_task_interrupted():
- return State.REVOKED
- details = workflow.get_runtime_details()
- node_list = details.values()
- all_node = [*node_list, *get_loop_workflow_node(node_list)]
- err = any([True for value in all_node if value.get('status') == 500 and not value.get('enableException')])
- if err:
- return State.FAILURE
- write_is_exist = any([True for value in all_node if value.get('type') == 'knowledge-write-node'])
- if not write_is_exist:
- return State.FAILURE
- return State.SUCCESS
-
-
-class NodeResult:
- def __init__(self, node_variable: Dict, workflow_variable: Dict,
- _write_context=write_context, _is_interrupt=is_interrupt):
- self._write_context = _write_context
- self.node_variable = node_variable
- self.workflow_variable = workflow_variable
- self._is_interrupt = _is_interrupt
-
- def write_context(self, node, workflow):
- return self._write_context(self.node_variable, self.workflow_variable, node, workflow)
-
- def is_assertion_result(self):
- return 'branch_id' in self.node_variable
-
- def is_interrupt_exec(self, current_node):
- """
- 是否中断执行
- @param current_node:
- @return:
- """
- return self._is_interrupt(current_node, self.node_variable, self.workflow_variable)
-
-
-class ReferenceAddressSerializer(serializers.Serializer):
- node_id = serializers.CharField(required=True, label="节点id")
- fields = serializers.ListField(
- child=serializers.CharField(required=True, label="节点字段"), required=True,
- label="节点字段数组")
-
-
-class FlowParamsSerializer(serializers.Serializer):
- # 历史对答
- history_chat_record = serializers.ListField(child=InstanceField(model_type=ChatRecord, required=True),
- label="历史对答")
-
- question = serializers.CharField(required=True, label="用户问题")
-
- chat_id = serializers.CharField(required=True, label="对话id")
-
- chat_record_id = serializers.CharField(required=True, label="对话记录id")
-
- stream = serializers.BooleanField(required=True, label="流式输出")
-
- chat_user_id = serializers.CharField(required=False, label="对话用户id")
-
- chat_user_type = serializers.CharField(required=False, label="对话用户类型")
-
- workspace_id = serializers.CharField(required=True, label="工作空间id")
-
- application_id = serializers.CharField(required=True, label="应用id")
-
- re_chat = serializers.BooleanField(required=True, label="换个答案")
-
- debug = serializers.BooleanField(required=True, label="是否debug")
-
-
-class KnowledgeFlowParamsSerializer(serializers.Serializer):
- knowledge_id = serializers.UUIDField(required=True, label="知识库id")
- workspace_id = serializers.CharField(required=True, label="工作空间id")
- knowledge_action_id = serializers.UUIDField(required=True, label="知识库任务执行器id")
- data_source = serializers.DictField(required=True, label="数据源")
- knowledge_base = serializers.DictField(required=False, label="知识库设置")
- user_id = serializers.UUIDField(required=False, label="创建人")
-
-
-class ToolFlowParamsSerializer(serializers.Serializer):
- tool_id = serializers.UUIDField(required=True, label="工具id")
- workspace_id = serializers.CharField(required=True, label="工作空间id")
-
-
-class INode:
- view_type = 'many_view'
-
- @abstractmethod
- def save_context(self, details, workflow_manage):
- pass
-
- def get_answer_list(self) -> List[Answer] | None:
- if self.answer_text is None:
- return None
- reasoning_content_enable = self.context.get('model_setting', {}).get('reasoning_content_enable', False)
- return [
- Answer(self.answer_text, self.view_type, self.runtime_node_id, self.workflow_params.get('chat_record_id'),
- {},
- self.runtime_node_id, self.context.get('reasoning_content', '') if reasoning_content_enable else '')]
-
- def __init__(self, node, workflow_params, workflow_manage, up_node_id_list=None,
- get_node_params=lambda node: node.properties.get('node_data'), salt=None):
- # 当前步骤上下文,用于存储当前步骤信息
- self.status = 200
- self.err_message = ''
- self.node = node
- self.node_params = get_node_params(node)
- self.workflow_params = workflow_params
- self.workflow_manage = workflow_manage
- self.node_params_serializer = None
- self.flow_params_serializer = None
- self.context = {}
- self.answer_text = None
- self.id = node.id
- if up_node_id_list is None:
- up_node_id_list = []
- self.up_node_id_list = up_node_id_list
- self.node_chunk = NodeChunk()
- self.runtime_node_id = sha1(uuid.NAMESPACE_DNS.bytes + bytes(str(uuid.uuid5(uuid.NAMESPACE_DNS,
- "".join([*sorted(up_node_id_list),
- node.id]))),
- "utf-8")).hexdigest() + (
- "__" + str(salt) if salt is not None else '')
- self.extra = {}
-
- def valid_args(self, node_params, flow_params):
- flow_params_serializer_class = self.get_flow_params_serializer_class()
- node_params_serializer_class = self.get_node_params_serializer_class()
- if flow_params_serializer_class is not None and flow_params is not None:
- self.flow_params_serializer = flow_params_serializer_class(data=flow_params)
- self.flow_params_serializer.is_valid(raise_exception=True)
- if node_params_serializer_class is not None:
- self.node_params_serializer = node_params_serializer_class(data=node_params)
- self.node_params_serializer.is_valid(raise_exception=True)
- if self.node.properties.get('status', 200) != 200:
- raise ValidationError(ErrorDetail(f'节点{self.node.properties.get("stepName")} 不可用'))
-
- def get_reference_field(self, fields: List[str]):
- return self.get_field(self.context, fields)
-
- @staticmethod
- def get_field(obj, fields: List[str]):
- for field in fields:
- value = obj.get(field)
- if value is None:
- return None
- else:
- obj = value
- return obj
-
- @abstractmethod
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- pass
-
- def get_flow_params_serializer_class(self) -> Type[serializers.Serializer]:
- return self.workflow_manage.get_params_serializer_class()
-
- def get_write_error_context(self, e):
- self.status = 500
- self.answer_text = str(e)
- self.err_message = str(e)
- current_time = time.time()
- self.context['run_time'] = current_time - (self.context.get('start_time') or current_time)
-
- def write_error_context(answer, status=200):
- pass
-
- return write_error_context
-
- def run(self) -> NodeResult:
- """
- :return: 执行结果
- """
- start_time = time.time()
- self.context['start_time'] = start_time
- result = self._run()
- self.context['run_time'] = time.time() - start_time
- return result
-
- def _run(self):
- result = self.execute()
- return result
-
- def execute(self, **kwargs) -> NodeResult:
- pass
-
- def get_details(self, index: int, **kwargs):
- """
- 运行详情
- :return: 步骤详情
- """
- return {}
diff --git a/apps/application/flow/knowledge_loop_workflow_manage.py b/apps/application/flow/knowledge_loop_workflow_manage.py
deleted file mode 100644
index 31d3ab4df25..00000000000
--- a/apps/application/flow/knowledge_loop_workflow_manage.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: workflow_manage.py
- @date:2024/1/9 17:40
- @desc:
-"""
-from application.flow.i_step_node import KnowledgeFlowParamsSerializer
-from application.flow.loop_workflow_manage import LoopWorkflowManage
-
-
-class KnowledgeLoopWorkflowManage(LoopWorkflowManage):
- def get_params_serializer_class(self):
- return KnowledgeFlowParamsSerializer
-
- def get_source_type(self):
- return "KNOWLEDGE"
-
- def get_source_id(self):
- return self.params.get('knowledge_id')
diff --git a/apps/application/flow/knowledge_workflow_manage.py b/apps/application/flow/knowledge_workflow_manage.py
deleted file mode 100644
index 98212c9ee5a..00000000000
--- a/apps/application/flow/knowledge_workflow_manage.py
+++ /dev/null
@@ -1,130 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: Knowledge_workflow_manage.py
- @date:2025/11/13 19:02
- @desc:
-"""
-import time
-import traceback
-from concurrent.futures import ThreadPoolExecutor
-
-from django.db.models import QuerySet
-from django.utils.translation import get_language
-
-from application.flow.common import Workflow
-from application.flow.i_step_node import WorkFlowPostHandler, KnowledgeFlowParamsSerializer, NodeResult
-from application.flow.workflow_manage import WorkflowManage
-from common.handle.base_to_response import BaseToResponse
-from common.handle.impl.response.system_to_response import SystemToResponse
-from knowledge.models.knowledge_action import KnowledgeAction, State
-
-executor = ThreadPoolExecutor(max_workers=200)
-
-
-class KnowledgeWorkflowManage(WorkflowManage):
-
- def __init__(self, flow: Workflow,
- params,
- work_flow_post_handler: WorkFlowPostHandler,
- base_to_response: BaseToResponse = SystemToResponse(),
- start_node_id=None,
- start_node_data=None, chat_record=None, child_node=None, is_the_task_interrupted=lambda: False):
- super().__init__(flow, params, work_flow_post_handler, base_to_response, None, None, None,
- None,
- None, None, start_node_id, start_node_data, chat_record, child_node, is_the_task_interrupted)
-
- def get_params_serializer_class(self):
- return KnowledgeFlowParamsSerializer
-
- def get_start_node(self):
- start_node_list = [node for node in self.flow.nodes if
- self.params.get('data_source', {}).get('node_id') == node.id]
- return start_node_list[0]
-
- def run(self):
- self.context['start_time'] = time.time()
- executor.submit(self._run)
-
- def _run(self):
- QuerySet(KnowledgeAction).filter(id=self.params.get('knowledge_action_id')).update(
- state=State.STARTED)
- language = get_language()
- self.run_chain_async(self.start_node, None, language)
- while self.is_run():
- pass
- self.work_flow_post_handler.handler(self)
-
- @staticmethod
- def get_node_details(current_node, node, index):
- if current_node == node:
- return {
- 'name': node.node.properties.get('stepName'),
- "index": index,
- 'run_time': 0,
- 'type': node.type,
- 'status': 202,
- 'err_message': ""
- }
-
- return node.get_details(index)
-
- def run_chain(self, current_node, node_result_future=None):
- QuerySet(KnowledgeAction).filter(id=self.params.get('knowledge_action_id')).update(
- details=self.get_runtime_details(lambda node, index: self.get_node_details(current_node, node, index)))
- if node_result_future is None:
- node_result_future = self.run_node_future(current_node)
- try:
- result = self.hand_node_result(current_node, node_result_future)
- return result
- except Exception as e:
- traceback.print_exc()
- return None
-
- def hand_node_result(self, current_node, node_result_future):
- try:
- current_result = node_result_future.result()
- result = current_result.write_context(current_node, self)
- if result is not None:
- # 阻塞获取结果
- list(result)
- if current_node.status == 500:
- enableException = current_node.node.properties.get('enableException')
- if not enableException:
- return None
- current_node.context['exception_message'] = current_node.err_message
- current_node.context['branch_id'] = 'exception'
- r = NodeResult({'branch_id': 'exception', 'exception': current_node.err_message}, {},
- _is_interrupt=lambda node, step_variable, global_variable: False)
- r.write_context(current_node, self)
- return r
- if self.is_the_task_interrupted():
- current_node.status = 201
- return None
- return current_result
- except Exception as e:
- traceback.print_exc()
- self.status = 500
- current_node.get_write_error_context(e)
- self.answer += str(e)
- if self.is_the_task_interrupted():
- current_node.status = 201
- return None
- enableException = current_node.node.properties.get('enableException')
- if enableException:
- current_node.context['exception_message'] = current_node.err_message
- current_node.context['branch_id'] = 'exception'
- return NodeResult({'branch_id': 'exception', 'exception': current_node.err_message}, {},
- _is_interrupt=lambda node, step_variable, global_variable: False)
- QuerySet(KnowledgeAction).filter(id=self.params.get('knowledge_action_id')).update(state=State.FAILURE)
- finally:
- current_node.node_chunk.end()
- QuerySet(KnowledgeAction).filter(id=self.params.get('knowledge_action_id')).update(
- details=self.get_runtime_details())
-
- def get_source_type(self):
- return "KNOWLEDGE"
-
- def get_source_id(self):
- return self.params.get('knowledge_id')
diff --git a/apps/application/flow/loop_workflow_manage.py b/apps/application/flow/loop_workflow_manage.py
deleted file mode 100644
index c236b15dcc5..00000000000
--- a/apps/application/flow/loop_workflow_manage.py
+++ /dev/null
@@ -1,199 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: workflow_manage.py
- @date:2024/1/9 17:40
- @desc:
-"""
-from concurrent.futures import ThreadPoolExecutor
-from typing import List
-
-from django.db import close_old_connections
-from django.utils.translation import get_language
-from langchain_core.prompts import PromptTemplate
-
-from application.flow.common import Workflow
-from application.flow.i_step_node import WorkFlowPostHandler, INode
-from application.flow.step_node import get_node
-from application.flow.workflow_manage import WorkflowManage
-from common.handle.base_to_response import BaseToResponse
-from common.handle.impl.response.system_to_response import SystemToResponse
-
-executor = ThreadPoolExecutor(max_workers=200)
-
-
-class NodeResultFuture:
- def __init__(self, r, e, status=200):
- self.r = r
- self.e = e
- self.status = status
-
- def result(self):
- if self.status == 200:
- return self.r
- else:
- raise self.e
-
-
-def await_result(result, timeout=1):
- try:
- result.result(timeout)
- return False
- except Exception as e:
- return True
-
-
-class NodeChunkManage:
-
- def __init__(self, work_flow):
- self.node_chunk_list = []
- self.current_node_chunk = None
- self.work_flow = work_flow
-
- def add_node_chunk(self, node_chunk):
- self.node_chunk_list.append(node_chunk)
-
- def contains(self, node_chunk):
- return self.node_chunk_list.__contains__(node_chunk)
-
- def pop(self):
- if self.current_node_chunk is None:
- try:
- current_node_chunk = self.node_chunk_list.pop(0)
- self.current_node_chunk = current_node_chunk
- except IndexError as e:
- pass
- if self.current_node_chunk is not None:
- try:
- chunk = self.current_node_chunk.chunk_list.pop(0)
- return chunk
- except IndexError as e:
- if self.current_node_chunk.is_end():
- self.current_node_chunk = None
- if self.work_flow.answer_is_not_empty():
- chunk = self.work_flow.base_to_response.to_stream_chunk_response(
- self.work_flow.params['chat_id'],
- self.work_flow.params['chat_record_id'],
- '\n\n', False, 0, 0)
- self.work_flow.append_answer('\n\n')
- return chunk
- return self.pop()
- return None
-
-
-class LoopWorkflowManage(WorkflowManage):
-
- def __init__(self, flow: Workflow,
- params,
- work_flow_post_handler: WorkFlowPostHandler,
- parentWorkflowManage,
- loop_params,
- get_loop_context,
- base_to_response: BaseToResponse = SystemToResponse(),
- start_node_id=None,
- start_node_data=None, chat_record=None, child_node=None, is_the_task_interrupted=lambda: False):
- self.parentWorkflowManage = parentWorkflowManage
- self.loop_params = loop_params
- self.get_loop_context = get_loop_context
- self.loop_field_list = []
- super().__init__(flow, params, work_flow_post_handler, base_to_response, None, None, None,
- None,
- None, None, start_node_id, start_node_data, chat_record, child_node, is_the_task_interrupted)
-
- def get_node_cls_by_id(self, node_id, up_node_id_list=None,
- get_node_params=lambda node: node.properties.get('node_data')):
- for node in self.flow.nodes:
- if node.id == node_id:
- node_instance = get_node(node.type, self.flow.workflow_mode)(node,
- self.params, self, up_node_id_list,
- get_node_params,
- salt=self.get_index())
- return node_instance
- return None
-
- def stream(self):
- close_old_connections()
- language = get_language()
- self.run_chain_async(self.start_node, None, language)
- return self.await_result(is_cleanup=False)
-
- def get_index(self):
- return self.loop_params.get('index')
-
- def get_start_node(self):
- start_node_list = [node for node in self.flow.nodes if
- ['loop-start-node'].__contains__(node.type)]
- return start_node_list[0]
-
- def get_reference_field(self, node_id: str, fields: List[str]):
- """
- @param node_id: 节点id
- @param fields: 字段
- @return:
- """
- if node_id == 'global':
- return self.parentWorkflowManage.get_reference_field(node_id, fields)
- elif node_id == 'chat':
- return self.parentWorkflowManage.get_reference_field(node_id, fields)
- elif node_id == 'loop':
- loop_context = self.get_loop_context()
- return INode.get_field(loop_context, fields)
- else:
- node = self.get_node_by_id(node_id)
- if node:
- return node.get_reference_field(fields)
- return self.parentWorkflowManage.get_reference_field(node_id, fields)
-
- def get_workflow_content(self):
- context = {
- 'global': self.context,
- 'chat': self.chat_context,
- 'loop': self.get_loop_context(),
- }
-
- for node in self.node_context:
- context[node.id] = node.context
- return context
-
- def init_fields(self):
- super().init_fields()
- loop_field_list = []
- loop_start_node = self.flow.get_node('loop-start-node')
- loop_input_field_list = loop_start_node.properties.get('loop_input_field_list')
- node_name = loop_start_node.properties.get('stepName')
- node_id = loop_start_node.id
- if loop_input_field_list is not None:
- for f in loop_input_field_list:
- loop_field_list.append(
- {'label': f.get('label'), 'value': f.get('field'), 'node_id': node_id, 'node_name': node_name})
- self.loop_field_list = loop_field_list
-
- def reset_prompt(self, prompt: str):
- prompt = super().reset_prompt(prompt)
- for field in self.loop_field_list:
- chatLabel = f"loop.{field.get('value')}"
- chatValue = f"context.get('loop').get('{field.get('value', '')}','')"
- prompt = prompt.replace(chatLabel, chatValue)
-
- prompt = self.parentWorkflowManage.reset_prompt(prompt)
- return prompt
-
- def generate_prompt(self, prompt: str):
- """
- 格式化生成提示词
- @param prompt: 提示词信息
- @return: 格式化后的提示词
- """
-
- context = {**self.get_workflow_content(), **self.parentWorkflowManage.get_workflow_content()}
- prompt = self.reset_prompt(prompt)
- prompt_template = PromptTemplate.from_template(prompt, template_format='jinja2')
- value = prompt_template.format(context=context)
- return value
-
- def get_source_type(self):
- return "APPLICATION"
-
- def get_source_id(self):
- return self.params.get('application_id')
diff --git a/apps/application/flow/step_node/__init__.py b/apps/application/flow/step_node/__init__.py
deleted file mode 100644
index 4c38020771e..00000000000
--- a/apps/application/flow/step_node/__init__.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py.py
- @date:2024/6/7 14:43
- @desc:
-"""
-from .ai_chat_step_node import *
-from .application_node import BaseApplicationNode
-from .condition_node import *
-from .data_source_local_node.impl.base_data_source_local_node import BaseDataSourceLocalNode
-from .data_source_web_node.impl.base_data_source_web_node import BaseDataSourceWebNode
-from .direct_reply_node import *
-from .document_extract_node import *
-from .form_node import *
-from .image_generate_step_node import *
-from .image_to_video_step_node import BaseImageToVideoNode
-from .image_understand_step_node import *
-from .intent_node import *
-from .knowledge_write_node.impl.base_knowledge_write_node import BaseKnowledgeWriteNode
-from .loop_break_node import BaseLoopBreakNode
-from .loop_continue_node import BaseLoopContinueNode
-from .loop_node import *
-from .loop_start_node import *
-from .mcp_node import BaseMcpNode
-from .parameter_extraction_node import BaseParameterExtractionNode
-from .question_node import *
-from .reranker_node import *
-from .search_document_node import BaseSearchDocumentNode
-from .search_knowledge_node import *
-from .speech_to_text_step_node import BaseSpeechToTextNode
-from .start_node import *
-from .text_to_speech_step_node.impl.base_text_to_speech_node import BaseTextToSpeechNode
-from .text_to_video_step_node.impl.base_text_to_video_node import BaseTextToVideoNode
-from .tool_lib_node import *
-from .tool_node import *
-from .tool_workflow_lib_node import BaseToolWorkflowLibNodeNode
-from .variable_aggregation_node.impl.base_variable_aggregation_node import BaseVariableAggregationNode
-from .variable_assign_node import BaseVariableAssignNode
-from .variable_splitting_node import BaseVariableSplittingNode
-from .video_understand_step_node import BaseVideoUnderstandNode
-from .document_split_node import BaseDocumentSplitNode
-from .tool_start_node import BaseToolStartStepNode
-
-node_list = [BaseStartStepNode, BaseChatNode, BaseSearchKnowledgeNode, BaseSearchDocumentNode, BaseQuestionNode,
- BaseConditionNode, BaseReplyNode,
- BaseToolNodeNode, BaseToolLibNodeNode, BaseRerankerNode, BaseApplicationNode,
- BaseDocumentExtractNode,
- BaseImageUnderstandNode, BaseFormNode, BaseSpeechToTextNode, BaseTextToSpeechNode,
- BaseImageGenerateNode, BaseVariableAssignNode, BaseMcpNode, BaseTextToVideoNode, BaseImageToVideoNode,
- BaseVideoUnderstandNode,
- BaseIntentNode, BaseLoopNode, BaseLoopStartStepNode,
- BaseLoopContinueNode,
- BaseLoopBreakNode, BaseVariableSplittingNode, BaseParameterExtractionNode, BaseVariableAggregationNode,
- BaseDataSourceLocalNode, BaseDataSourceWebNode, BaseKnowledgeWriteNode, BaseDocumentSplitNode,
- BaseToolStartStepNode, BaseToolWorkflowLibNodeNode]
-
-node_map = {n.type: {w: n for w in n.support} for n in node_list}
-
-
-def get_node(node_type, workflow_model):
- return node_map.get(node_type).get(workflow_model)
diff --git a/apps/application/flow/step_node/ai_chat_step_node/__init__.py b/apps/application/flow/step_node/ai_chat_step_node/__init__.py
deleted file mode 100644
index 1929ae2af49..00000000000
--- a/apps/application/flow/step_node/ai_chat_step_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:29
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/ai_chat_step_node/i_chat_node.py b/apps/application/flow/step_node/ai_chat_step_node/i_chat_node.py
deleted file mode 100644
index 0483c9cb5e7..00000000000
--- a/apps/application/flow/step_node/ai_chat_step_node/i_chat_node.py
+++ /dev/null
@@ -1,92 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_chat_node.py
- @date:2024/6/4 13:58
- @desc:
-"""
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class ChatNodeSerializer(serializers.Serializer):
- model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
- system = serializers.CharField(required=False, allow_blank=True, allow_null=True,
- label=_("Role Setting"))
- prompt = serializers.CharField(required=True, label=_("Prompt word"))
- # 多轮对话数量
- dialogue_number = serializers.IntegerField(required=True, label=_("Number of multi-round conversations"))
-
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- model_params_setting = serializers.DictField(required=False,
- label=_("Model parameter settings"))
- model_setting = serializers.DictField(required=False,
- label='Model settings')
- dialogue_type = serializers.CharField(required=False, allow_blank=True, allow_null=True,
- label=_("Context Type"))
- mcp_servers = serializers.JSONField(required=False, label=_("MCP Server"))
- mcp_tool_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("MCP Tool ID"))
- mcp_tool_ids = serializers.ListField(child=serializers.UUIDField(), required=False, allow_empty=True,
- label=_("MCP Tool IDs"), )
- mcp_source = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("MCP Source"))
-
- tool_ids = serializers.ListField(child=serializers.UUIDField(), required=False, allow_empty=True,
- label=_("Tool IDs"), )
- application_ids = serializers.ListField(child=serializers.UUIDField(), required=False, allow_empty=True,
- label=_("App IDs"), )
- skill_tool_ids = serializers.ListField(child=serializers.UUIDField(), required=False, allow_empty=True,
- label=_("Skill IDs"), )
- mcp_output_enable = serializers.BooleanField(required=False, default=True, label=_("Whether to enable MCP output"))
-
- video_list = serializers.ListField(required=False, label=_("video"))
-
- image_list = serializers.ListField(required=False, label=_("picture"))
-
- vision = serializers.BooleanField(required=False, default=False, label=_("vision"))
-
-
-class IChatNode(INode):
- type = 'ai-chat-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE_LOOP,
- WorkflowMode.KNOWLEDGE, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return ChatNodeSerializer
-
- def _run(self):
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL,
- WorkflowMode.TOOL_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data,
- **{'history_chat_record': [], 'stream': True, 'chat_id': None, 'chat_record_id': None})
- else:
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, model_id, system, prompt, dialogue_number, history_chat_record, stream, chat_id,
- chat_record_id,
- model_params_setting=None,
- model_id_type=None,
- model_id_reference=None,
- dialogue_type=None,
- model_setting=None,
- mcp_servers=None,
- mcp_tool_id=None,
- mcp_tool_ids=None,
- mcp_source=None,
- tool_ids=None,
- application_ids=None,
- skill_tool_ids=None,
- mcp_output_enable=True,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/ai_chat_step_node/impl/__init__.py b/apps/application/flow/step_node/ai_chat_step_node/impl/__init__.py
deleted file mode 100644
index 79051a999fb..00000000000
--- a/apps/application/flow/step_node/ai_chat_step_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:34
- @desc:
-"""
-from .base_chat_node import BaseChatNode
diff --git a/apps/application/flow/step_node/ai_chat_step_node/impl/base_chat_node.py b/apps/application/flow/step_node/ai_chat_step_node/impl/base_chat_node.py
deleted file mode 100644
index 5e2b94ade0c..00000000000
--- a/apps/application/flow/step_node/ai_chat_step_node/impl/base_chat_node.py
+++ /dev/null
@@ -1,500 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_question_node.py
- @date:2024/6/4 14:30
- @desc:
-"""
-import base64
-import json
-import re
-import time
-from functools import reduce
-from imghdr import what
-from typing import List, Dict
-
-from django.db.models import QuerySet
-from django.utils.translation import gettext as _
-from langchain_core.messages import BaseMessage, AIMessage, HumanMessage, SystemMessage
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import NodeResult, INode
-from application.flow.step_node.ai_chat_step_node.i_chat_node import IChatNode
-from application.flow.tools import Reasoning, mcp_response_generator, get_tools
-from application.models import Application, ApplicationApiKey, ApplicationAccessToken
-from common.exception.app_exception import AppApiException
-from common.utils.rsa_util import rsa_long_decrypt
-from common.utils.shared_resource_auth import filter_authorized_ids
-from common.utils.tool_code import ToolExecutor
-from knowledge.models import File
-from models_provider.models import Model
-from models_provider.tools import get_model_credential, get_model_instance_by_model_workspace_id
-from tools.models import Tool, ToolType
-
-
-def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow, answer: str,
- reasoning_content: str):
- chat_model = node_variable.get('chat_model')
- message_tokens = chat_model.get_num_tokens_from_messages(node_variable.get('message_list'))
- answer_tokens = chat_model.get_num_tokens(answer)
- node.context['message_tokens'] = message_tokens
- node.context['answer_tokens'] = answer_tokens
- node.context['answer'] = answer
- node.context['question'] = node_variable['question']
- node.context['run_time'] = time.time() - node.context['start_time']
- node.context['reasoning_content'] = reasoning_content
- if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
- node.answer_text = answer
-
-
-def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据 (流式)
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result')
- answer = ''
- reasoning_content = ''
- model_setting = node.context.get('model_setting',
- {'reasoning_content_enable': False, 'reasoning_content_end': '',
- 'reasoning_content_start': ''})
- reasoning = Reasoning(model_setting.get('reasoning_content_start', ''),
- model_setting.get('reasoning_content_end', ''))
- response_reasoning_content = False
-
- for chunk in response:
- if workflow.is_the_task_interrupted():
- break
- reasoning_chunk = reasoning.get_reasoning_content(chunk)
- content_chunk = reasoning_chunk.get('content')
- if 'reasoning_content' in chunk.additional_kwargs:
- response_reasoning_content = True
- reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '')
- else:
- reasoning_content_chunk = reasoning_chunk.get('reasoning_content')
- answer += content_chunk
- if reasoning_content_chunk is None:
- reasoning_content_chunk = ''
- reasoning_content += reasoning_content_chunk
- yield {'content': content_chunk,
- 'reasoning_content': reasoning_content_chunk if model_setting.get('reasoning_content_enable',
- False) else ''}
-
- reasoning_chunk = reasoning.get_end_reasoning_content()
- answer += reasoning_chunk.get('content')
- reasoning_content_chunk = ""
- if not response_reasoning_content:
- reasoning_content_chunk = reasoning_chunk.get(
- 'reasoning_content')
- yield {'content': reasoning_chunk.get('content'),
- 'reasoning_content': reasoning_content_chunk if model_setting.get('reasoning_content_enable',
- False) else ''}
- _write_context(node_variable, workflow_variable, node, workflow, answer, reasoning_content)
-
-
-def write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点实例对象
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result')
- model_setting = node.context.get('model_setting',
- {'reasoning_content_enable': False, 'reasoning_content_end': '',
- 'reasoning_content_start': ''})
- reasoning = Reasoning(model_setting.get('reasoning_content_start'), model_setting.get('reasoning_content_end'))
- reasoning_result = reasoning.get_reasoning_content(response)
- reasoning_result_end = reasoning.get_end_reasoning_content()
- content = reasoning_result.get('content') + reasoning_result_end.get('content')
- meta = {**response.response_metadata, **response.additional_kwargs}
- if 'reasoning_content' in meta:
- reasoning_content = (meta.get('reasoning_content', '') or '')
- else:
- reasoning_content = (reasoning_result.get('reasoning_content') or '') + (
- reasoning_result_end.get('reasoning_content') or '')
- _write_context(node_variable, workflow_variable, node, workflow, content, reasoning_content)
-
-
-def get_default_model_params_setting(model_id):
- model = QuerySet(Model).filter(id=model_id).first()
- credential = get_model_credential(model.provider, model.model_type, model.model_name)
- model_params_setting = credential.get_model_params_setting_form(
- model.model_name).get_default_form_data()
- return model_params_setting
-
-
-def get_node_message(chat_record, runtime_node_id):
- node_details = chat_record.get_node_details_runtime_node_id(runtime_node_id)
- if node_details is None:
- return []
- return [HumanMessage(node_details.get('question')), AIMessage(node_details.get('answer'))]
-
-
-def get_workflow_message(chat_record):
- return [chat_record.get_human_message(), chat_record.get_ai_message()]
-
-
-def get_message(chat_record, dialogue_type, runtime_node_id):
- return get_node_message(chat_record, runtime_node_id) if dialogue_type == 'NODE' else get_workflow_message(
- chat_record)
-
-
-class BaseChatNode(IChatNode):
- def save_context(self, details, workflow_manage):
- self.context['answer'] = details.get('answer')
- self.context['question'] = details.get('question')
- self.context['reasoning_content'] = details.get('reasoning_content')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
-
- def execute(self, model_id, system, prompt, dialogue_number, history_chat_record, stream, chat_id, chat_record_id,
- model_params_setting=None,
- model_id_type=None,
- model_id_reference=None,
- dialogue_type=None,
- model_setting=None,
- mcp_servers=None,
- mcp_tool_id=None,
- mcp_tool_ids=None,
- mcp_source=None,
- tool_ids=None,
- application_ids=None,
- skill_tool_ids=None,
- mcp_output_enable=True,
- **kwargs) -> NodeResult:
- if dialogue_type is None:
- dialogue_type = 'WORKFLOW'
-
- if model_id_type == 'reference' and model_id_reference:
-
- reference_data = self.workflow_manage.get_reference_field(
- model_id_reference[0],
- model_id_reference[1:],
- )
-
- if reference_data and isinstance(reference_data, dict):
- model_id = reference_data.get('model_id', model_id)
- model_params_setting = reference_data.get('model_params_setting')
- if model_id is None or model_id == '':
- raise Exception(_('Model is not allowed to be empty'))
-
- if model_params_setting is None and model_id:
- model_params_setting = get_default_model_params_setting(model_id)
-
- if model_setting is None:
- model_setting = {'reasoning_content_enable': False, 'reasoning_content_end': '',
- 'reasoning_content_start': ''}
- self.context['model_setting'] = model_setting
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id,
- **(model_params_setting or {}))
- history_message = self.get_history_message(history_chat_record, dialogue_number, dialogue_type,
- self.runtime_node_id)
- self.context['history_message'] = [{'content': message.content, 'role': message.type} for message in
- (history_message if history_message is not None else [])]
- question = self.generate_prompt_question(prompt, chat_model)
- self.context['question'] = question.content
- system = self.workflow_manage.generate_prompt(system)
- self.context['system'] = system
- message_list = self.generate_message_list(question, history_message)
- self.context['message_list'] = message_list
-
- # 过滤tool_id
- all_tool_ids = list(set(
- (mcp_tool_ids or []) +
- (tool_ids or []) +
- (skill_tool_ids or []) +
- ([mcp_tool_id] if mcp_tool_id else [])
- ))
- authorized_set = set(filter_authorized_ids('tool', all_tool_ids, workspace_id))
-
- mcp_tool_ids = [i for i in (mcp_tool_ids or []) if i in authorized_set]
- tool_ids = [i for i in (tool_ids or []) if i in authorized_set]
- skill_tool_ids = [i for i in (skill_tool_ids or []) if i in authorized_set]
- mcp_tool_id = mcp_tool_id if (mcp_tool_id and mcp_tool_id in authorized_set) else None
- # 处理 MCP 请求
- mcp_result = self._handle_mcp_request(
- mcp_source, mcp_servers, mcp_tool_id, mcp_tool_ids, tool_ids,
- application_ids, skill_tool_ids, mcp_output_enable,
- chat_model, SystemMessage(system), message_list, history_message, question, chat_id, workspace_id
- )
- if mcp_result:
- return mcp_result
- message_list = [SystemMessage(system)] + message_list
- if stream:
- r = chat_model.stream(message_list)
- return NodeResult({'result': r, 'chat_model': chat_model, 'message_list': message_list,
- 'question': question.content}, {},
- _write_context=write_context_stream)
- else:
- r = chat_model.invoke(message_list)
- return NodeResult({'result': r, 'chat_model': chat_model, 'message_list': message_list,
- 'history_message': [{'content': message.content, 'role': message.type} for message in
- (history_message if history_message is not None else [])],
- 'question': question.content}, {},
- _write_context=write_context)
-
- def _handle_mcp_request(self, mcp_source, mcp_servers, mcp_tool_id, mcp_tool_ids, tool_ids,
- application_ids, skill_tool_ids,
- mcp_output_enable, chat_model, system_prompt, message_list, history_message, question,
- chat_id, workspace_id):
-
- mcp_servers_config = {}
-
- # 迁移过来mcp_source是None
- if mcp_source is None:
- mcp_source = 'custom'
- # 兼容老数据
- if not mcp_tool_ids:
- mcp_tool_ids = []
- if mcp_tool_id:
- mcp_tool_ids = list(set(mcp_tool_ids + [mcp_tool_id]))
- if mcp_source == 'custom' and mcp_servers:
- mcp_servers_config = json.loads(mcp_servers)
- mcp_servers_config = self.handle_variables(mcp_servers_config)
- elif mcp_tool_ids:
- mcp_tools = QuerySet(Tool).filter(id__in=mcp_tool_ids).values()
- for mcp_tool in mcp_tools:
- if mcp_tool and mcp_tool['is_active']:
- mcp_servers_config = {**mcp_servers_config, **json.loads(mcp_tool['code'])}
- mcp_servers_config = self.handle_variables(mcp_servers_config)
- # 校验代码是否包括禁止的关键字
- ToolExecutor().validate_mcp_transport(json.dumps(mcp_servers_config))
-
- tool_init_params = {}
- tools = get_tools(self.workflow_manage.get_source_type(), self.workflow_manage.get_source_id(), tool_ids,
- workspace_id)
- if tool_ids and len(tool_ids) > 0: # 如果有工具ID,则将其转换为MCP
- self.context['tool_ids'] = tool_ids
- custom_tools_map = {str(t.id): t for t in
- QuerySet(Tool).filter(id__in=tool_ids, tool_type=ToolType.CUSTOM, is_active=True)}
- for tool_id in tool_ids:
- tool = custom_tools_map.get(str(tool_id))
- if tool is None:
- continue
- executor = ToolExecutor()
- if tool.init_params is not None:
- tool_init_params = json.loads(rsa_long_decrypt(tool.init_params))
- else:
- tool_init_params = {i["field"]: i.get('default_value') for i in tool.init_field_list}
- tool_config = executor.get_tool_mcp_config(tool, tool_init_params)
-
- mcp_servers_config[str(tool.id)] = tool_config
-
- if application_ids and len(application_ids) > 0:
- self.context['application_ids'] = application_ids
- apps_map = {str(a.id): a for a in
- QuerySet(Application).filter(id__in=application_ids, is_publish=True)}
- app_keys_map = {str(ak.application_id): ak for ak in
- QuerySet(ApplicationApiKey).filter(application_id__in=application_ids, is_active=True)}
- app_access_tokens_map = {str(at.application_id): at for at in
- QuerySet(ApplicationAccessToken).filter(
- application_id__in=application_ids)}
- for application_id in application_ids:
- app = apps_map.get(str(application_id))
- if app is None:
- continue
- app_key = app_keys_map.get(str(application_id))
- if app_key is not None:
- api_key = app_key.secret_key
- application_access_token = app_access_tokens_map.get(str(app_key.application_id))
- if application_access_token is not None and application_access_token.authentication:
- raise AppApiException(
- 500,
- _('Agent 【{name}】 access token authentication is not supported for agent tool').format(
- name=app.name)
- )
- else:
- raise AppApiException(
- 500,
- _('Agent Key is required for agent tool 【{name}】').format(name=app.name)
- )
- executor = ToolExecutor()
- app_config = executor.get_app_mcp_config(api_key)
- mcp_servers_config[app.name] = app_config
-
- if skill_tool_ids and len(skill_tool_ids) > 0:
- self.context['skill_tool_ids'] = skill_tool_ids
- skill_file_items = []
- skill_tools_map = {str(t.id): t for t in
- QuerySet(Tool).filter(id__in=skill_tool_ids, is_active=True)}
- for tool_id in skill_tool_ids:
- tool = skill_tools_map.get(str(tool_id))
- if tool is None:
- continue
- init_params_default_value = {i["field"]: i.get('default_value') for i in tool.init_field_list}
- if tool.init_params is not None:
- params = init_params_default_value | json.loads(rsa_long_decrypt(tool.init_params))
- else:
- params = init_params_default_value
-
- skill_file_items.append({
- 'tool_id': str(tool.id),
- 'file_id': tool.code,
- 'params': params
- })
- mcp_servers_config['skills'] = skill_file_items
-
- if len(mcp_servers_config) > 0 or len(tools) > 0:
- # 安全获取 application
- application_id = None
- tool_id = None
- knowledge_id = None
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- knowledge_id = self.workflow_params.get('knowledge_id')
- elif [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- application_id = self.workflow_manage.work_flow_post_handler.chat_info.application.id
- elif [WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP].__contains__(self.workflow_manage.flow.workflow_mode):
- tool_id = self.workflow_params.get('tool_id')
-
- source_id = application_id or knowledge_id or tool_id
- source_type = 'APPLICATION' if application_id else 'KNOWLEDGE' if knowledge_id else 'TOOL'
- r = mcp_response_generator(chat_model, system_prompt, message_list, json.dumps(mcp_servers_config),
- mcp_output_enable,
- tool_init_params, source_id, source_type, chat_id, tools)
- return NodeResult(
- {'result': r, 'chat_model': chat_model, 'message_list': message_list,
- 'history_message': [{'content': message.content, 'role': message.type} for message in
- (history_message if history_message is not None else [])],
- 'question': question.content}, {},
- _write_context=write_context_stream)
-
- return None
-
- def handle_variables(self, tool_params):
- # 处理参数中的变量
- for k, v in tool_params.items():
- if type(v) == str:
- tool_params[k] = self.workflow_manage.generate_prompt(tool_params[k])
- elif type(v) == dict:
- self.handle_variables(v)
- elif (type(v) == list) and len(v) > 0 and (type(v[0]) == str):
- tool_params[k] = self.get_reference_content(v)
- return tool_params
-
- def get_reference_content(self, fields: List[str]):
- return str(self.workflow_manage.get_reference_field(
- fields[0],
- fields[1:])) if fields else ''
-
- @staticmethod
- def get_history_message(history_chat_record, dialogue_number, dialogue_type, runtime_node_id):
- start_index = len(history_chat_record) - dialogue_number
- history_message = reduce(lambda x, y: [*x, *y], [
- get_message(history_chat_record[index], dialogue_type, runtime_node_id)
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))], [])
- for message in history_message:
- if isinstance(message.content, str):
- message.content = re.sub(r'.*?<\/form_rander>', '', message.content, flags=re.DOTALL)
- return history_message
-
- def generate_prompt_question(self, prompt, model):
- image = self.get_image()
- video = self.get_video()
- vision = self.is_vision()
- videos = []
- images = []
- if image and vision:
- images = self._process_images(image)
- if video and vision:
- videos = self._process_videos(video, model)
- return HumanMessage(
- content=[*videos, *images, {'type': 'text', 'text': self.workflow_manage.generate_prompt(prompt)}])
-
- def is_vision(self):
- if 'vision' in self.node_params_serializer.data:
- return self.node_params_serializer.data.get('vision')
- return False
-
- def get_image(self):
- if 'image_list' in self.node_params_serializer.data:
- image = self.workflow_manage.get_reference_field(self.node_params_serializer.data.get('image_list')[0],
- self.node_params_serializer.data.get('image_list')[1:])
- return image
- return None
-
- def get_video(self):
- if 'video_list' in self.node_params_serializer.data:
- video = self.workflow_manage.get_reference_field(self.node_params_serializer.data.get('video_list')[0],
- self.node_params_serializer.data.get('video_list')[1:])
- return video
- return None
-
- def _process_videos(self, image, video_model):
- videos = []
- if isinstance(image, str) and image.startswith('http'):
- videos.append({'type': 'video_url', 'video_url': {'url': image}})
- elif image is not None and len(image) > 0:
- for img in image:
- if 'file_id' in img:
- file_id = img['file_id']
- file = QuerySet(File).filter(id=file_id).first()
- url = video_model.upload_file_and_get_url(file.get_bytes(), file.file_name)
- videos.append(
- {'type': 'video_url', 'video_url': {'url': url}})
- elif 'url' in img and img['url'].startswith('http'):
- videos.append(
- {'type': 'video_url', 'video_url': {'url': img['url']}})
- return videos
-
- def _process_images(self, image):
- """
- 处理图像数据,转换为模型可识别的格式
- """
- images = []
- if isinstance(image, str) and image.startswith('http'):
- images.append({'type': 'image_url', 'image_url': {'url': image}})
- elif image is not None and len(image) > 0:
- for img in image:
- if 'file_id' in img:
- file_id = img['file_id']
- file = QuerySet(File).filter(id=file_id).first()
- image_bytes = file.get_bytes()
- base64_image = base64.b64encode(image_bytes).decode("utf-8")
- image_format = what(None, image_bytes)
- images.append(
- {'type': 'image_url', 'image_url': {'url': f'data:image/{image_format};base64,{base64_image}'}})
- elif 'url' in img and img['url'].startswith('http'):
- images.append(
- {'type': 'image_url', 'image_url': {'url': img["url"]}})
- return images
-
- def generate_message_list(self, question, history_message):
- return [*history_message, question]
-
- @staticmethod
- def reset_message_list(message_list: List[BaseMessage], answer_text):
- result = [{'role': 'user' if isinstance(message, HumanMessage) else 'ai', 'content': message.content} for
- message
- in
- message_list]
- result.append({'role': 'ai', 'content': answer_text})
- return result
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'system': self.context.get('system'),
- 'history_message': self.context.get('history_message'),
- 'question': self.context.get('question'),
- 'answer': self.context.get('answer'),
- 'reasoning_content': self.context.get('reasoning_content'),
- 'enableException': self.node.properties.get('enableException'),
- 'type': self.node.type,
- 'message_tokens': self.context.get('message_tokens'),
- 'answer_tokens': self.context.get('answer_tokens'),
- 'status': self.status,
- 'err_message': self.err_message
- }
diff --git a/apps/application/flow/step_node/application_node/__init__.py b/apps/application/flow/step_node/application_node/__init__.py
deleted file mode 100644
index d1ea91ca7f8..00000000000
--- a/apps/application/flow/step_node/application_node/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# coding=utf-8
-from .impl import *
diff --git a/apps/application/flow/step_node/application_node/i_application_node.py b/apps/application/flow/step_node/application_node/i_application_node.py
deleted file mode 100644
index 30cfd8632fc..00000000000
--- a/apps/application/flow/step_node/application_node/i_application_node.py
+++ /dev/null
@@ -1,106 +0,0 @@
-# coding=utf-8
-from typing import Type
-
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-from django.utils.translation import gettext_lazy as _
-
-from application.models import ChatSourceChoices
-
-
-class ApplicationNodeSerializer(serializers.Serializer):
- application_id = serializers.CharField(required=True, label=_("Application ID"))
- question_reference_address = serializers.ListField(required=True,
- label=_("User Questions"))
- api_input_field_list = serializers.ListField(required=False, label=_("API Input Fields"))
- user_input_field_list = serializers.ListField(required=False,
- label=_("User Input Fields"))
- image_list = serializers.ListField(required=False, label=_("picture"))
- document_list = serializers.ListField(required=False, label=_("document"))
- audio_list = serializers.ListField(required=False, label=_("Audio"))
- video_list = serializers.ListField(required=False, label=_("Video"))
- child_node = serializers.DictField(required=False, allow_null=True,
- label=_("Child Nodes"))
- node_data = serializers.DictField(required=False, allow_null=True, label=_("Form Data"))
-
-
-class IApplicationNode(INode):
- type = 'application-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return ApplicationNodeSerializer
-
- def _run(self):
- question = self.workflow_manage.get_reference_field(
- self.node_params_serializer.data.get('question_reference_address')[0],
- self.node_params_serializer.data.get('question_reference_address')[1:])
- kwargs = {}
- for api_input_field in self.node_params_serializer.data.get('api_input_field_list', []):
- value = api_input_field.get('value', [''])[0] if api_input_field.get('value') else ''
- kwargs[api_input_field['variable']] = self.workflow_manage.get_reference_field(value,
- api_input_field['value'][
- 1:]) if value != '' else ''
-
- for user_input_field in self.node_params_serializer.data.get('user_input_field_list', []):
- value = user_input_field.get('value', [''])[0] if user_input_field.get('value') else ''
- kwargs[user_input_field['field']] = self.workflow_manage.get_reference_field(value,
- user_input_field['value'][
- 1:]) if value != '' else ''
- # 判断是否包含这个属性
- app_document_list = self.node_params_serializer.data.get('document_list', [])
- if app_document_list and len(app_document_list) > 0:
- app_document_list = self.workflow_manage.get_reference_field(
- app_document_list[0],
- app_document_list[1:])
- for document in app_document_list:
- if 'file_id' not in document:
- raise ValueError(
- _("Parameter value error: The uploaded document lacks file_id, and the document upload fails"))
- app_image_list = self.node_params_serializer.data.get('image_list', [])
- if app_image_list and len(app_image_list) > 0:
- app_image_list = self.workflow_manage.get_reference_field(
- app_image_list[0],
- app_image_list[1:])
- for image in app_image_list:
- if 'file_id' not in image:
- raise ValueError(
- _("Parameter value error: The uploaded image lacks file_id, and the image upload fails"))
-
- app_audio_list = self.node_params_serializer.data.get('audio_list', [])
- if app_audio_list and len(app_audio_list) > 0:
- app_audio_list = self.workflow_manage.get_reference_field(
- app_audio_list[0],
- app_audio_list[1:])
- for audio in app_audio_list:
- if 'file_id' not in audio:
- raise ValueError(
- _("Parameter value error: The uploaded audio lacks file_id, and the audio upload fails."))
- app_video_list = self.node_params_serializer.data.get('video_list', [])
- if app_video_list and len(app_video_list) > 0:
- app_video_list = self.workflow_manage.get_reference_field(
- app_video_list[0],
- app_video_list[1:]
- )
- for video in app_video_list:
- if 'file_id' not in video:
- raise ValueError(
- _("Parameter value error: The uploaded video lacks file_id, and the video upload fails."))
- return self.execute(**{**self.flow_params_serializer.data, **self.node_params_serializer.data},
- app_document_list=app_document_list, app_image_list=app_image_list,
- app_audio_list=app_audio_list,
- app_video_list=app_video_list,
- ip_address=self.workflow_params.get('ip_address') or '-',
- source=self.workflow_params.get('source') or {"type": ChatSourceChoices.ONLINE.value},
- message=str(question), **kwargs)
-
- def execute(self, application_id, message, chat_id, chat_record_id, stream, re_chat, client_id, client_type,
- app_document_list=None, app_image_list=None, app_audio_list=None, app_video_list=None, child_node=None,
- node_data=None,
- ip_address=None,
- source=None,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/application_node/impl/__init__.py b/apps/application/flow/step_node/application_node/impl/__init__.py
deleted file mode 100644
index e31a8d885cd..00000000000
--- a/apps/application/flow/step_node/application_node/impl/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# coding=utf-8
-from .base_application_node import BaseApplicationNode
diff --git a/apps/application/flow/step_node/application_node/impl/base_application_node.py b/apps/application/flow/step_node/application_node/impl/base_application_node.py
deleted file mode 100644
index 7622de3a75f..00000000000
--- a/apps/application/flow/step_node/application_node/impl/base_application_node.py
+++ /dev/null
@@ -1,299 +0,0 @@
-# coding=utf-8
-import json
-import re
-import time
-import uuid
-from typing import Dict, List
-from django.utils.translation import gettext as _
-from application.flow.common import Answer
-from application.flow.i_step_node import NodeResult, INode
-from application.flow.step_node.application_node.i_application_node import IApplicationNode
-from common.utils.logger import maxkb_logger
-from application.models import Chat, ChatSourceChoices
-
-
-def string_to_uuid(input_str):
- return str(uuid.uuid5(uuid.NAMESPACE_DNS, input_str))
-
-
-def _is_interrupt_exec(node, node_variable: Dict, workflow_variable: Dict):
- return node_variable.get('is_interrupt_exec', False)
-
-
-def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow, answer: str,
- reasoning_content: str):
- result = node_variable.get('result')
- node.context['application_node_dict'] = node_variable.get('application_node_dict')
- node.context['node_dict'] = node_variable.get('node_dict', {})
- node.context['is_interrupt_exec'] = node_variable.get('is_interrupt_exec')
- node.context['message_tokens'] = result.get('usage', {}).get('prompt_tokens', 0)
- node.context['answer_tokens'] = result.get('usage', {}).get('completion_tokens', 0)
- node.context['answer'] = answer
- node.context['result'] = answer
- node.context['reasoning_content'] = reasoning_content
- node.context['question'] = node_variable['question']
- node.context['run_time'] = time.time() - node.context['start_time']
- if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
- node.answer_text = answer
-
-
-def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据 (流式)
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result')
- answer = ''
- reasoning_content = ''
- usage = {}
- node_child_node = {}
- application_node_dict = node.context.get('application_node_dict', {})
- is_interrupt_exec = False
- for chunk in response:
- # 先把流转成字符串
- response_content = chunk.decode('utf-8')[6:]
- response_content = json.loads(response_content)
- content = (response_content.get('content', '') or '')
- runtime_node_id = response_content.get('runtime_node_id', '')
- chat_record_id = response_content.get('chat_record_id', '')
- child_node = response_content.get('child_node')
- view_type = response_content.get('view_type')
- node_type = response_content.get('node_type')
- real_node_id = response_content.get('real_node_id')
- node_is_end = response_content.get('node_is_end', False)
- _reasoning_content = (response_content.get('reasoning_content', '') or '')
- if node_type == 'form-node':
- is_interrupt_exec = True
- answer += content
- reasoning_content += _reasoning_content
- node_child_node = {'runtime_node_id': runtime_node_id, 'chat_record_id': chat_record_id,
- 'child_node': child_node}
-
- if real_node_id is not None:
- real_node_id = real_node_id + '__' + node.runtime_node_id
- application_node = application_node_dict.get(real_node_id, None)
- if application_node is None:
-
- application_node_dict[real_node_id] = {'content': content,
- 'runtime_node_id': runtime_node_id,
- 'chat_record_id': chat_record_id,
- 'child_node': child_node,
- 'index': len(application_node_dict),
- 'view_type': view_type,
- 'reasoning_content': _reasoning_content}
- else:
- application_node['content'] += content
- application_node['reasoning_content'] += _reasoning_content
-
- yield {'content': content,
- 'node_type': node_type,
- 'runtime_node_id': runtime_node_id, 'chat_record_id': chat_record_id,
- 'reasoning_content': _reasoning_content,
- 'child_node': child_node,
- 'real_node_id': real_node_id,
- 'node_is_end': node_is_end,
- 'view_type': view_type}
- usage = response_content.get('usage', {})
- node_variable['result'] = {'usage': usage}
- node_variable['is_interrupt_exec'] = is_interrupt_exec
- node_variable['child_node'] = node_child_node
- node_variable['application_node_dict'] = application_node_dict
- _write_context(node_variable, workflow_variable, node, workflow, answer, reasoning_content)
-
-
-def write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点实例对象
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result', {}).get('data', {})
- node_variable['result'] = {'usage': {'completion_tokens': response.get('completion_tokens'),
- 'prompt_tokens': response.get('prompt_tokens')}}
- answer = response.get('content', '') or "抱歉,没有查找到相关内容,请重新描述您的问题或提供更多信息。"
- reasoning_content = response.get('reasoning_content', '')
- answer_list = response.get('answer_list', [])
- node_variable['application_node_dict'] = {answer.get('real_node_id'): {**answer, 'index': index} for answer, index
- in
- zip(answer_list, range(len(answer_list)))}
- _write_context(node_variable, workflow_variable, node, workflow, answer, reasoning_content)
-
-
-def reset_application_node_dict(application_node_dict, runtime_node_id, node_data):
- try:
- if application_node_dict is None:
- return
- for key in application_node_dict:
- application_node = application_node_dict[key]
- if application_node.get('runtime_node_id') == runtime_node_id:
- content: str = application_node.get('content')
- match = re.search(r'.*?<\/form_rander>', content, flags=re.DOTALL)
- if match:
- form_setting_str = match.group().replace('', '').replace('', '')
- form_setting = json.loads(form_setting_str)
- form_setting['is_submit'] = True
- form_setting['form_data'] = node_data
- value = f'{json.dumps(form_setting)}'
- res = re.sub(r'.*?<\/form_rander>', '${value}', content, flags=re.DOTALL)
- application_node['content'] = res.replace('${value}', value)
- except Exception as e:
- maxkb_logger.warning(f'reset_application_node_dict error: {e}', exc_info=True)
-
-
-class BaseApplicationNode(IApplicationNode):
- def get_answer_list(self) -> List[Answer] | None:
- if self.answer_text is None:
- return None
- application_node_dict = self.context.get('application_node_dict')
- if application_node_dict is None or len(application_node_dict) == 0:
- return [
- Answer(self.answer_text, self.view_type, self.runtime_node_id, self.workflow_params['chat_record_id'],
- self.context.get('child_node'), self.runtime_node_id, '')]
- else:
- return [Answer(n.get('content'), n.get('view_type'), self.runtime_node_id,
- self.workflow_params['chat_record_id'], {'runtime_node_id': n.get('runtime_node_id'),
- 'chat_record_id': n.get('chat_record_id')
- , 'child_node': n.get('child_node')}, n.get('real_node_id'),
- n.get('reasoning_content', ''))
- for n in
- sorted(application_node_dict.values(), key=lambda item: item.get('index'))]
-
- def save_context(self, details, workflow_manage):
- self.context['answer'] = details.get('answer')
- self.context['result'] = details.get('answer')
- self.context['question'] = details.get('question')
- self.context['type'] = details.get('type')
- self.context['reasoning_content'] = details.get('reasoning_content')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
-
- def get_chat_asker(self, kwargs):
- asker = kwargs.get('asker')
- if asker:
- if isinstance(asker, dict):
- return asker
- return {'username': asker}
- return self.workflow_manage.work_flow_post_handler.chat_info.get_chat_user()
-
- def execute(self, application_id, message, chat_id, chat_record_id, stream, re_chat,
- chat_user_id,
- chat_user_type,
- app_document_list=None, app_image_list=None, app_audio_list=None, app_video_list=None, child_node=None,
- node_data=None,
- ip_address=None,
- source=None,
- **kwargs) -> NodeResult:
- from chat.serializers.chat import ChatSerializers
- if application_id == self.workflow_manage.get_body().get('application_id'):
- raise Exception(_("The sub application cannot use the current node"))
- # 生成嵌入应用的chat_id
- current_chat_id = string_to_uuid(chat_id + application_id)
- Chat.objects.get_or_create(id=current_chat_id, defaults={
- 'application_id': application_id,
- 'abstract': message[0:1024],
- 'chat_user_id': chat_user_id,
- 'chat_user_type': chat_user_type,
- 'ip_address': ip_address,
- 'source': source,
- 'asker': self.get_chat_asker(kwargs)
- })
- if app_document_list is None:
- app_document_list = []
- if app_image_list is None:
- app_image_list = []
- if app_audio_list is None:
- app_audio_list = []
- if app_video_list is None:
- app_video_list = []
- runtime_node_id = None
- record_id = None
- child_node_value = None
- if child_node is not None:
- runtime_node_id = child_node.get('runtime_node_id')
- record_id = child_node.get('chat_record_id')
- child_node_value = child_node.get('child_node')
- application_node_dict = self.context.get('application_node_dict')
- reset_application_node_dict(application_node_dict, runtime_node_id, node_data)
- response = ChatSerializers(data={
- "chat_id": current_chat_id,
- "chat_user_id": chat_user_id,
- 'chat_user_type': chat_user_type,
- 'application_id': application_id,
- 'ip_address': ip_address,
- 'source': source,
- 'debug': False
- }).chat(instance=
- {'message': message,
- 're_chat': re_chat,
- 'stream': stream,
- 'document_list': [*app_document_list],
- 'image_list': [*app_image_list],
- 'audio_list': [*app_audio_list],
- 'video_list': [*app_video_list],
- 'runtime_node_id': runtime_node_id,
- 'chat_record_id': record_id,
- 'child_node': child_node_value,
- 'node_data': node_data,
- 'form_data': kwargs}
- )
-
- if response.status_code == 200:
- if stream:
- content_generator = response.streaming_content
- return NodeResult({'result': content_generator, 'question': message}, {},
- _write_context=write_context_stream, _is_interrupt=_is_interrupt_exec)
- else:
- data = json.loads(response.content)
- return NodeResult({'result': data, 'question': message}, {},
- _write_context=write_context, _is_interrupt=_is_interrupt_exec)
-
- def get_details(self, index: int, **kwargs):
- global_fields = []
- for api_input_field in self.node_params_serializer.data.get('api_input_field_list', []):
- value = api_input_field.get('value', [''])[0] if api_input_field.get('value') else ''
- global_fields.append({
- 'label': api_input_field['variable'],
- 'key': api_input_field['variable'],
- 'value': self.workflow_manage.get_reference_field(
- value,
- api_input_field['value'][1:]
- ) if value != '' else ''
- })
-
- for user_input_field in self.node_params_serializer.data.get('user_input_field_list', []):
- value = user_input_field.get('value', [''])[0] if user_input_field.get('value') else ''
- global_fields.append({
- 'label': user_input_field['label'],
- 'key': user_input_field['field'],
- 'value': self.workflow_manage.get_reference_field(
- value,
- user_input_field['value'][1:]
- ) if value != '' else ''
- })
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- "info": self.node.properties.get('node_data'),
- 'run_time': self.context.get('run_time'),
- 'question': self.context.get('question'),
- 'answer': self.context.get('answer'),
- 'reasoning_content': self.context.get('reasoning_content'),
- 'type': self.node.type,
- 'message_tokens': self.context.get('message_tokens'),
- 'answer_tokens': self.context.get('answer_tokens'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'global_fields': global_fields,
- 'document_list': self.workflow_manage.document_list,
- 'image_list': self.workflow_manage.image_list,
- 'audio_list': self.workflow_manage.audio_list,
- 'video_list': self.workflow_manage.video_list,
- 'application_node_dict': self.context.get('application_node_dict'),
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/condition_node/__init__.py b/apps/application/flow/step_node/condition_node/__init__.py
deleted file mode 100644
index 57638504c9e..00000000000
--- a/apps/application/flow/step_node/condition_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py.py
- @date:2024/6/7 14:43
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/condition_node/i_condition_node.py b/apps/application/flow/step_node/condition_node/i_condition_node.py
deleted file mode 100644
index 664ee91baff..00000000000
--- a/apps/application/flow/step_node/condition_node/i_condition_node.py
+++ /dev/null
@@ -1,42 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_condition_node.py
- @date:2024/6/7 9:54
- @desc:
-"""
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode
-
-
-class ConditionSerializer(serializers.Serializer):
- compare = serializers.CharField(required=True, label=_("Comparator"))
- value = serializers.CharField(required=True, label=_("value"))
- field = serializers.ListField(required=True, label=_("Fields"))
-
-
-class ConditionBranchSerializer(serializers.Serializer):
- id = serializers.CharField(required=True, label=_("Branch id"))
- type = serializers.CharField(required=True, label=_("Branch Type"))
- condition = serializers.CharField(required=True, label=_("Condition or|and"))
- conditions = ConditionSerializer(many=True)
-
-
-class ConditionNodeParamsSerializer(serializers.Serializer):
- branch = ConditionBranchSerializer(many=True)
-
-
-class IConditionNode(INode):
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return ConditionNodeParamsSerializer
-
- type = 'condition-node'
-
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
diff --git a/apps/application/flow/step_node/condition_node/impl/__init__.py b/apps/application/flow/step_node/condition_node/impl/__init__.py
deleted file mode 100644
index c21cd3ebb37..00000000000
--- a/apps/application/flow/step_node/condition_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:35
- @desc:
-"""
-from .base_condition_node import BaseConditionNode
diff --git a/apps/application/flow/step_node/condition_node/impl/base_condition_node.py b/apps/application/flow/step_node/condition_node/impl/base_condition_node.py
deleted file mode 100644
index e0da03ace4c..00000000000
--- a/apps/application/flow/step_node/condition_node/impl/base_condition_node.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_condition_node.py
- @date:2024/6/7 11:29
- @desc:
-"""
-from typing import List
-
-from application.flow.i_step_node import NodeResult
-from application.flow.compare import do_assertion
-from application.flow.step_node.condition_node.i_condition_node import IConditionNode
-
-
-class BaseConditionNode(IConditionNode):
- def save_context(self, details, workflow_manage):
- self.context['branch_id'] = details.get('branch_id')
- self.context['branch_name'] = details.get('branch_name')
- self.context['exception_message'] = details.get('err_message')
-
- def execute(self, **kwargs) -> NodeResult:
- branch_list = self.node_params_serializer.data['branch']
- branch = self._execute(branch_list)
- r = NodeResult({'branch_id': branch.get('id'), 'branch_name': branch.get('type')}, {})
- return r
-
- def _execute(self, branch_list: List):
- for branch in branch_list:
- if self.branch_assertion(branch):
- return branch
-
- def branch_assertion(self, branch):
- return do_assertion(self.workflow_manage, branch.get('condition'), branch.get('conditions'))
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'branch_id': self.context.get('branch_id'),
- 'branch_name': self.context.get('branch_name'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/data_source_local_node/__init__.py b/apps/application/flow/step_node/data_source_local_node/__init__.py
deleted file mode 100644
index bbf804a7079..00000000000
--- a/apps/application/flow/step_node/data_source_local_node/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2025/11/11 10:06
- @desc:
-"""
diff --git a/apps/application/flow/step_node/data_source_local_node/i_data_source_local_node.py b/apps/application/flow/step_node/data_source_local_node/i_data_source_local_node.py
deleted file mode 100644
index e6b39f686fa..00000000000
--- a/apps/application/flow/step_node/data_source_local_node/i_data_source_local_node.py
+++ /dev/null
@@ -1,42 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: i_data_source_local_node.py
- @date:2025/11/11 10:06
- @desc:
-"""
-from abc import abstractmethod
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class DataSourceLocalNodeParamsSerializer(serializers.Serializer):
- file_type_list = serializers.ListField(child=serializers.CharField(label=('')), label='')
- file_size_limit = serializers.IntegerField(required=True, label=_("Number of uploaded files"))
- file_count_limit = serializers.IntegerField(required=True, label=_("Upload file size"))
-
-
-class IDataSourceLocalNode(INode):
- type = 'data-source-local-node'
-
- @staticmethod
- @abstractmethod
- def get_form_list(node):
- pass
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return DataSourceLocalNodeParamsSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, file_type_list, file_size_limit, file_count_limit, **kwargs) -> NodeResult:
- pass
-
- support = [WorkflowMode.KNOWLEDGE]
diff --git a/apps/application/flow/step_node/data_source_local_node/impl/__init__.py b/apps/application/flow/step_node/data_source_local_node/impl/__init__.py
deleted file mode 100644
index 6f830151971..00000000000
--- a/apps/application/flow/step_node/data_source_local_node/impl/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2025/11/11 10:08
- @desc:
-"""
diff --git a/apps/application/flow/step_node/data_source_local_node/impl/base_data_source_local_node.py b/apps/application/flow/step_node/data_source_local_node/impl/base_data_source_local_node.py
deleted file mode 100644
index c2f69b6f21a..00000000000
--- a/apps/application/flow/step_node/data_source_local_node/impl/base_data_source_local_node.py
+++ /dev/null
@@ -1,52 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: base_data_source_local_node.py
- @date:2025/11/11 10:30
- @desc:
-"""
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.data_source_local_node.i_data_source_local_node import IDataSourceLocalNode
-from common import forms
-from common.forms import BaseForm
-
-
-class BaseDataSourceLocalNodeForm(BaseForm):
- api_key = forms.PasswordInputField('API Key', required=True)
-
-
-class BaseDataSourceLocalNode(IDataSourceLocalNode):
- def save_context(self, details, workflow_manage):
- self.context['exception_message'] = details.get('err_message')
-
- @staticmethod
- def get_form_list(node):
- node_data = node.get('properties').get('node_data')
- return [{
- 'field': 'file_list',
- 'input_type': 'LocalFileUpload',
- 'attrs': {
- 'file_count_limit': node_data.get('file_count_limit') or 10,
- 'file_size_limit': node_data.get('file_size_limit') or 100,
- 'file_type_list': node_data.get('file_type_list'),
- },
- 'label': '',
- }]
-
- def execute(self, file_type_list, file_size_limit, file_count_limit, **kwargs) -> NodeResult:
- return NodeResult({'file_list': self.workflow_manage.params.get('data_source', {}).get('file_list')},
- self.workflow_manage.params.get('knowledge_base') or {})
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'file_list': self.context.get('file_list'),
- 'knowledge_base': self.workflow_params.get('knowledge_base'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/data_source_web_node/__init__.py b/apps/application/flow/step_node/data_source_web_node/__init__.py
deleted file mode 100644
index 461bab6fc12..00000000000
--- a/apps/application/flow/step_node/data_source_web_node/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:niu
- @file: __init__.py.py
- @date:2025/11/12 13:43
- @desc:
-"""
diff --git a/apps/application/flow/step_node/data_source_web_node/i_data_source_web_node.py b/apps/application/flow/step_node/data_source_web_node/i_data_source_web_node.py
deleted file mode 100644
index ee5dc990b84..00000000000
--- a/apps/application/flow/step_node/data_source_web_node/i_data_source_web_node.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:niu
- @file: i_data_source_web_node.py
- @date:2025/11/12 13:47
- @desc:
-"""
-from abc import abstractmethod
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class IDataSourceWebNode(INode):
- type = 'data-source-web-node'
- support = [WorkflowMode.KNOWLEDGE]
-
- @staticmethod
- @abstractmethod
- def get_form_list(node):
- pass
-
- def _run(self):
- return self.execute(**self.flow_params_serializer.data)
-
- def execute(self, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/data_source_web_node/impl/__init__.py b/apps/application/flow/step_node/data_source_web_node/impl/__init__.py
deleted file mode 100644
index b7541b12df1..00000000000
--- a/apps/application/flow/step_node/data_source_web_node/impl/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:niu
- @file: __init__.py
- @date:2025/11/12 13:44
- @desc:
-"""
diff --git a/apps/application/flow/step_node/data_source_web_node/impl/base_data_source_web_node.py b/apps/application/flow/step_node/data_source_web_node/impl/base_data_source_web_node.py
deleted file mode 100644
index 0a9ec336036..00000000000
--- a/apps/application/flow/step_node/data_source_web_node/impl/base_data_source_web_node.py
+++ /dev/null
@@ -1,98 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:niu
- @file: base_data_source_web_node.py
- @date:2025/11/12 13:47
- @desc:
-"""
-import traceback
-
-from django.utils.translation import gettext_lazy as _
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.data_source_web_node.i_data_source_web_node import IDataSourceWebNode
-from common import forms
-from common.forms import BaseForm
-from common.utils.fork import ForkManage, Fork, ChildLink
-from common.utils.logger import maxkb_logger
-
-
-class BaseDataSourceWebNodeForm(BaseForm):
- source_url = forms.TextInputField(_('Web source url'), required=True, attrs={
- 'placeholder': _('Please enter the Web root address')})
- selector = forms.TextInputField(_('Web knowledge selector'), required=False, attrs={
- 'placeholder': _('The default is body, you can enter .classname/#idname/tagname')})
-
-
-class InterruptedTaskException(Exception):
- def __init__(self, *args, **kwargs): # real signature unknown
- pass
-
-
-def get_collect_handler(workflow_manage):
- results = []
-
- def handler(child_link: ChildLink, response: Fork.Response):
- if response.status == 200:
- try:
- document_name = child_link.tag.text if child_link.tag is not None and len(
- child_link.tag.text.strip()) > 0 else child_link.url
- results.append({
- "name": document_name.strip(),
- "content": response.content,
- })
-
- except Exception as e:
- maxkb_logger.error(f'{str(e)}:{traceback.format_exc()}')
- if workflow_manage.is_the_task_interrupted():
- raise InterruptedTaskException('Task interrupted')
-
- return handler, results
-
-
-class BaseDataSourceWebNode(IDataSourceWebNode):
- def save_context(self, details, workflow_manage):
- self.context['exception_message'] = details.get('err_message')
-
- @staticmethod
- def get_form_list(node):
- return BaseDataSourceWebNodeForm().to_form_list()
-
- def execute(self, **kwargs) -> NodeResult:
- BaseDataSourceWebNodeForm().valid_form(self.workflow_params.get("data_source"))
-
- data_source = self.workflow_params.get("data_source")
-
- node_id = data_source.get("node_id")
- source_url = data_source.get("source_url")
- selector = data_source.get("selector") or "body"
-
- collect_handler, document_list = get_collect_handler(self.workflow_manage)
-
- try:
- ForkManage(source_url, selector.split(" ") if selector is not None else []).fork(3, set(), collect_handler)
-
- return NodeResult({'document_list': document_list, 'source_url': source_url, 'selector': selector},
- self.workflow_manage.params.get('knowledge_base') or {})
-
- except Exception as e:
- if isinstance(e, InterruptedTaskException):
- return NodeResult({'document_list': document_list, 'source_url': source_url, 'selector': selector},
- self.workflow_manage.params.get('knowledge_base') or {})
- maxkb_logger.error(_('data source web node:{node_id} error{error}{traceback}').format(
- node_id=node_id, error=str(e), traceback=traceback.format_exc()))
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'input_params': {"source_url": self.context.get("source_url"), "selector": self.context.get('selector')},
- 'output_params': self.context.get('document_list'),
- 'knowledge_base': self.workflow_params.get('knowledge_base'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/direct_reply_node/__init__.py b/apps/application/flow/step_node/direct_reply_node/__init__.py
deleted file mode 100644
index cf360f95685..00000000000
--- a/apps/application/flow/step_node/direct_reply_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 17:50
- @desc:
-"""
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/direct_reply_node/i_reply_node.py b/apps/application/flow/step_node/direct_reply_node/i_reply_node.py
deleted file mode 100644
index 1a963d76a58..00000000000
--- a/apps/application/flow/step_node/direct_reply_node/i_reply_node.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_reply_node.py
- @date:2024/6/11 16:25
- @desc:
-"""
-from typing import Type
-
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-from common.exception.app_exception import AppApiException
-
-from django.utils.translation import gettext_lazy as _
-
-
-class ReplyNodeParamsSerializer(serializers.Serializer):
- reply_type = serializers.CharField(required=True, label=_("Response Type"))
- fields = serializers.ListField(required=False, label=_("Reference Field"))
- content = serializers.CharField(required=False, allow_blank=True, allow_null=True,
- label=_("Direct answer content"))
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
- if self.data.get('reply_type') == 'referencing':
- if 'fields' not in self.data:
- raise AppApiException(500, _("Reference field cannot be empty"))
- if len(self.data.get('fields')) < 2:
- raise AppApiException(500, _("Reference field error"))
- else:
- if 'content' not in self.data or self.data.get('content') is None:
- raise AppApiException(500, _("Content cannot be empty"))
-
-
-class IReplyNode(INode):
- type = 'reply-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE_LOOP,
- WorkflowMode.KNOWLEDGE, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return ReplyNodeParamsSerializer
-
- def _run(self):
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL,
- WorkflowMode.TOOL_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data,
- **{'stream': True})
- else:
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, reply_type, stream, fields=None, content=None, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/direct_reply_node/impl/__init__.py b/apps/application/flow/step_node/direct_reply_node/impl/__init__.py
deleted file mode 100644
index 3307e90899e..00000000000
--- a/apps/application/flow/step_node/direct_reply_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 17:49
- @desc:
-"""
-from .base_reply_node import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/direct_reply_node/impl/base_reply_node.py b/apps/application/flow/step_node/direct_reply_node/impl/base_reply_node.py
deleted file mode 100644
index e70c45afd07..00000000000
--- a/apps/application/flow/step_node/direct_reply_node/impl/base_reply_node.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_reply_node.py
- @date:2024/6/11 17:25
- @desc:
-"""
-from typing import List
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.direct_reply_node.i_reply_node import IReplyNode
-
-
-class BaseReplyNode(IReplyNode):
- def save_context(self, details, workflow_manage):
- self.context['answer'] = details.get('answer')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
-
- def execute(self, reply_type, stream, fields=None, content=None, **kwargs) -> NodeResult:
- if reply_type == 'referencing':
- result = self.get_reference_content(fields)
- else:
- result = self.generate_reply_content(content)
- return NodeResult({'answer': result}, {})
-
- def generate_reply_content(self, prompt):
- return self.workflow_manage.generate_prompt(prompt)
-
- def get_reference_content(self, fields: List[str]):
- return str(self.workflow_manage.get_reference_field(
- fields[0],
- fields[1:])) if fields else ''
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'answer': self.context.get('answer'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/document_extract_node/__init__.py b/apps/application/flow/step_node/document_extract_node/__init__.py
deleted file mode 100644
index ce8f10f3e24..00000000000
--- a/apps/application/flow/step_node/document_extract_node/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/document_extract_node/i_document_extract_node.py b/apps/application/flow/step_node/document_extract_node/i_document_extract_node.py
deleted file mode 100644
index d2cf43e0238..00000000000
--- a/apps/application/flow/step_node/document_extract_node/i_document_extract_node.py
+++ /dev/null
@@ -1,30 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class DocumentExtractNodeSerializer(serializers.Serializer):
- document_list = serializers.ListField(required=False, label=_("document"))
-
-
-class IDocumentExtractNode(INode):
- type = 'document-extract-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE_LOOP,
- WorkflowMode.KNOWLEDGE, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return DocumentExtractNodeSerializer
-
- def _run(self):
- res = self.workflow_manage.get_reference_field(self.node_params_serializer.data.get('document_list')[0],
- self.node_params_serializer.data.get('document_list')[1:])
- return self.execute(document=res, **self.flow_params_serializer.data)
-
- def execute(self, document, chat_id=None, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/document_extract_node/impl/__init__.py b/apps/application/flow/step_node/document_extract_node/impl/__init__.py
deleted file mode 100644
index cf9d55ecde8..00000000000
--- a/apps/application/flow/step_node/document_extract_node/impl/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .base_document_extract_node import BaseDocumentExtractNode
diff --git a/apps/application/flow/step_node/document_extract_node/impl/base_document_extract_node.py b/apps/application/flow/step_node/document_extract_node/impl/base_document_extract_node.py
deleted file mode 100644
index 7bd910c31f3..00000000000
--- a/apps/application/flow/step_node/document_extract_node/impl/base_document_extract_node.py
+++ /dev/null
@@ -1,95 +0,0 @@
-# coding=utf-8
-import ast
-import io
-
-import uuid_utils.compat as uuid
-from django.db.models import QuerySet
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.document_extract_node.i_document_extract_node import IDocumentExtractNode
-from knowledge.models import File, FileSourceType
-from knowledge.serializers.document import split_handles, parse_table_handle_list, FileBufferHandle
-
-splitter = '\n`-----------------------------------`\n'
-
-
-class BaseDocumentExtractNode(IDocumentExtractNode):
- def save_context(self, details, workflow_manage):
- self.context['content'] = details.get('content')
- self.context['exception_message'] = details.get('err_message')
-
- def execute(self, document, chat_id=None, **kwargs):
- get_buffer = FileBufferHandle().get_buffer
-
- self.context['document_list'] = document
- content = []
- if document is None or not isinstance(document, list):
- return NodeResult({'content': '', 'document_list': []}, {})
-
- # 安全获取 application
- application_id = None
- tool_id = None
- knowledge_id = None
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP].__contains__(self.workflow_manage.flow.workflow_mode):
- knowledge_id = self.workflow_params.get('knowledge_id')
- elif [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- application_id = self.workflow_manage.work_flow_post_handler.chat_info.application.id
- elif [WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP].__contains__(self.workflow_manage.flow.workflow_mode):
- tool_id = self.workflow_params.get('tool_id')
-
- # doc文件中的图片保存
- def save_image(image_list):
- for image in image_list:
- meta = {
- 'debug': False if (application_id or knowledge_id or tool_id) else True,
- 'chat_id': chat_id,
- 'application_id': str(application_id) if application_id else None,
- 'knowledge_id': str(knowledge_id) if knowledge_id else None,
- 'tool_id': str(tool_id) if tool_id else None,
- 'file_id': str(image.id)
- }
- file_bytes = image.meta.pop('content')
- new_file = File(
- id=meta['file_id'],
- file_name=image.file_name,
- file_size=len(file_bytes),
- source_type=FileSourceType.APPLICATION.value if application_id else FileSourceType.KNOWLEDGE.value if knowledge_id else FileSourceType.TOOL.value,
- source_id=application_id or knowledge_id or tool_id,
- meta=meta
- )
- if not QuerySet(File).filter(id=new_file.id).exists():
- new_file.save(file_bytes)
-
- document_list = []
- for doc in document:
- file = QuerySet(File).filter(id=doc['file_id']).first()
- buffer = io.BytesIO(file.get_bytes())
- buffer.name = doc['name'] # this is the important line
-
- for split_handle in (parse_table_handle_list + split_handles):
- if split_handle.support(buffer, get_buffer):
- # 回到文件头
- buffer.seek(0)
- file_content = split_handle.get_content(buffer, save_image)
- content.append('### ' + doc['name'] + '\n' + file_content)
- document_list.append({'id': str(file.id), 'name': doc['name'], 'content': file_content})
- break
-
- return NodeResult({'content': splitter.join(content), 'document_list': document_list}, {})
-
- def get_details(self, index: int, **kwargs):
- content = self.context.get('content', '').split(splitter)
- # 不保存content全部内容,因为content内容可能会很大
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'content': [file_content[:500] for file_content in content],
- 'status': self.status,
- 'err_message': self.err_message,
- 'document_list': self.context.get('document_list'),
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/document_split_node/__init__.py b/apps/application/flow/step_node/document_split_node/__init__.py
deleted file mode 100644
index ce8f10f3e24..00000000000
--- a/apps/application/flow/step_node/document_split_node/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/document_split_node/i_document_split_node.py b/apps/application/flow/step_node/document_split_node/i_document_split_node.py
deleted file mode 100644
index 7b13d2d405d..00000000000
--- a/apps/application/flow/step_node/document_split_node/i_document_split_node.py
+++ /dev/null
@@ -1,97 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class DocumentSplitNodeSerializer(serializers.Serializer):
- document_list = serializers.ListField(required=False, label=_("document list"))
- split_strategy = serializers.ChoiceField(
- choices=['auto', 'custom', 'qa'], required=False, label=_("split strategy"), default='auto'
- )
- paragraph_title_relate_problem_type = serializers.ChoiceField(
- choices=['custom', 'referencing'], required=False, label=_("paragraph title relate problem type"),
- default='custom'
- )
- paragraph_title_relate_problem = serializers.BooleanField(
- required=False, label=_("paragraph title relate problem"), default=False
- )
- paragraph_title_relate_problem_reference = serializers.ListField(
- required=False, label=_("paragraph title relate problem reference"), child=serializers.CharField(), default=[]
- )
- document_name_relate_problem_type = serializers.ChoiceField(
- choices=['custom', 'referencing'], required=False, label=_("document name relate problem type"),
- default='custom'
- )
- document_name_relate_problem = serializers.BooleanField(
- required=False, label=_("document name relate problem"), default=False
- )
- document_name_relate_problem_reference = serializers.ListField(
- required=False, label=_("document name relate problem reference"), child=serializers.CharField(), default=[]
- )
- limit = serializers.IntegerField(required=False, label=_("limit"), default=4096)
- limit_type = serializers.ChoiceField(
- choices=['custom', 'referencing'], required=False, label=_("document name relate problem type"),
- default='custom'
- )
- limit_reference = serializers.ListField(
- required=False, label=_("limit reference"), child=serializers.CharField(), default=[]
- )
- chunk_size = serializers.IntegerField(required=False, label=_("chunk size"), default=256)
- chunk_size_type = serializers.ChoiceField(
- choices=['custom', 'referencing'], required=False, label=_("chunk size type"), default='custom'
- )
- chunk_size_reference = serializers.ListField(
- required=False, label=_("chunk size reference"), child=serializers.CharField(), default=[]
- )
- patterns = serializers.ListField(
- required=False, label=_("patterns"), child=serializers.CharField(), default=[]
- )
- patterns_type = serializers.ChoiceField(
- choices=['custom', 'referencing'], required=False, label=_("patterns type"), default='custom'
- )
- patterns_reference = serializers.ListField(
- required=False, label=_("patterns reference"), child=serializers.CharField(), default=[]
- )
- with_filter = serializers.BooleanField(
- required=False, label=_("with filter"), default=False
- )
- with_filter_type = serializers.ChoiceField(
- choices=['custom', 'referencing'], required=False, label=_("with filter type"), default='custom'
- )
- with_filter_reference = serializers.ListField(
- required=False, label=_("with filter reference"), child=serializers.CharField(), default=[]
- )
-
-
-class IDocumentSplitNode(INode):
- type = 'document-split-node'
- support = [
- WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP
- ]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return DocumentSplitNodeSerializer
-
- def _run(self):
- if [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.TOOL,
- WorkflowMode.TOOL_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data,
- **{'knowledge_id': None})
- else:
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, document_list, knowledge_id, split_strategy, paragraph_title_relate_problem_type,
- paragraph_title_relate_problem, paragraph_title_relate_problem_reference,
- document_name_relate_problem_type, document_name_relate_problem,
- document_name_relate_problem_reference, limit, limit_type, limit_reference, chunk_size, chunk_size_type,
- chunk_size_reference, patterns, patterns_type, patterns_reference, with_filter, with_filter_type,
- with_filter_reference, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/document_split_node/impl/__init__.py b/apps/application/flow/step_node/document_split_node/impl/__init__.py
deleted file mode 100644
index cc7dc7dda90..00000000000
--- a/apps/application/flow/step_node/document_split_node/impl/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .base_document_split_node import BaseDocumentSplitNode
diff --git a/apps/application/flow/step_node/document_split_node/impl/base_document_split_node.py b/apps/application/flow/step_node/document_split_node/impl/base_document_split_node.py
deleted file mode 100644
index 5e71cdd50a1..00000000000
--- a/apps/application/flow/step_node/document_split_node/impl/base_document_split_node.py
+++ /dev/null
@@ -1,192 +0,0 @@
-# coding=utf-8
-import io
-import mimetypes
-from typing import List
-
-from django.core.files.uploadedfile import InMemoryUploadedFile
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.document_split_node.i_document_split_node import IDocumentSplitNode
-from common.chunk import text_to_chunk
-from knowledge.serializers.document import default_split_handle, FileBufferHandle, md_qa_split_handle
-
-
-def bytes_to_uploaded_file(file_bytes, file_name="file.txt"):
- if file_name.startswith("http"):
- file_name = "file.txt"
- content_type, _ = mimetypes.guess_type(file_name)
- if content_type is None:
- # 如果未能识别,设置为默认的二进制文件类型
- content_type = "application/octet-stream"
- # 创建一个内存中的字节流对象
- file_stream = io.BytesIO(file_bytes)
-
- # 获取文件大小
- file_size = len(file_bytes)
-
- # 创建 InMemoryUploadedFile 对象
- uploaded_file = InMemoryUploadedFile(
- file=file_stream,
- field_name=None,
- name=file_name,
- content_type=content_type,
- size=file_size,
- charset=None,
- )
- return uploaded_file
-
-
-class BaseDocumentSplitNode(IDocumentSplitNode):
- def save_context(self, details, workflow_manage):
- self.context['content'] = details.get('content')
- self.context['exception_message'] = details.get('err_message')
-
- def get_reference_content(self, fields: List[str]):
- return self.workflow_manage.get_reference_field(fields[0], fields[1:]) if fields else None
-
- def execute(self, document_list, knowledge_id, split_strategy, paragraph_title_relate_problem_type,
- paragraph_title_relate_problem, paragraph_title_relate_problem_reference,
- document_name_relate_problem_type, document_name_relate_problem,
- document_name_relate_problem_reference, limit, limit_type, limit_reference, chunk_size, chunk_size_type,
- chunk_size_reference, patterns, patterns_type, patterns_reference, with_filter, with_filter_type,
- with_filter_reference, **kwargs) -> NodeResult:
- self.context['knowledge_id'] = knowledge_id
- file_list = self.get_reference_content(document_list)
-
- # 处理引用类型的参数
- if patterns_type == 'referencing':
- patterns = self.get_reference_content(patterns_reference)
- if limit_type == 'referencing':
- limit = self.get_reference_content(limit_reference)
- if chunk_size_type == 'referencing':
- chunk_size = self.get_reference_content(chunk_size_reference)
- if with_filter_type == 'referencing':
- with_filter = self.get_reference_content(with_filter_reference)
-
- paragraph_list = []
- for doc in file_list:
- get_buffer = FileBufferHandle().get_buffer
-
- file_mem = bytes_to_uploaded_file(doc['content'].encode('utf-8'), doc['name'])
- if split_strategy == 'qa':
- result = md_qa_split_handle.handle(file_mem, get_buffer, self._save_image)
- else:
- result = default_split_handle.handle(file_mem, patterns, with_filter, limit, get_buffer,
- self._save_image)
- # 统一处理结果为列表
- results = result if isinstance(result, list) else [result]
-
- for item in results:
- self._process_split_result(
- item, knowledge_id, doc.get('id'), doc.get('name'),
- split_strategy, paragraph_title_relate_problem_type,
- paragraph_title_relate_problem, paragraph_title_relate_problem_reference,
- document_name_relate_problem_type, document_name_relate_problem,
- document_name_relate_problem_reference, chunk_size
- )
-
- paragraph_list += results
-
- self.context['paragraph_list'] = paragraph_list
- self.context['document_list'] = file_list
- self.context['limit'] = limit
- self.context['chunk_size'] = chunk_size
- self.context['with_filter'] = with_filter
- self.context['patterns'] = patterns
- self.context['split_strategy'] = split_strategy
-
- return NodeResult({'paragraph_list': paragraph_list}, {})
-
- def _save_image(self, image_list):
- pass
-
- def _process_split_result(
- self, item, knowledge_id, source_file_id, file_name,
- split_strategy, paragraph_title_relate_problem_type,
- paragraph_title_relate_problem, paragraph_title_relate_problem_reference,
- document_name_relate_problem_type, document_name_relate_problem,
- document_name_relate_problem_reference, chunk_size
- ):
- """处理文档分割结果"""
- item['meta'] = {
- 'knowledge_id': knowledge_id,
- 'source_file_id': source_file_id,
- 'source_url': file_name,
- }
- if item.get('name', 'file.txt') == 'file.txt':
- item['name'] = file_name
- item['source_file_id'] = source_file_id
- item['paragraphs'] = item.pop('content', item.get('paragraphs', []))
-
- for paragraph in item['paragraphs']:
- paragraph['problem_list'] = self._generate_problem_list(
- paragraph, file_name,
- split_strategy, paragraph_title_relate_problem_type,
- paragraph_title_relate_problem, paragraph_title_relate_problem_reference,
- document_name_relate_problem_type, document_name_relate_problem,
- document_name_relate_problem_reference
- )
- paragraph['is_active'] = True
- paragraph['chunks'] = text_to_chunk(paragraph['content'], chunk_size)
-
- def _generate_problem_list(
- self, paragraph, document_name, split_strategy, paragraph_title_relate_problem_type,
- paragraph_title_relate_problem, paragraph_title_relate_problem_reference,
- document_name_relate_problem_type, document_name_relate_problem,
- document_name_relate_problem_reference
- ):
- if paragraph_title_relate_problem_type == 'referencing':
- paragraph_title_relate_problem = self.get_reference_content(paragraph_title_relate_problem_reference)
- if document_name_relate_problem_type == 'referencing':
- document_name_relate_problem = self.get_reference_content(document_name_relate_problem_reference)
-
- problem_list = [
- item for p in paragraph.get('problem_list', []) for item in p.get('content', '').split('
')
- if item.strip()
- ]
-
- if split_strategy == 'auto':
- if paragraph_title_relate_problem and paragraph.get('title'):
- problem_list.append(paragraph.get('title'))
- if document_name_relate_problem and document_name:
- problem_list.append(document_name)
- elif split_strategy == 'custom':
- if paragraph_title_relate_problem and paragraph.get('title'):
- problem_list.append(paragraph.get('title'))
- if document_name_relate_problem and document_name:
- problem_list.append(document_name)
- elif split_strategy == 'qa':
- if document_name_relate_problem and document_name:
- problem_list.append(document_name)
-
- return list(set(problem_list))
-
- def get_details(self, index: int, **kwargs):
- paragraph_list = self.context.get('paragraph_list', [])
- # 每个文档保留前5个分段
- limited_paragraph_list = []
- for doc in paragraph_list:
- if doc.get('paragraphs'):
- doc_copy = doc.copy()
- doc_copy['paragraphs'] = doc['paragraphs'][:5]
- limited_paragraph_list.append(doc_copy)
- else:
- limited_paragraph_list.append(doc)
- paragraph_list = limited_paragraph_list
-
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'paragraph_list': paragraph_list,
- 'limit': self.context.get('limit'),
- 'chunk_size': self.context.get('chunk_size'),
- 'with_filter': self.context.get('with_filter'),
- 'patterns': self.context.get('patterns'),
- 'split_strategy': self.context.get('split_strategy'),
- 'enableException': self.node.properties.get('enableException'),
- # 'document_list': self.context.get('document_list', []),
- }
diff --git a/apps/application/flow/step_node/form_node/__init__.py b/apps/application/flow/step_node/form_node/__init__.py
deleted file mode 100644
index ce04b64aea8..00000000000
--- a/apps/application/flow/step_node/form_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: __init__.py.py
- @date:2024/11/4 14:48
- @desc:
-"""
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/form_node/i_form_node.py b/apps/application/flow/step_node/form_node/i_form_node.py
deleted file mode 100644
index 9be117f857f..00000000000
--- a/apps/application/flow/step_node/form_node/i_form_node.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: i_form_node.py
- @date:2024/11/4 14:48
- @desc:
-"""
-from typing import Type
-
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-from django.utils.translation import gettext_lazy as _
-
-
-class FormNodeParamsSerializer(serializers.Serializer):
- form_field_list = serializers.ListField(required=True, label=_("Form Configuration"))
- form_content_format = serializers.CharField(required=True, label=_('Form output content'))
- form_data = serializers.DictField(required=False, allow_null=True, label=_("Form Data"))
-
-
-class IFormNode(INode):
- type = 'form-node'
- view_type = 'single_view'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return FormNodeParamsSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, form_field_list, form_content_format, form_data, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/form_node/impl/__init__.py b/apps/application/flow/step_node/form_node/impl/__init__.py
deleted file mode 100644
index 4cea85e1d9e..00000000000
--- a/apps/application/flow/step_node/form_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: __init__.py.py
- @date:2024/11/4 14:49
- @desc:
-"""
-from .base_form_node import BaseFormNode
diff --git a/apps/application/flow/step_node/form_node/impl/base_form_node.py b/apps/application/flow/step_node/form_node/impl/base_form_node.py
deleted file mode 100644
index 710811f1505..00000000000
--- a/apps/application/flow/step_node/form_node/impl/base_form_node.py
+++ /dev/null
@@ -1,238 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: base_form_node.py
- @date:2024/11/4 14:52
- @desc:
-"""
-import copy
-import json
-import time
-from typing import Dict, List
-
-from langchain_core.prompts import PromptTemplate
-
-from application.flow.common import Answer
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.form_node.i_form_node import IFormNode
-import re
-
-_TEMPLATE_RE = re.compile(r'\{\{([^.\s}]+)\.([^.\s}]+)\}\}')
-multi_select_list = [
- 'MultiSelect',
- 'MultiRow'
-]
-
-
-def get_default_option(option_list, _type, value_field):
- try:
- if option_list is not None and isinstance(option_list, list) and len(option_list) > 0:
- default_value_list = [o.get(value_field) for o in option_list if o.get('default')]
- if len(default_value_list) == 0:
- return [option_list[0].get(
- value_field)] if multi_select_list.__contains__(_type) else option_list[0].get(
- value_field)
- else:
- if multi_select_list.__contains__(_type):
- return default_value_list
- else:
- return default_value_list[0]
- except Exception as _:
- pass
- return []
-
-
-def write_context(step_variable: Dict, global_variable: Dict, node, workflow):
- if step_variable is not None:
- for key in step_variable:
- node.context[key] = step_variable[key]
- if workflow.is_result(node, NodeResult(step_variable, global_variable)) and 'result' in step_variable:
- result = step_variable['result']
- yield result
- node.answer_text = result
- node.context['run_time'] = time.time() - node.context['start_time']
-
-
-def generate_prompt(workflow_manage, _value):
- try:
- return workflow_manage.generate_prompt(_value)
- except Exception as e:
- return _value
-
-
-class BaseFormNode(IFormNode):
- def save_context(self, details, workflow_manage):
- form_data = details.get('form_data', None)
- self.context['result'] = details.get('result')
- self.context['form_content_format'] = details.get('form_content_format')
- self.context['form_field_list'] = details.get('form_field_list')
- self.context['run_time'] = details.get('run_time')
- self.context['start_time'] = details.get('start_time')
- self.context['form_data'] = form_data
- self.context['is_submit'] = details.get('is_submit')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('result')
- if form_data is not None:
- for key in form_data:
- self.context[key] = form_data[key]
-
- def reset_field(self, field):
- field = copy.copy(field)
- reset_field = ['field', 'label', 'default_value']
- for f in reset_field:
- _value = field[f]
- if _value is None:
- continue
- if isinstance(_value, str):
- field[f] = generate_prompt(self.workflow_manage, _value)
- elif f == 'label':
- _label_value = _value.get('label')
- _value['label'] = generate_prompt(self.workflow_manage, _label_value)
- tooltip = _value.get('attrs').get('tooltip')
- if tooltip is not None:
- _value.get('attrs')['tooltip'] = generate_prompt(self.workflow_manage, tooltip)
-
- if ['SingleSelect', 'MultiSelect', 'RadioCard', 'RadioRow', 'MultiRow'].__contains__(field.get('input_type')):
- if field.get('assignment_method') == 'ref_variables':
- option_list = self.workflow_manage.get_reference_field(field.get('option_list')[0],
- field.get('option_list')[1:])
- option_list = option_list if isinstance(option_list, list) else []
- field['option_list'] = option_list
- field['default_value'] = get_default_option(option_list, field.get('input_type'),
- field.get('value_field'))
-
- if ['JsonInput'].__contains__(field.get('input_type')):
- if field.get('default_value_assignment_method') == 'ref_variables':
- field['default_value'] = self.workflow_manage.get_reference_field(field.get('default_value')[0],
- field.get('default_value')[1:])
-
- visibility_rules = field.get('visibility_rules')
- if visibility_rules and isinstance(visibility_rules.get('conditions'), list):
- for cond in visibility_rules['conditions']:
- cond_field = cond.get('field')
- if not cond_field or len(cond_field) < 2 or not cond_field[0] or not cond_field[1]:
- continue
-
- # cross node -------> _left
- if cond_field[0] != self.node.id:
- cond['_left'] = self.workflow_manage.get_reference_field(cond_field[0], cond_field[1:])
- # 右值 {{}}
- cond_value = cond.get("value")
- if isinstance(cond_value, str) and _TEMPLATE_RE.search(cond_value):
- cond['value'] = self._render_cond_value(cond_value)
-
- return field
-
- def _render_cond_value(self, value):
- """
- render cross-node/global/chat {{}} to literal, preserve same-form {{}}
- match.group(0) → "{{开始.question}}" # 完整匹配
- match.group(1) → "开始" # 第一个 () 捕获的
- match.group(2) → "question" # 第二个 () 捕获的
- match.start() → 3 # 匹配起始位置
- match.end() → 16 # 匹配结束位置
- """
- def replacer(match):
- node_display = match.group(1)
- field_name = match.group(2)
-
- # field_list: cross_node
- for f in self.workflow_manage.field_list:
- if f.get('node_name') == node_display and f.get('value') == field_name:
- if f.get('node_id') == self.node.id:
- return match.group(0) # same node
- ref = self.workflow_manage.get_reference_field(f.get('node_id'),[field_name])
- return str(ref) if ref is not None else ''
-
- # global
- if node_display in ('全局变量', 'global'):
- for f in self.workflow_manage.global_field_list:
- if f.get('value') == field_name:
- ref = self.workflow_manage.get_reference_field('global', [field_name])
- return str(ref) if ref is not None else ''
-
- # chat
- if node_display == 'chat':
- for f in self.workflow_manage.chat_field_list:
- if f.get("value") == field_name:
- ref = self.workflow_manage.get_reference_field('chat', [field_name])
- return str(ref) if ref is not None else ''
- return match.group(0)
- try:
- return _TEMPLATE_RE.sub(replacer, value)
- except Exception:
- return value
-
- def execute(self, form_field_list, form_content_format, form_data, **kwargs) -> NodeResult:
- if form_data is not None:
- self.context['is_submit'] = True
- self.context['form_data'] = form_data
- for key in form_data:
- self.context[key] = form_data.get(key)
- else:
- self.context['is_submit'] = False
- form_field_list = [self.reset_field(field) for field in form_field_list]
- form_setting = {"form_field_list": form_field_list, "runtime_node_id": self.runtime_node_id,
- "chat_record_id": self.flow_params_serializer.data.get("chat_record_id"),
- "is_submit": self.context.get("is_submit", False)}
- form = f'{json.dumps(form_setting, ensure_ascii=False)}'
- context = self.workflow_manage.get_workflow_content()
- form_content_format = self.workflow_manage.reset_prompt(form_content_format)
- prompt_template = PromptTemplate.from_template(form_content_format, template_format='jinja2')
- value = prompt_template.format(form=form, context=context, runtime_node_id=self.runtime_node_id,
- chat_record_id=self.flow_params_serializer.data.get("chat_record_id"),
- form_field_list=form_field_list)
-
- return NodeResult(
- {'result': value, 'form_field_list': form_field_list, 'form_content_format': form_content_format}, {},
- _write_context=write_context)
-
- def get_answer_list(self) -> List[Answer] | None:
- form_content_format = self.context.get('form_content_format')
- form_field_list = self.context.get('form_field_list')
- form_setting = {"form_field_list": form_field_list, "runtime_node_id": self.runtime_node_id,
- "chat_record_id": self.flow_params_serializer.data.get("chat_record_id"),
- 'form_data': self.context.get('form_data', {}),
- "is_submit": self.context.get("is_submit", False)}
- form = f'{json.dumps(form_setting, ensure_ascii=False)}'
- context = self.workflow_manage.get_workflow_content()
- form_content_format = self.workflow_manage.reset_prompt(form_content_format)
- prompt_template = PromptTemplate.from_template(form_content_format, template_format='jinja2')
- value = prompt_template.format(form=form, context=context, runtime_node_id=self.runtime_node_id,
- chat_record_id=self.flow_params_serializer.data.get("chat_record_id"),
- form_field_list=form_field_list)
- return [
- Answer(value, self.view_type, self.runtime_node_id, self.workflow_params.get('chat_record_id') or '', None,
- self.runtime_node_id, '')]
-
- def get_details(self, index: int, **kwargs):
- form_content_format = self.context.get('form_content_format')
- form_field_list = self.context.get('form_field_list')
- form_setting = {"form_field_list": form_field_list, "runtime_node_id": self.runtime_node_id,
- "chat_record_id": self.flow_params_serializer.data.get("chat_record_id"),
- 'form_data': self.context.get('form_data', {}),
- "is_submit": self.context.get("is_submit", False)}
- form = f'{json.dumps(form_setting, ensure_ascii=False)}'
- context = self.workflow_manage.get_workflow_content()
- form_content_format = self.workflow_manage.reset_prompt(form_content_format)
- prompt_template = PromptTemplate.from_template(form_content_format, template_format='jinja2')
- value = prompt_template.format(form=form, context=context, runtime_node_id=self.runtime_node_id,
- chat_record_id=self.flow_params_serializer.data.get("chat_record_id"),
- form_field_list=form_field_list)
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- "result": value,
- "form_content_format": self.context.get('form_content_format'),
- "form_field_list": self.context.get('form_field_list'),
- 'form_data': self.context.get('form_data'),
- 'start_time': self.context.get('start_time'),
- 'is_submit': self.context.get('is_submit'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/image_generate_step_node/__init__.py b/apps/application/flow/step_node/image_generate_step_node/__init__.py
deleted file mode 100644
index f3feecc9ce2..00000000000
--- a/apps/application/flow/step_node/image_generate_step_node/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .impl import *
diff --git a/apps/application/flow/step_node/image_generate_step_node/i_image_generate_node.py b/apps/application/flow/step_node/image_generate_step_node/i_image_generate_node.py
deleted file mode 100644
index 834c842fd14..00000000000
--- a/apps/application/flow/step_node/image_generate_step_node/i_image_generate_node.py
+++ /dev/null
@@ -1,56 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class ImageGenerateNodeSerializer(serializers.Serializer):
- model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
- prompt = serializers.CharField(required=True, label=_("Prompt word (positive)"))
-
- negative_prompt = serializers.CharField(required=False, label=_("Prompt word (negative)"),
- allow_null=True, allow_blank=True, )
- # 多轮对话数量
- dialogue_number = serializers.IntegerField(required=False, default=0,
- label=_("Number of multi-round conversations"))
-
- dialogue_type = serializers.CharField(required=False, default='NODE',
- label=_("Conversation storage type"))
-
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- model_params_setting = serializers.JSONField(required=False, default=dict,
- label=_("Model parameter settings"))
-
-
-class IImageGenerateNode(INode):
- type = 'image-generate-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return ImageGenerateNodeSerializer
-
- def _run(self):
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL,
- WorkflowMode.TOOL_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data,
- **{'history_chat_record': [], 'stream': True, 'chat_id': None, 'chat_record_id': None})
- else:
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, model_id, prompt, negative_prompt, dialogue_number, dialogue_type, history_chat_record,
- model_params_setting,
- chat_record_id, model_id_type=None, model_id_reference=None,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/image_generate_step_node/impl/__init__.py b/apps/application/flow/step_node/image_generate_step_node/impl/__init__.py
deleted file mode 100644
index 14a21a9159c..00000000000
--- a/apps/application/flow/step_node/image_generate_step_node/impl/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .base_image_generate_node import BaseImageGenerateNode
diff --git a/apps/application/flow/step_node/image_generate_step_node/impl/base_image_generate_node.py b/apps/application/flow/step_node/image_generate_step_node/impl/base_image_generate_node.py
deleted file mode 100644
index 281122364be..00000000000
--- a/apps/application/flow/step_node/image_generate_step_node/impl/base_image_generate_node.py
+++ /dev/null
@@ -1,199 +0,0 @@
-# coding=utf-8
-from functools import reduce
-from typing import List
-
-import requests
-from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
-from django.utils.translation import gettext_lazy as _
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.image_generate_step_node.i_image_generate_node import IImageGenerateNode
-from common.utils.common import bytes_to_uploaded_file
-from knowledge.models import FileSourceType
-from models_provider.tools import get_model_instance_by_model_workspace_id
-from oss.serializers.file import FileSerializer
-
-
-class BaseImageGenerateNode(IImageGenerateNode):
- def save_context(self, details, workflow_manage):
- self.context['answer'] = details.get('answer')
- self.context['question'] = details.get('question')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
-
- def execute(self, model_id, prompt, negative_prompt, dialogue_number, dialogue_type, history_chat_record,
- model_params_setting,
- chat_record_id,
- model_id_type=None, model_id_reference=None,
- **kwargs) -> NodeResult:
- # 处理引用类型
- if model_id_type == 'reference' and model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- model_id_reference[0],
- model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- model_id = reference_data.get('model_id', model_id)
- model_params_setting = reference_data.get('model_params_setting')
-
- if model_id is None or model_id == '':
- raise Exception(_('Model is not allowed to be empty'))
-
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- tti_model = get_model_instance_by_model_workspace_id(model_id, workspace_id,
- **(model_params_setting or {}))
- history_message = self.get_history_message(history_chat_record, dialogue_number)
- self.context['history_message'] = history_message
- question = self.generate_prompt_question(prompt)
- self.context['question'] = question
- message_list = self.generate_message_list(question, history_message)
- self.context['message_list'] = message_list
- self.context['dialogue_type'] = dialogue_type
- self.context['negative_prompt'] = self.generate_prompt_question(negative_prompt)
- image_urls = tti_model.generate_image(question, negative_prompt)
- # 保存图片
- file_urls = []
- for image_url in image_urls:
- file_name = 'generated_image.png'
- if isinstance(image_url, str):
- if image_url.startswith('http'):
- # HTTP URL 情况
- image_url = requests.get(image_url).content
- elif image_url.startswith('data:image'):
- # Data URL 格式 (data:image/png;base64,...)
- import base64
- header, encoded = image_url.split(',', 1)
- image_url = base64.b64decode(encoded)
- else:
- import base64
- image_url = base64.b64decode(image_url)
- file = bytes_to_uploaded_file(image_url, file_name)
- file_url = self.upload_file(file)
- file_urls.append(file_url)
- self.context['image_list'] = [{'file_id': path.split('/')[-1], 'url': path} for path in file_urls]
- answer = ' '.join([f"" for path in file_urls])
- return NodeResult({'answer': answer, 'chat_model': tti_model, 'message_list': message_list,
- 'image': [{'file_id': path.split('/')[-1], 'url': path} for path in file_urls],
- 'history_message': history_message, 'question': question}, {})
-
- def generate_history_ai_message(self, chat_record):
- for val in chat_record.details.values():
- if self.node.id == val['node_id'] and 'image_list' in val:
- if val['dialogue_type'] == 'WORKFLOW':
- return chat_record.get_ai_message()
- image_list = val['image_list']
- return AIMessage(content=[
- *[{'type': 'image_url', 'image_url': {'url': f'{file_url}'}} for file_url in image_list]
- ])
- return chat_record.get_ai_message()
-
- def get_history_message(self, history_chat_record, dialogue_number):
- start_index = len(history_chat_record) - dialogue_number
- history_message = reduce(lambda x, y: [*x, *y], [
- [self.generate_history_human_message(history_chat_record[index]),
- self.generate_history_ai_message(history_chat_record[index])]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))], [])
- return history_message
-
- def generate_history_human_message(self, chat_record):
-
- for data in chat_record.details.values():
- if self.node.id == data['node_id'] and 'image_list' in data:
- image_list = data['image_list']
- if len(image_list) == 0 or data['dialogue_type'] == 'WORKFLOW':
- return HumanMessage(content=chat_record.problem_text)
- return HumanMessage(content=data['question'])
- return HumanMessage(content=chat_record.problem_text)
-
- def generate_prompt_question(self, prompt):
- return self.workflow_manage.generate_prompt(prompt)
-
- def generate_message_list(self, question: str, history_message):
- return [
- *history_message,
- question
- ]
-
- def upload_file(self, file):
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.upload_knowledge_file(file)
- if [WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP].__contains__(self.workflow_manage.flow.workflow_mode):
- return self.upload_tool_file(file)
- return self.upload_application_file(file)
-
- def upload_knowledge_file(self, file):
- knowledge_id = self.workflow_params.get('knowledge_id')
- meta = {
- 'debug': False,
- 'knowledge_id': knowledge_id,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': knowledge_id,
- 'source_type': FileSourceType.KNOWLEDGE.value
- }).upload()
- return file_url
-
- def upload_tool_file(self, file):
- tool_id = self.workflow_params.get('tool_id')
- meta = {
- 'debug': False,
- 'tool_id': tool_id,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': tool_id,
- 'source_type': FileSourceType.TOOL.value
- }).upload()
- return file_url
-
- def upload_application_file(self, file):
- application = self.workflow_manage.work_flow_post_handler.chat_info.application
- chat_id = self.workflow_params.get('chat_id')
- meta = {
- 'debug': False if application.id else True,
- 'chat_id': chat_id,
- 'application_id': str(application.id) if application.id else None,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': meta['application_id'],
- 'source_type': FileSourceType.APPLICATION.value
- }).upload()
- return file_url
-
- @staticmethod
- def reset_message_list(message_list: List[BaseMessage], answer_text):
- result = [{'role': 'user' if isinstance(message, HumanMessage) else 'ai', 'content': message.content} for
- message
- in
- message_list]
- result.append({'role': 'ai', 'content': answer_text})
- return result
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'history_message': [{'content': message.content, 'role': message.type} for message in
- (self.context.get('history_message') if self.context.get(
- 'history_message') is not None else [])],
- 'question': self.context.get('question'),
- 'answer': self.context.get('answer'),
- 'type': self.node.type,
- 'message_tokens': self.context.get('message_tokens'),
- 'answer_tokens': self.context.get('answer_tokens'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'image_list': self.context.get('image_list'),
- 'dialogue_type': self.context.get('dialogue_type'),
- 'negative_prompt': self.context.get('negative_prompt'),
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/image_to_video_step_node/__init__.py b/apps/application/flow/step_node/image_to_video_step_node/__init__.py
deleted file mode 100644
index f3feecc9ce2..00000000000
--- a/apps/application/flow/step_node/image_to_video_step_node/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .impl import *
diff --git a/apps/application/flow/step_node/image_to_video_step_node/i_image_to_video_node.py b/apps/application/flow/step_node/image_to_video_step_node/i_image_to_video_node.py
deleted file mode 100644
index 846f4e90d8f..00000000000
--- a/apps/application/flow/step_node/image_to_video_step_node/i_image_to_video_node.py
+++ /dev/null
@@ -1,78 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class ImageToVideoNodeSerializer(serializers.Serializer):
- model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
-
- prompt = serializers.CharField(required=True, label=_("Prompt word (positive)"))
-
- negative_prompt = serializers.CharField(required=False, label=_("Prompt word (negative)"),
- allow_null=True, allow_blank=True, )
- # 多轮对话数量
- dialogue_number = serializers.IntegerField(required=False, default=0,
- label=_("Number of multi-round conversations"))
-
- dialogue_type = serializers.CharField(required=False, default='NODE',
- label=_("Conversation storage type"))
-
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- model_params_setting = serializers.JSONField(required=False, default=dict,
- label=_("Model parameter settings"))
-
- first_frame_url = serializers.ListField(required=True, label=_("First frame url"))
- last_frame_url = serializers.ListField(required=False, label=_("Last frame url"))
-
-
-class IImageToVideoNode(INode):
- type = 'image-to-video-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return ImageToVideoNodeSerializer
-
- def _run(self):
- first_frame_url = self.workflow_manage.get_reference_field(
- self.node_params_serializer.data.get('first_frame_url')[0],
- self.node_params_serializer.data.get('first_frame_url')[1:])
- if first_frame_url is []:
- raise ValueError(
- _("First frame url cannot be empty"))
- last_frame_url = None
- if self.node_params_serializer.data.get('last_frame_url') is not None and self.node_params_serializer.data.get(
- 'last_frame_url') != []:
- last_frame_url = self.workflow_manage.get_reference_field(
- self.node_params_serializer.data.get('last_frame_url')[0],
- self.node_params_serializer.data.get('last_frame_url')[1:])
- node_params_data = {k: v for k, v in self.node_params_serializer.data.items()
- if k not in ['first_frame_url', 'last_frame_url']}
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL,
- WorkflowMode.TOOL_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.execute(first_frame_url=first_frame_url, last_frame_url=last_frame_url, **node_params_data,
- **self.flow_params_serializer.data,
- **{'history_chat_record': [], 'stream': True, 'chat_id': None, 'chat_record_id': None})
- else:
- return self.execute(first_frame_url=first_frame_url, last_frame_url=last_frame_url,
- **node_params_data, **self.flow_params_serializer.data)
-
- def execute(self, model_id, prompt, negative_prompt, dialogue_number, dialogue_type, history_chat_record,
- model_params_setting,
- chat_record_id,
- first_frame_url, last_frame_url,
- model_id_type=None, model_id_reference=None,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/image_to_video_step_node/impl/__init__.py b/apps/application/flow/step_node/image_to_video_step_node/impl/__init__.py
deleted file mode 100644
index 95be14851cb..00000000000
--- a/apps/application/flow/step_node/image_to_video_step_node/impl/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .base_image_to_video_node import BaseImageToVideoNode
diff --git a/apps/application/flow/step_node/image_to_video_step_node/impl/base_image_to_video_node.py b/apps/application/flow/step_node/image_to_video_step_node/impl/base_image_to_video_node.py
deleted file mode 100644
index 97acad76337..00000000000
--- a/apps/application/flow/step_node/image_to_video_step_node/impl/base_image_to_video_node.py
+++ /dev/null
@@ -1,214 +0,0 @@
-# coding=utf-8
-import base64
-from functools import reduce
-from typing import List
-
-import requests
-from django.db.models import QuerySet
-from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
-from django.utils.translation import gettext_lazy as _
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.image_to_video_step_node.i_image_to_video_node import IImageToVideoNode
-from common.utils.common import bytes_to_uploaded_file
-from knowledge.models import FileSourceType, File
-from oss.serializers.file import FileSerializer, mime_types
-from models_provider.tools import get_model_instance_by_model_workspace_id
-from django.utils.translation import gettext
-
-
-class BaseImageToVideoNode(IImageToVideoNode):
- def save_context(self, details, workflow_manage):
- self.context['answer'] = details.get('answer')
- self.context['question'] = details.get('question')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
-
- def execute(self, model_id, prompt, negative_prompt, dialogue_number, dialogue_type, history_chat_record,
- model_params_setting,
- chat_record_id,
- first_frame_url, last_frame_url=None,
- model_id_type=None, model_id_reference=None,
- **kwargs) -> NodeResult:
- # 处理引用类型
- if model_id_type == 'reference' and model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- model_id_reference[0],
- model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- model_id = reference_data.get('model_id', model_id)
- model_params_setting = reference_data.get('model_params_setting')
- if model_id is None or model_id == '':
- raise Exception(_('Model is not allowed to be empty'))
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- ttv_model = get_model_instance_by_model_workspace_id(model_id, workspace_id,
- **(model_params_setting or {}))
- history_message = self.get_history_message(history_chat_record, dialogue_number)
- self.context['history_message'] = history_message
- question = self.generate_prompt_question(prompt)
- self.context['question'] = question
- message_list = self.generate_message_list(question, history_message)
- self.context['message_list'] = message_list
- self.context['dialogue_type'] = dialogue_type
- self.context['negative_prompt'] = self.generate_prompt_question(negative_prompt)
- self.context['first_frame_url'] = first_frame_url
- self.context['last_frame_url'] = last_frame_url
- # 处理首尾帧图片 这块可以是url 也可以是file_id 如果是url 可以直接传递给模型 如果是file_id 需要传base64
- # 判断是不是 url
- first_frame_url = self.get_file_base64(first_frame_url)
- last_frame_url = self.get_file_base64(last_frame_url)
- video_urls = ttv_model.generate_video(question, negative_prompt, first_frame_url, last_frame_url)
- # 保存图片
- if video_urls is None or video_urls == '':
- return NodeResult({'answer': gettext('Failed to generate video')}, {})
- file_name = 'generated_video.mp4'
- if isinstance(video_urls, str) and video_urls.startswith('http'):
- video_urls = requests.get(video_urls).content
- file = bytes_to_uploaded_file(video_urls, file_name)
- file_url = self.upload_file(file)
- video_label = f''
- video_list = [{'file_id': file_url.split('/')[-1], 'file_name': file_name, 'url': file_url}]
- return NodeResult({'answer': video_label, 'chat_model': ttv_model, 'message_list': message_list,
- 'video': video_list,
- 'history_message': history_message, 'question': question}, {})
-
- def get_file_base64(self, image_url):
- try:
- if isinstance(image_url, list):
- image_url = image_url[0].get('file_id') if 'file_id' in image_url[0] else image_url[0].get('url')
- if isinstance(image_url, str) and not image_url.startswith('http'):
- file = QuerySet(File).filter(id=image_url).first()
- file_bytes = file.get_bytes()
- # 如果我不知道content_type 可以用 magic 库去检测
- file_type = file.file_name.split(".")[-1].lower()
- content_type = mime_types.get(file_type, 'application/octet-stream')
- encoded_bytes = base64.b64encode(file_bytes)
- return f'data:{content_type};base64,{encoded_bytes.decode()}'
- return image_url
- except Exception as e:
- raise ValueError(
- gettext("Failed to obtain the image"))
-
- def upload_file(self, file):
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.upload_knowledge_file(file)
- if [WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP].__contains__(self.workflow_manage.flow.workflow_mode):
- return self.upload_tool_file(file)
- return self.upload_application_file(file)
-
- def upload_knowledge_file(self, file):
- knowledge_id = self.workflow_params.get('knowledge_id')
- meta = {
- 'debug': False,
- 'knowledge_id': knowledge_id
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': knowledge_id,
- 'source_type': FileSourceType.KNOWLEDGE.value
- }).upload()
- return file_url
-
- def upload_tool_file(self, file):
- tool_id = self.workflow_params.get('tool_id')
- meta = {
- 'debug': False,
- 'tool_id': tool_id,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': tool_id,
- 'source_type': FileSourceType.TOOL.value
- }).upload()
- return file_url
-
- def upload_application_file(self, file):
- application = self.workflow_manage.work_flow_post_handler.chat_info.application
- chat_id = self.workflow_params.get('chat_id')
- meta = {
- 'debug': False if application.id else True,
- 'chat_id': chat_id,
- 'application_id': str(application.id) if application.id else None,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': meta['application_id'],
- 'source_type': FileSourceType.APPLICATION.value
- }).upload()
- return file_url
-
- def generate_history_ai_message(self, chat_record):
- for val in chat_record.details.values():
- if self.node.id == val['node_id'] and 'image_list' in val:
- if val['dialogue_type'] == 'WORKFLOW':
- return chat_record.get_ai_message()
- image_list = val['image_list']
- return AIMessage(content=[
- *[{'type': 'image_url', 'image_url': {'url': f'{file_url}'}} for file_url in image_list]
- ])
- return chat_record.get_ai_message()
-
- def get_history_message(self, history_chat_record, dialogue_number):
- start_index = len(history_chat_record) - dialogue_number
- history_message = reduce(lambda x, y: [*x, *y], [
- [self.generate_history_human_message(history_chat_record[index]),
- self.generate_history_ai_message(history_chat_record[index])]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))], [])
- return history_message
-
- def generate_history_human_message(self, chat_record):
-
- for data in chat_record.details.values():
- if self.node.id == data['node_id'] and 'image_list' in data:
- image_list = data['image_list']
- if len(image_list) == 0 or data['dialogue_type'] == 'WORKFLOW':
- return HumanMessage(content=chat_record.problem_text)
- return HumanMessage(content=data['question'])
- return HumanMessage(content=chat_record.problem_text)
-
- def generate_prompt_question(self, prompt):
- return self.workflow_manage.generate_prompt(prompt)
-
- def generate_message_list(self, question: str, history_message):
- return [
- *history_message,
- question
- ]
-
- @staticmethod
- def reset_message_list(message_list: List[BaseMessage], answer_text):
- result = [{'role': 'user' if isinstance(message, HumanMessage) else 'ai', 'content': message.content} for
- message
- in
- message_list]
- result.append({'role': 'ai', 'content': answer_text})
- return result
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'history_message': [{'content': message.content, 'role': message.type} for message in
- (self.context.get('history_message') if self.context.get(
- 'history_message') is not None else [])],
- 'question': self.context.get('question'),
- 'answer': self.context.get('answer'),
- 'type': self.node.type,
- 'message_tokens': self.context.get('message_tokens'),
- 'answer_tokens': self.context.get('answer_tokens'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'first_frame_url': self.context.get('first_frame_url'),
- 'last_frame_url': self.context.get('last_frame_url'),
- 'dialogue_type': self.context.get('dialogue_type'),
- 'negative_prompt': self.context.get('negative_prompt'),
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/image_understand_step_node/__init__.py b/apps/application/flow/step_node/image_understand_step_node/__init__.py
deleted file mode 100644
index f3feecc9ce2..00000000000
--- a/apps/application/flow/step_node/image_understand_step_node/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .impl import *
diff --git a/apps/application/flow/step_node/image_understand_step_node/i_image_understand_node.py b/apps/application/flow/step_node/image_understand_step_node/i_image_understand_node.py
deleted file mode 100644
index 907ad019a33..00000000000
--- a/apps/application/flow/step_node/image_understand_step_node/i_image_understand_node.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-from django.utils.translation import gettext_lazy as _
-
-
-class ImageUnderstandNodeSerializer(serializers.Serializer):
- model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
- system = serializers.CharField(required=False, allow_blank=True, allow_null=True,
- label=_("Role Setting"))
- prompt = serializers.CharField(required=True, label=_("Prompt word"))
- # 多轮对话数量
- dialogue_number = serializers.IntegerField(required=True, label=_("Number of multi-round conversations"))
-
- dialogue_type = serializers.CharField(required=True, label=_("Conversation storage type"))
-
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- image_list = serializers.ListField(required=False, label=_("picture"))
-
- model_params_setting = serializers.JSONField(required=False, default=dict,
- label=_("Model parameter settings"))
- model_setting = serializers.DictField(required=False,
- label='Model settings')
-
-
-class IImageUnderstandNode(INode):
- type = 'image-understand-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return ImageUnderstandNodeSerializer
-
- def _run(self):
- res = self.workflow_manage.get_reference_field(self.node_params_serializer.data.get('image_list')[0],
- self.node_params_serializer.data.get('image_list')[1:])
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL,
- WorkflowMode.TOOL_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.execute(image=res, **self.node_params_serializer.data, **self.flow_params_serializer.data,
- **{'history_chat_record': [], 'stream': True, 'chat_record_id': None})
- else:
- return self.execute(image=res, **self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, model_id, system, prompt, dialogue_number, dialogue_type, history_chat_record, stream,
- model_params_setting,
- chat_record_id,
- image,
- model_id_type=None, model_id_reference=None,
- model_setting=None,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/image_understand_step_node/impl/__init__.py b/apps/application/flow/step_node/image_understand_step_node/impl/__init__.py
deleted file mode 100644
index ba251283921..00000000000
--- a/apps/application/flow/step_node/image_understand_step_node/impl/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .base_image_understand_node import BaseImageUnderstandNode
diff --git a/apps/application/flow/step_node/image_understand_step_node/impl/base_image_understand_node.py b/apps/application/flow/step_node/image_understand_step_node/impl/base_image_understand_node.py
deleted file mode 100644
index 43ad363d106..00000000000
--- a/apps/application/flow/step_node/image_understand_step_node/impl/base_image_understand_node.py
+++ /dev/null
@@ -1,340 +0,0 @@
-# coding=utf-8
-import base64
-import time
-from functools import reduce
-from imghdr import what
-from typing import List, Dict
-
-from django.db.models import QuerySet
-from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
-from django.utils.translation import gettext_lazy as _
-from application.flow.i_step_node import NodeResult, INode
-from application.flow.step_node.image_understand_step_node.i_image_understand_node import IImageUnderstandNode
-from application.flow.tools import Reasoning
-from knowledge.models import File
-from models_provider.tools import get_model_instance_by_model_workspace_id
-
-
-def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow, answer: str,
- reasoning_content: str):
- chat_model = node_variable.get('chat_model')
- message_tokens = chat_model.get_num_tokens_from_messages(node_variable.get('message_list'))
- answer_tokens = chat_model.get_num_tokens(answer)
- node.context['message_tokens'] = message_tokens
- node.context['answer_tokens'] = answer_tokens
- node.context['answer'] = answer
- node.context['history_message'] = node_variable['history_message']
- node.context['question'] = node_variable['question']
- node.context['run_time'] = time.time() - node.context['start_time']
- node.context['reasoning_content'] = reasoning_content
- if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
- node.answer_text = answer
-
-
-def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据 (流式)
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result')
- answer = ''
- reasoning_content = ''
- model_setting = node.context.get('model_setting',
- {'reasoning_content_enable': False, 'reasoning_content_end': '',
- 'reasoning_content_start': ''})
- reasoning = Reasoning(model_setting.get('reasoning_content_start', ''),
- model_setting.get('reasoning_content_end', ''))
- response_reasoning_content = False
-
- for chunk in response:
- if workflow.is_the_task_interrupted():
- break
-
- # 处理 reasoning content
- reasoning_chunk = reasoning.get_reasoning_content(chunk)
- content_chunk = reasoning_chunk.get('content')
- if 'reasoning_content' in chunk.additional_kwargs:
- response_reasoning_content = True
- reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '')
- else:
- reasoning_content_chunk = reasoning_chunk.get('reasoning_content')
-
- answer += content_chunk
- if reasoning_content_chunk is None:
- reasoning_content_chunk = ''
- reasoning_content += reasoning_content_chunk
-
- # 处理 chunk.content 为 list 的情况
- if isinstance(chunk.content, list):
- for chunk_item in chunk.content:
- text = chunk_item.get("text", "")
- yield {'content': text,
- 'reasoning_content': reasoning_content_chunk if model_setting.get('reasoning_content_enable',
- False) else ''}
- else:
- text = chunk.content or ""
- yield {'content': text,
- 'reasoning_content': reasoning_content_chunk if model_setting.get('reasoning_content_enable',
- False) else ''}
-
- reasoning_chunk = reasoning.get_end_reasoning_content()
- answer += reasoning_chunk.get('content')
- reasoning_content_chunk = ""
- if not response_reasoning_content:
- reasoning_content_chunk = reasoning_chunk.get(
- 'reasoning_content')
- yield {'content': reasoning_chunk.get('content'),
- 'reasoning_content': reasoning_content_chunk if model_setting.get('reasoning_content_enable',
- False) else ''}
- _write_context(node_variable, workflow_variable, node, workflow, answer, reasoning_content)
-
-
-def write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点实例对象
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result')
- model_setting = node.context.get('model_setting',
- {'reasoning_content_enable': False, 'reasoning_content_end': '',
- 'reasoning_content_start': ''})
- reasoning = Reasoning(model_setting.get('reasoning_content_start'), model_setting.get('reasoning_content_end'))
- reasoning_result = reasoning.get_reasoning_content(response)
- reasoning_result_end = reasoning.get_end_reasoning_content()
- content = reasoning_result.get('content') + reasoning_result_end.get('content')
- meta = {**response.response_metadata, **response.additional_kwargs}
- if 'reasoning_content' in meta:
- reasoning_content = (meta.get('reasoning_content', '') or '')
- else:
- reasoning_content = (reasoning_result.get('reasoning_content') or '') + (
- reasoning_result_end.get('reasoning_content') or '')
- _write_context(node_variable, workflow_variable, node, workflow, content, reasoning_content)
-
-
-def file_id_to_base64(file_id: str):
- file = QuerySet(File).filter(id=file_id).first()
- file_bytes = file.get_bytes()
- base64_image = base64.b64encode(file_bytes).decode("utf-8")
- return [base64_image, what(None, file_bytes)]
-
-
-class BaseImageUnderstandNode(IImageUnderstandNode):
- def save_context(self, details, workflow_manage):
- self.context['answer'] = details.get('answer')
- self.context['question'] = details.get('question')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
-
- def execute(self, model_id, system, prompt, dialogue_number, dialogue_type, history_chat_record, stream,
- model_params_setting,
- chat_record_id,
- image,
- model_id_type=None, model_id_reference=None,
- model_setting=None,
- **kwargs) -> NodeResult:
- if model_setting is None:
- model_setting = {'reasoning_content_enable': False, 'reasoning_content_end': '',
- 'reasoning_content_start': ''}
- self.context['model_setting'] = model_setting
- # 处理引用类型
- if model_id_type == 'reference' and model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- model_id_reference[0],
- model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- model_id = reference_data.get('model_id', model_id)
- model_params_setting = reference_data.get('model_params_setting')
-
- if model_id is None or model_id == '':
- raise Exception(_('Model is not allowed to be empty'))
- # 处理不正确的参数
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- image_model = get_model_instance_by_model_workspace_id(model_id, workspace_id,
- **(model_params_setting or {}))
- # 执行详情中的历史消息不需要图片内容
- history_message = self.get_history_message_for_details(history_chat_record, dialogue_number)
- self.context['history_message'] = history_message
- question = self.generate_prompt_question(prompt)
- self.context['question'] = question.content
- system = self.workflow_manage.generate_prompt(system)
- self.context['system'] = system
- # 生成消息列表, 真实的history_message
- message_list = self.generate_message_list(image_model, system, prompt,
- self.get_history_message(history_chat_record, dialogue_number), image)
- self.context['message_list'] = message_list
- self.generate_context_image(image)
- self.context['dialogue_type'] = dialogue_type
- if stream:
- r = image_model.stream(message_list)
- return NodeResult({'result': r, 'chat_model': image_model, 'message_list': message_list,
- 'history_message': history_message, 'question': question.content}, {},
- _write_context=write_context_stream)
- else:
- r = image_model.invoke(message_list)
- return NodeResult({'result': r, 'chat_model': image_model, 'message_list': message_list,
- 'history_message': history_message, 'question': question.content}, {},
- _write_context=write_context)
-
- def generate_context_image(self, image):
- if isinstance(image, str) and image.startswith('http'):
- self.context['image_list'] = [{'url': image}]
- elif image is not None and len(image) > 0:
- self.context['image_list'] = image
-
- def get_history_message_for_details(self, history_chat_record, dialogue_number):
- start_index = len(history_chat_record) - dialogue_number
- history_message = reduce(lambda x, y: [*x, *y], [
- [self.generate_history_human_message_for_details(history_chat_record[index]),
- self.generate_history_ai_message(history_chat_record[index])]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))], [])
- return history_message
-
- def generate_history_ai_message(self, chat_record):
- for val in chat_record.details.values():
- if self.node.id == val['node_id'] and 'image_list' in val:
- if val['dialogue_type'] == 'WORKFLOW':
- return chat_record.get_ai_message()
- return AIMessage(content=val['answer'])
- return chat_record.get_ai_message()
-
- def generate_history_human_message_for_details(self, chat_record):
- for data in chat_record.details.values():
- if self.node.id == data['node_id'] and 'image_list' in data:
- image_list = data['image_list'] or []
- if len(image_list) == 0 or data['dialogue_type'] == 'WORKFLOW':
- return HumanMessage(content=chat_record.problem_text)
-
- file_id_list = []
- url_list = []
- for image in image_list:
- if 'file_id' in image:
- file_id_list.append(image.get('file_id'))
- elif 'url' in image:
- url_list.append(image.get('url'))
- return HumanMessage(content=[
- {'type': 'text', 'text': data['question']},
- *[{'type': 'image_url', 'image_url': {'url': f'./oss/file/{file_id}'}} for file_id in file_id_list],
- *[{'type': 'image_url', 'image_url': {'url': url}} for url in url_list]
- ])
- return HumanMessage(content=chat_record.problem_text)
-
- def get_history_message(self, history_chat_record, dialogue_number):
- start_index = len(history_chat_record) - dialogue_number
- history_message = reduce(lambda x, y: [*x, *y], [
- [self.generate_history_human_message(history_chat_record[index]),
- self.generate_history_ai_message(history_chat_record[index])]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))], [])
- return history_message
-
- def generate_history_human_message(self, chat_record):
-
- for data in chat_record.details.values():
- if self.node.id == data['node_id'] and 'image_list' in data:
- image_list = data['image_list'] or []
- if len(image_list) == 0 or data['dialogue_type'] == 'WORKFLOW':
- return HumanMessage(content=chat_record.problem_text)
- file_id_list = []
- url_list = []
- for image in image_list:
- if 'file_id' in image:
- file_id_list.append(image.get('file_id'))
- elif 'url' in image:
- url_list.append(image.get('url'))
- image_base64_list = [file_id_to_base64(file_id) for file_id in file_id_list]
-
- return HumanMessage(
- content=[
- {'type': 'text', 'text': data['question']},
- *[{'type': 'image_url',
- 'image_url': {'url': f'data:image/{base64_image[1]};base64,{base64_image[0]}'}} for
- base64_image in image_base64_list],
- *[{'type': 'image_url', 'image_url': {'url': url}} for url in url_list]
- ])
- return HumanMessage(content=chat_record.problem_text)
-
- def generate_prompt_question(self, prompt):
- return HumanMessage(self.workflow_manage.generate_prompt(prompt))
-
- def _process_images(self, image):
- """
- 处理图像数据,转换为模型可识别的格式
- """
- images = []
- if isinstance(image, str) and image.startswith('http'):
- images.append({'type': 'image_url', 'image_url': {'url': image}})
- elif image is not None and len(image) > 0:
- for img in image:
- if 'file_id' in img:
- file_id = img['file_id']
- file = QuerySet(File).filter(id=file_id).first()
- image_bytes = file.get_bytes()
- base64_image = base64.b64encode(image_bytes).decode("utf-8")
- image_format = what(None, image_bytes)
- images.append(
- {'type': 'image_url', 'image_url': {'url': f'data:image/{image_format};base64,{base64_image}'}})
- elif 'url' in img and img['url'].startswith('http'):
- images.append(
- {'type': 'image_url', 'image_url': {'url': img["url"]}})
- return images
-
- def generate_message_list(self, image_model, system: str, prompt: str, history_message, image):
- prompt_text = self.workflow_manage.generate_prompt(prompt)
- images = self._process_images(image)
-
- if images:
- messages = [HumanMessage(content=[{'type': 'text', 'text': prompt_text}, *images])]
- else:
- messages = [HumanMessage(prompt_text)]
-
- if system is not None and len(system) > 0:
- return [
- SystemMessage(system),
- *history_message,
- *messages
- ]
- else:
- return [
- *history_message,
- *messages
- ]
-
- @staticmethod
- def reset_message_list(message_list: List[BaseMessage], answer_text):
- result = [{'role': 'user' if isinstance(message, HumanMessage) else 'ai', 'content': message.content} for
- message
- in
- message_list]
- result.append({'role': 'ai', 'content': answer_text})
- return result
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'system': self.context.get('system'),
- 'history_message': [{'content': message.content, 'role': message.type} for message in
- (self.context.get('history_message') if self.context.get(
- 'history_message') is not None else [])],
- 'question': self.context.get('question'),
- 'answer': self.context.get('answer'),
- 'reasoning_content': self.context.get('reasoning_content'),
- 'type': self.node.type,
- 'message_tokens': self.context.get('message_tokens'),
- 'answer_tokens': self.context.get('answer_tokens'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'image_list': self.context.get('image_list'),
- 'dialogue_type': self.context.get('dialogue_type'),
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/intent_node/__init__.py b/apps/application/flow/step_node/intent_node/__init__.py
deleted file mode 100644
index 4b372238e7d..00000000000
--- a/apps/application/flow/step_node/intent_node/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-# coding=utf-8
-
-
-
-
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/intent_node/i_intent_node.py b/apps/application/flow/step_node/intent_node/i_intent_node.py
deleted file mode 100644
index d22d321c842..00000000000
--- a/apps/application/flow/step_node/intent_node/i_intent_node.py
+++ /dev/null
@@ -1,59 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class IntentBranchSerializer(serializers.Serializer):
- id = serializers.CharField(required=True, label=_("Branch id"))
- content = serializers.CharField(required=True, label=_("content"))
- isOther = serializers.BooleanField(required=True, label=_("Branch Type"))
-
-
-class IntentNodeSerializer(serializers.Serializer):
- model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
- content_list = serializers.ListField(required=True, label=_("Text content"))
- dialogue_number = serializers.IntegerField(required=True, label=
- _("Number of multi-round conversations"))
- model_params_setting = serializers.DictField(required=False,
- label=_("Model parameter settings"))
- branch = IntentBranchSerializer(many=True)
-
-
-class IIntentNode(INode):
- type = 'intent-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def save_context(self, details, workflow_manage):
- pass
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return IntentNodeSerializer
-
- def _run(self):
- question = self.workflow_manage.get_reference_field(
- self.node_params_serializer.data.get('content_list')[0],
- self.node_params_serializer.data.get('content_list')[1:],
- )
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL,
- WorkflowMode.TOOL_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data,
- **{'history_chat_record': [], 'stream': True, 'chat_id': None, 'chat_record_id': None,
- 'user_input': str(question)})
- else:
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data,
- user_input=str(question))
-
- def execute(self, model_id, dialogue_number, history_chat_record, user_input, branch,
- model_params_setting=None, model_id_type=None, model_id_reference=None, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/intent_node/impl/__init__.py b/apps/application/flow/step_node/intent_node/impl/__init__.py
deleted file mode 100644
index 56954da75d4..00000000000
--- a/apps/application/flow/step_node/intent_node/impl/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-from .base_intent_node import BaseIntentNode
\ No newline at end of file
diff --git a/apps/application/flow/step_node/intent_node/impl/base_intent_node.py b/apps/application/flow/step_node/intent_node/impl/base_intent_node.py
deleted file mode 100644
index b3f1608acc2..00000000000
--- a/apps/application/flow/step_node/intent_node/impl/base_intent_node.py
+++ /dev/null
@@ -1,265 +0,0 @@
-# coding=utf-8
-import json
-import re
-import time
-from typing import List, Dict, Any
-from functools import reduce
-
-from django.db.models import QuerySet
-from langchain_core.messages import HumanMessage, SystemMessage
-
-from application.flow.i_step_node import INode, NodeResult
-from application.flow.step_node.intent_node.i_intent_node import IIntentNode
-from models_provider.models import Model
-from models_provider.tools import get_model_instance_by_model_workspace_id, get_model_credential
-from .prompt_template import PROMPT_TEMPLATE
-
-
-def get_default_model_params_setting(model_id):
- model = QuerySet(Model).filter(id=model_id).first()
- credential = get_model_credential(model.provider, model.model_type, model.model_name)
- model_params_setting = credential.get_model_params_setting_form(
- model.model_name).get_default_form_data()
- return model_params_setting
-
-
-def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow, answer: str):
- chat_model = node_variable.get('chat_model')
- message_tokens = chat_model.get_num_tokens_from_messages(node_variable.get('message_list'))
- answer_tokens = chat_model.get_num_tokens(answer)
-
- node.context['message_tokens'] = message_tokens
- node.context['answer_tokens'] = answer_tokens
- node.context['answer'] = answer
- node.context['history_message'] = node_variable['history_message']
- node.context['user_input'] = node_variable['user_input']
- node.context['branch_id'] = node_variable.get('branch_id')
- node.context['reason'] = node_variable.get('reason')
- node.context['category'] = node_variable.get('category')
- node.context['run_time'] = time.time() - node.context['start_time']
-
-
-def write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- response = node_variable.get('result')
- answer = response.content
- _write_context(node_variable, workflow_variable, node, workflow, answer)
-
-
-class BaseIntentNode(IIntentNode):
-
- def save_context(self, details, workflow_manage):
- self.context['exception_message'] = details.get('err_message')
- self.context['branch_id'] = details.get('branch_id')
- self.context['category'] = details.get('category')
-
- def execute(self, model_id, dialogue_number, history_chat_record, user_input, branch,
- model_params_setting=None, model_id_type=None, model_id_reference=None, **kwargs) -> NodeResult:
- # 处理引用类型
- if model_id_type == 'reference' and model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- model_id_reference[0],
- model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- model_id = reference_data.get('model_id', model_id)
- model_params_setting = reference_data.get('model_params_setting')
- if not model_id:
- raise Exception(_('Model is not allowed to be empty'))
-
- # 设置默认模型参数
- if model_params_setting is None and model_id:
- model_params_setting = get_default_model_params_setting(model_id)
-
- # 获取模型实例
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- chat_model = get_model_instance_by_model_workspace_id(
- model_id, workspace_id, **(model_params_setting or {})
- )
-
- # 获取历史对话
- history_message = self.get_history_message(history_chat_record, dialogue_number)
- self.context['history_message'] = history_message
-
- # 保存问题到上下文
- self.context['user_input'] = user_input
-
- # 构建分类提示词
- prompt = self.build_classification_prompt(user_input, branch)
-
- # 生成消息列表
- system = self.build_system_prompt()
- message_list = self.generate_message_list(system, prompt, history_message)
- self.context['message_list'] = message_list
-
- # 调用模型进行分类
- try:
- r = chat_model.invoke(message_list)
- classification_result = r.content.strip()
- # 解析分类结果获取分支信息
- matched_branch = self.parse_classification_result(classification_result, branch)
-
- # 返回结果
- return NodeResult({
- 'result': r,
- 'chat_model': chat_model,
- 'message_list': message_list,
- 'history_message': history_message,
- 'user_input': user_input,
- 'branch_id': matched_branch['id'],
- 'reason': self.parse_result_reason(r.content),
- 'category': matched_branch.get('content', matched_branch['id'])
- }, {}, _write_context=write_context)
-
- except Exception as e:
- # 错误处理:返回"其他"分支
- other_branch = self.find_other_branch(branch)
- if other_branch:
- return NodeResult({
- 'branch_id': other_branch['id'],
- 'category': other_branch.get('content', other_branch['id']),
- 'error': str(e)
- }, {})
- else:
- raise Exception(f"error: {str(e)}")
-
- @staticmethod
- def get_history_message(history_chat_record, dialogue_number):
- """获取历史消息"""
- start_index = len(history_chat_record) - dialogue_number
- history_message = reduce(lambda x, y: [*x, *y], [
- [history_chat_record[index].get_human_message(), history_chat_record[index].get_ai_message()]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))], [])
-
- for message in history_message:
- if isinstance(message.content, str):
- message.content = re.sub(r'.*?<\/form_rander>', '', message.content, flags=re.DOTALL)
- return history_message
-
- def build_system_prompt(self) -> str:
- """构建系统提示词"""
- return "你是一个专业的意图识别助手,请根据用户输入和意图选项,准确识别用户的真实意图。"
-
- def build_classification_prompt(self, user_input: str, branch: List[Dict]) -> str:
- """构建分类提示词"""
-
- classification_list = []
-
- other_branch = self.find_other_branch(branch)
- # 添加其他分支
- if other_branch:
- classification_list.append({
- "classificationId": 0,
- "content": other_branch.get('content')
- })
- # 添加正常分支
- classification_id = 1
- for b in branch:
- if not b.get('isOther'):
- classification_list.append({
- "classificationId": classification_id,
- "content": b['content']
- })
- classification_id += 1
-
- return PROMPT_TEMPLATE.format(
- classification_list=classification_list,
- user_input=user_input
- )
-
- def generate_message_list(self, system: str, prompt: str, history_message):
- """生成消息列表"""
- if system is None or len(system) == 0:
- return [*history_message, HumanMessage(self.workflow_manage.generate_prompt(prompt))]
- else:
- return [SystemMessage(self.workflow_manage.generate_prompt(system)), *history_message,
- HumanMessage(self.workflow_manage.generate_prompt(prompt))]
-
- def parse_classification_result(self, result: str, branch: List[Dict]) -> Dict[str, Any]:
- """解析分类结果"""
-
- other_branch = self.find_other_branch(branch)
- normal_intents = [
- b
- for b in branch
- if not b.get('isOther')
- ]
-
- def get_branch_by_id(category_id: int):
- if category_id == 0:
- return other_branch
- elif 1 <= category_id <= len(normal_intents):
- return normal_intents[category_id - 1]
- return None
-
- try:
- result_json = json.loads(result)
- classification_id = result_json.get('classificationId')
- # 如果是 0 ,返回其他分支
- matched_branch = get_branch_by_id(classification_id)
- if matched_branch:
- return matched_branch
-
- except Exception as e:
- # json 解析失败,re 提取
- numbers = re.findall(r'"classificationId":\s*(\d+)', result)
- if numbers:
- classification_id = int(numbers[0])
-
- matched_branch = get_branch_by_id(classification_id)
- if matched_branch:
- return matched_branch
-
- # 如果都解析失败,返回“other”
- return other_branch or (normal_intents[0] if normal_intents else {'id': 'unknown', 'content': 'unknown'})
-
- def parse_result_reason(self, result: str):
- """解析分类的原因"""
- try:
- result_json = json.loads(result)
- return result_json.get('reason', '')
- except Exception as e:
- reason_patterns = [
- r'"reason":\s*"([^"]*)"', # 标准格式
- r'"reason":\s*"([^"]*)', # 缺少结束引号
- r'"reason":\s*([^,}\n]*)', # 没有引号包围的内容
- ]
- for pattern in reason_patterns:
- match = re.search(pattern, result, re.DOTALL)
- if match:
- reason = match.group(1).strip()
- # 清理可能的尾部字符
- reason = re.sub(r'["\s]*$', '', reason)
- return reason
-
- return ''
-
- def find_other_branch(self, branch: List[Dict]) -> Dict[str, Any] | None:
- """查找其他分支"""
- for b in branch:
- if b.get('isOther'):
- return b
- return None
-
- def get_details(self, index: int, **kwargs):
- """获取节点执行详情"""
- return {
- 'name': self.node.properties.get('stepName'),
- 'index': index,
- 'run_time': self.context.get('run_time'),
- 'system': self.context.get('system'),
- 'history_message': [
- {'content': message.content, 'role': message.type}
- for message in (self.context.get('history_message') or [])
- ],
- 'user_input': self.context.get('user_input'),
- 'answer': self.context.get('answer'),
- 'branch_id': self.context.get('branch_id'),
- 'category': self.context.get('category'),
- 'type': self.node.type,
- 'message_tokens': self.context.get('message_tokens'),
- 'answer_tokens': self.context.get('answer_tokens'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/knowledge_write_node/__init__.py b/apps/application/flow/step_node/knowledge_write_node/__init__.py
deleted file mode 100644
index ea50569d563..00000000000
--- a/apps/application/flow/step_node/knowledge_write_node/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:niu
- @file: __init__.py.py
- @date:2025/11/13 11:17
- @desc:
-"""
\ No newline at end of file
diff --git a/apps/application/flow/step_node/knowledge_write_node/i_knowledge_write_node.py b/apps/application/flow/step_node/knowledge_write_node/i_knowledge_write_node.py
deleted file mode 100644
index 2f5349fa613..00000000000
--- a/apps/application/flow/step_node/knowledge_write_node/i_knowledge_write_node.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:niu
- @file: i_knowledge_write_node.py
- @date:2025/11/13 11:19
- @desc:
-"""
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class KnowledgeWriteNodeParamSerializer(serializers.Serializer):
- document_list = serializers.ListField(required=True, child=serializers.CharField(required=True), allow_null=True,
- label=_('document list'))
-
-
-class IKnowledgeWriteNode(INode):
-
- def save_context(self, details, workflow_manage):
- pass
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return KnowledgeWriteNodeParamSerializer
-
- def _run(self):
- documents = self.workflow_manage.get_reference_field(
- self.node_params_serializer.data.get('document_list')[0],
- self.node_params_serializer.data.get('document_list')[1:],
- )
-
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data, documents=documents)
-
- def execute(self, documents, user_id, **kwargs) -> NodeResult:
- pass
-
- type = 'knowledge-write-node'
- support = [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP]
diff --git a/apps/application/flow/step_node/knowledge_write_node/impl/__init__.py b/apps/application/flow/step_node/knowledge_write_node/impl/__init__.py
deleted file mode 100644
index 077d7432575..00000000000
--- a/apps/application/flow/step_node/knowledge_write_node/impl/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:niu
- @file: __init__.py.py
- @date:2025/11/13 11:18
- @desc:
-"""
diff --git a/apps/application/flow/step_node/knowledge_write_node/impl/base_knowledge_write_node.py b/apps/application/flow/step_node/knowledge_write_node/impl/base_knowledge_write_node.py
deleted file mode 100644
index aebf9d009d1..00000000000
--- a/apps/application/flow/step_node/knowledge_write_node/impl/base_knowledge_write_node.py
+++ /dev/null
@@ -1,343 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:niu
- @file: base_knowledge_write_node.py
- @date:2025/11/13 11:19
- @desc:
-"""
-from functools import reduce
-from typing import Any, Dict, List
-
-import uuid_utils.compat as uuid
-from common.chunk import text_to_chunk
-from common.utils.common import bulk_create_in_batches, filter_special_character
-from django.db.models import QuerySet
-from django.db.models.aggregates import Max
-from django.utils.translation import gettext_lazy as _
-from knowledge.models import (
- Document,
- DocumentTag,
- File,
- FileSourceType,
- KnowledgeType,
- Paragraph,
- Problem,
- ProblemParagraphMapping,
- Tag,
-)
-from knowledge.serializers.common import ProblemParagraphManage, ProblemParagraphObject
-from knowledge.serializers.document import DocumentSerializers
-from rest_framework import serializers
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.knowledge_write_node.i_knowledge_write_node import IKnowledgeWriteNode
-
-
-class ParagraphInstanceSerializer(serializers.Serializer):
- content = serializers.CharField(required=True, label=_('content'), max_length=102400, min_length=1, allow_null=True,
- allow_blank=True)
- title = serializers.CharField(required=False, max_length=256, label=_('section title'), allow_null=True,
- allow_blank=True)
- problem_list = serializers.ListField(required=False, child=serializers.CharField(required=False, allow_blank=True))
- is_active = serializers.BooleanField(required=False, label=_('Is active'))
- chunks = serializers.ListField(required=False, child=serializers.CharField(required=True))
-
-
-class TagInstanceSerializer(serializers.Serializer):
- key = serializers.CharField(required=True, max_length=64, label=_('Tag Key'))
- value = serializers.CharField(required=True, max_length=128, label=_('Tag Value'))
-
-
-class KnowledgeWriteParamSerializer(serializers.Serializer):
- name = serializers.CharField(required=True, label=_('document name'), max_length=128, min_length=1,
- source=_('document name'))
- meta = serializers.DictField(required=False)
- tags = serializers.ListField(required=False, label=_('Tags'), child=TagInstanceSerializer())
- paragraphs = ParagraphInstanceSerializer(required=False, many=True, allow_null=True)
- source_file_id = serializers.UUIDField(required=False, allow_null=True)
- user_id = serializers.UUIDField(required=False, allow_null=True)
-
-
-def convert_uuid_to_str(obj):
- if isinstance(obj, dict):
- return {k: convert_uuid_to_str(v) for k, v in obj.items()}
- elif isinstance(obj, list):
- return [convert_uuid_to_str(i) for i in obj]
- elif isinstance(obj, uuid.UUID):
- return str(obj)
- else:
- return obj
-
-
-def link_file(source_file_id, document_id):
- if source_file_id is None:
- return
- source_file = QuerySet(File).filter(id=source_file_id).first()
- if source_file:
- file_content = source_file.get_bytes()
-
- new_file = File(
- id=uuid.uuid7(),
- file_name=source_file.file_name,
- file_size=source_file.file_size,
- source_type=FileSourceType.DOCUMENT,
- source_id=document_id, # 更新为当前知识库ID
- meta=source_file.meta.copy() if source_file.meta else {}
- )
-
- # 保存文件内容和元数据
- new_file.save(file_content)
-
-
-def get_paragraph_problem_model(knowledge_id: str, document_id: str, instance: Dict):
- paragraph = Paragraph(
- id=uuid.uuid7(),
- document_id=document_id,
- content=filter_special_character(instance.get("content")),
- knowledge_id=knowledge_id,
- title=instance.get("title") if 'title' in instance else '',
- chunks=[filter_special_character(c) for c in (instance.get('chunks') if 'chunks' in instance else text_to_chunk(
- instance.get("content")))],
- )
-
- problem_paragraph_object_list = [ProblemParagraphObject(
- knowledge_id, document_id, str(paragraph.id), problem
- ) for problem in (instance.get('problem_list') if 'problem_list' in instance else [])]
-
- return {
- 'paragraph': paragraph,
- 'problem_paragraph_object_list': problem_paragraph_object_list,
- }
-
-
-def get_paragraph_model(document_model, paragraph_list: List):
- knowledge_id = document_model.knowledge_id
- paragraph_model_dict_list = [
- get_paragraph_problem_model(knowledge_id, document_model.id, paragraph)
- for paragraph in paragraph_list
- ]
-
- paragraph_model_list = []
- problem_paragraph_object_list = []
- for paragraphs in paragraph_model_dict_list:
- paragraph = paragraphs.get('paragraph')
- for problem_model in paragraphs.get('problem_paragraph_object_list'):
- problem_paragraph_object_list.append(problem_model)
- paragraph_model_list.append(paragraph)
-
- return {
- 'document': document_model,
- 'paragraph_model_list': paragraph_model_list,
- 'problem_paragraph_object_list': problem_paragraph_object_list,
- }
-
-
-def get_document_paragraph_model(knowledge_id: str, instance: Dict):
- source_meta = {'source_file_id': instance.get("source_file_id")} if instance.get("source_file_id") else {}
- meta = {**instance.get('meta'), **source_meta} if instance.get('meta') is not None else source_meta
- meta = {**convert_uuid_to_str(meta), 'allow_download': True}
-
- document_model = Document(
- **{
- 'knowledge_id': knowledge_id,
- 'id': uuid.uuid7(),
- 'name': instance.get('name'),
- 'char_length': reduce(
- lambda x, y: x + y,
- [len(p.get('content')) for p in instance.get('paragraphs', [])],
- 0),
- 'meta': meta,
- 'type': instance.get('type') if instance.get('type') is not None else KnowledgeType.WORKFLOW,
- "user_id": instance.get("user_id"),
- }
- )
-
- return get_paragraph_model(
- document_model,
- instance.get('paragraphs') if 'paragraphs' in instance else []
- )
-
-
-def save_knowledge_tags(knowledge_id: str, tags: List[Dict[str, Any]]):
- existed_tags_dict = {
- (key, value): str(tag_id)
- for key, value, tag_id in QuerySet(Tag).filter(knowledge_id=knowledge_id).values_list("key", "value", "id")
- }
-
- tag_model_list = []
- new_tag_dict = {}
- for tag in tags:
- key = tag.get("key")
- value = tag.get("value")
-
- if (key, value) not in existed_tags_dict:
- tag_model = Tag(
- id=uuid.uuid7(),
- knowledge_id=knowledge_id,
- key=key,
- value=value
- )
- tag_model_list.append(tag_model)
- new_tag_dict[(key, value)] = str(tag_model.id)
-
- if tag_model_list:
- Tag.objects.bulk_create(tag_model_list)
-
- all_tag_dict = {**existed_tags_dict, **new_tag_dict}
-
- return all_tag_dict, new_tag_dict
-
-
-def batch_add_document_tag(document_tag_map: Dict[str, List[str]]):
- """
- 批量添加文档-标签关联
- document_tag_map: {document_id: [tag_id1, tag_id2, ...]}
- """
- all_document_ids = list(document_tag_map.keys())
- all_tag_ids = list(set(tag_id for tag_ids in document_tag_map.values() for tag_id in tag_ids))
-
- # 查询已存在的文档-标签关联
- existed_relations = set(
- QuerySet(DocumentTag).filter(
- document_id__in=all_document_ids,
- tag_id__in=all_tag_ids
- ).values_list('document_id', 'tag_id')
- )
-
- new_relations = [
- DocumentTag(
- id=uuid.uuid7(),
- document_id=doc_id,
- tag_id=tag_id,
- )
- for doc_id, tag_ids in document_tag_map.items()
- for tag_id in tag_ids
- if (doc_id, tag_id) not in existed_relations
- ]
-
- if new_relations:
- QuerySet(DocumentTag).bulk_create(new_relations)
-
-
-class BaseKnowledgeWriteNode(IKnowledgeWriteNode):
-
- def save_context(self, details, workflow_manage):
- self.context['exception_message'] = details.get('err_message')
-
- def save(self, document_list, user_id):
- serializer = KnowledgeWriteParamSerializer(data=document_list, many=True)
- serializer.is_valid(raise_exception=True)
- document_list = serializer.data
-
- knowledge_id = self.workflow_params.get("knowledge_id")
- workspace_id = self.workflow_params.get("workspace_id")
-
- document_model_list = []
- paragraph_model_list = []
- problem_paragraph_object_list = []
- # 所有标签
- knowledge_tag_list = []
- # 文档标签映射关系
- document_tags_map = {}
- knowledge_tag_dict = {}
-
- for document in document_list:
- document["user_id"] = user_id
- document_paragraph_dict_model = get_document_paragraph_model(
- knowledge_id,
- document
- )
- document_instance = document_paragraph_dict_model.get('document')
- link_file(document.get("source_file_id"), document_instance.id)
- document_model_list.append(document_instance)
- # 收集标签
- single_document_tag_list = document.get("tags", [])
- # 去重传入的标签
- for tag in single_document_tag_list:
- tag_key = (tag['key'], tag['value'])
- if tag_key not in knowledge_tag_dict:
- knowledge_tag_dict[tag_key] = tag
-
- if single_document_tag_list:
- document_tags_map[str(document_instance.id)] = single_document_tag_list
-
- for paragraph in document_paragraph_dict_model.get("paragraph_model_list"):
- paragraph_model_list.append(paragraph)
- for problem_paragraph_object in document_paragraph_dict_model.get("problem_paragraph_object_list"):
- problem_paragraph_object_list.append(problem_paragraph_object)
- knowledge_tag_list = list(knowledge_tag_dict.values())
- # 保存所有文档中含有的标签到知识库
- if knowledge_tag_list:
- all_tag_dict, new_tag_dict = save_knowledge_tags(knowledge_id, knowledge_tag_list)
- # 构建文档-标签ID映射
- document_tag_id_map = {}
- # 为每个文档添加其对应的标签
- for doc_id, doc_tags in document_tags_map.items():
- doc_tag_ids = [
- all_tag_dict[(tag.get("key"), tag.get("value"))]
- for tag in doc_tags
- if (tag.get("key"), tag.get("value")) in all_tag_dict
- ]
- if doc_tag_ids:
- document_tag_id_map[doc_id] = doc_tag_ids
- if document_tag_id_map:
- batch_add_document_tag(document_tag_id_map)
-
- problem_model_list, problem_paragraph_mapping_list = (
- ProblemParagraphManage(problem_paragraph_object_list, knowledge_id).to_problem_model_list()
- )
-
- QuerySet(Document).bulk_create(document_model_list) if len(document_model_list) > 0 else None
-
- if len(paragraph_model_list) > 0:
- for document in document_model_list:
- max_position = Paragraph.objects.filter(document_id=document.id).aggregate(
- max_position=Max('position')
- )['max_position'] or 0
- sub_list = [p for p in paragraph_model_list if p.document_id == document.id]
- for i, paragraph in enumerate(sub_list):
- paragraph.position = max_position + i + 1
- QuerySet(Paragraph).bulk_create(sub_list if len(sub_list) > 0 else [])
-
- bulk_create_in_batches(Problem, problem_model_list, batch_size=1000)
-
- bulk_create_in_batches(ProblemParagraphMapping, problem_paragraph_mapping_list, batch_size=1000)
-
- return document_model_list, knowledge_id, workspace_id
-
- @staticmethod
- def post_embedding(document_model_list, knowledge_id, workspace_id):
- for document in document_model_list:
- DocumentSerializers.Operate(data={
- 'knowledge_id': knowledge_id,
- 'document_id': document.id,
- 'workspace_id': workspace_id
- }).refresh()
-
- def execute(self, documents, user_id, **kwargs) -> NodeResult:
-
- document_model_list, knowledge_id, workspace_id = self.save(documents, user_id)
- self.post_embedding(document_model_list, knowledge_id, workspace_id)
-
- write_content_list = [{
- "name": document.get("name"),
- "paragraphs": [{
- "title": p.get("title"),
- "content": p.get("content"),
- } for p in document.get("paragraphs")[0:5]]
- } for document in documents]
-
- return NodeResult({'write_content': write_content_list}, {})
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'write_content': self.context.get("write_content"),
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/loop_break_node/__init__.py b/apps/application/flow/step_node/loop_break_node/__init__.py
deleted file mode 100644
index ee45b3ee837..00000000000
--- a/apps/application/flow/step_node/loop_break_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2025/9/15 12:08
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/loop_break_node/i_loop_break_node.py b/apps/application/flow/step_node/loop_break_node/i_loop_break_node.py
deleted file mode 100644
index 07edf227b53..00000000000
--- a/apps/application/flow/step_node/loop_break_node/i_loop_break_node.py
+++ /dev/null
@@ -1,41 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: i_loop_break_node.py
- @date:2025/9/15 12:14
- @desc:
-"""
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode
-from application.flow.i_step_node import NodeResult
-
-
-class ConditionSerializer(serializers.Serializer):
- compare = serializers.CharField(required=True, label=_("Comparator"))
- value = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("value"))
- field = serializers.ListField(required=True, label=_("Fields"))
-
-
-class LoopBreakNodeSerializer(serializers.Serializer):
- condition = serializers.CharField(required=True, label=_("Condition or|and"))
- condition_list = ConditionSerializer(many=True)
-
-
-class ILoopBreakNode(INode):
- type = 'loop-break-node'
- support = [WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return LoopBreakNodeSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data)
-
- def execute(self, condition, condition_list, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/loop_break_node/impl/__init__.py b/apps/application/flow/step_node/loop_break_node/impl/__init__.py
deleted file mode 100644
index 0ed3e008022..00000000000
--- a/apps/application/flow/step_node/loop_break_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2025/9/15 12:16
- @desc:
-"""
-from .base_loop_break_node import BaseLoopBreakNode
diff --git a/apps/application/flow/step_node/loop_break_node/impl/base_loop_break_node.py b/apps/application/flow/step_node/loop_break_node/impl/base_loop_break_node.py
deleted file mode 100644
index f82289729da..00000000000
--- a/apps/application/flow/step_node/loop_break_node/impl/base_loop_break_node.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: base_loop_break_node.py
- @date:2025/9/15 12:17
- @desc:
-"""
-import time
-from typing import Dict
-
-from application.flow.compare import do_assertion
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.loop_break_node.i_loop_break_node import ILoopBreakNode
-
-
-def _write_context(step_variable: Dict, global_variable: Dict, node, workflow):
- if step_variable.get("is_break"):
- yield "BREAK"
-
- node.context['run_time'] = time.time() - node.context['start_time']
-
-
-class BaseLoopBreakNode(ILoopBreakNode):
- def save_context(self, details, workflow_manage):
- self.context['exception_message'] = details.get('err_message')
-
- def execute(self, condition, condition_list, **kwargs) -> NodeResult:
- is_break = do_assertion(self.workflow_manage, condition, condition_list)
- if is_break:
- self.node_params['is_result'] = True
- self.context['is_break'] = is_break
- return NodeResult({'is_break': is_break}, {},
- _write_context=_write_context,
- _is_interrupt=lambda n, v, w: is_break)
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'is_break': self.context.get('is_break'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/loop_continue_node/__init__.py b/apps/application/flow/step_node/loop_continue_node/__init__.py
deleted file mode 100644
index 9f7f1729d5c..00000000000
--- a/apps/application/flow/step_node/loop_continue_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2025/9/15 12:08
- @desc:
-"""
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/loop_continue_node/i_loop_continue_node.py b/apps/application/flow/step_node/loop_continue_node/i_loop_continue_node.py
deleted file mode 100644
index 00b6aa04c39..00000000000
--- a/apps/application/flow/step_node/loop_continue_node/i_loop_continue_node.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: i_loop_continue_node.py
- @date:2025/9/15 12:13
- @desc:
-"""
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class ConditionSerializer(serializers.Serializer):
- compare = serializers.CharField(required=True, label=_("Comparator"))
- value = serializers.CharField(required=True, label=_("value"))
- field = serializers.ListField(required=True, label=_("Fields"))
-
-
-class LoopContinueNodeSerializer(serializers.Serializer):
- condition = serializers.CharField(required=True, label=_("Condition or|and"))
- condition_list = ConditionSerializer(many=True)
-
-
-class ILoopContinueNode(INode):
- type = 'loop-continue-node'
- support = [WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return LoopContinueNodeSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data)
-
- def execute(self, condition, condition_list, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/loop_continue_node/impl/__init__.py b/apps/application/flow/step_node/loop_continue_node/impl/__init__.py
deleted file mode 100644
index 3aca2f827de..00000000000
--- a/apps/application/flow/step_node/loop_continue_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2025/9/15 12:13
- @desc:
-"""
-from .base_loop_continue_node import BaseLoopContinueNode
\ No newline at end of file
diff --git a/apps/application/flow/step_node/loop_continue_node/impl/base_loop_continue_node.py b/apps/application/flow/step_node/loop_continue_node/impl/base_loop_continue_node.py
deleted file mode 100644
index 3c0393217c5..00000000000
--- a/apps/application/flow/step_node/loop_continue_node/impl/base_loop_continue_node.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: base_loop_continue_node.py
- @date:2025/9/15 12:13
- @desc:
-"""
-from application.flow.compare import do_assertion
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.loop_continue_node.i_loop_continue_node import ILoopContinueNode
-
-
-class BaseLoopContinueNode(ILoopContinueNode):
- def save_context(self, details, workflow_manage):
- self.context['exception_message'] = details.get('err_message')
-
- def execute(self, condition, condition_list, **kwargs) -> NodeResult:
- is_continue = do_assertion(self.workflow_manage, condition, condition_list)
- self.context['is_continue'] = is_continue
- if is_continue:
- return NodeResult({'is_continue': is_continue, 'branch_id': 'continue'}, {})
- return NodeResult({'is_continue': is_continue}, {})
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- "is_continue": self.context.get('is_continue'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/loop_node/__init__.py b/apps/application/flow/step_node/loop_node/__init__.py
deleted file mode 100644
index a5f59372be7..00000000000
--- a/apps/application/flow/step_node/loop_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: __init__.py
- @date:2025/3/11 18:24
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/loop_node/i_loop_node.py b/apps/application/flow/step_node/loop_node/i_loop_node.py
deleted file mode 100644
index e16dbebc059..00000000000
--- a/apps/application/flow/step_node/loop_node/i_loop_node.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: i_loop_node.py
- @date:2025/3/11 18:19
- @desc:
-"""
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-from common.exception.app_exception import AppApiException
-
-
-class ILoopNodeSerializer(serializers.Serializer):
- loop_type = serializers.CharField(required=True, label=_("loop_type"))
- array = serializers.ListField(required=False, allow_null=True,
- label=_("array"))
- number = serializers.IntegerField(required=False, allow_null=True,
- label=_("number"))
- loop_body = serializers.DictField(required=True, label="循环体")
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
- loop_type = self.data.get('loop_type')
- if loop_type == 'ARRAY':
- array = self.data.get('array')
- if array is None or len(array) == 0:
- message = _('{field}, this field is required.', field='array')
- raise AppApiException(500, message)
- elif loop_type == 'NUMBER':
- number = self.data.get('number')
- if number is None:
- message = _('{field}, this field is required.', field='number')
- raise AppApiException(500, message)
-
-
-class ILoopNode(INode):
- type = 'loop-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.KNOWLEDGE, WorkflowMode.TOOL]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return ILoopNodeSerializer
-
- def _run(self):
- array = self.node_params_serializer.data.get('array')
- if self.node_params_serializer.data.get('loop_type') == 'ARRAY':
- array = self.workflow_manage.get_reference_field(
- array[0],
- array[1:])
- return self.execute(**{**self.node_params_serializer.data, "array": array}, **self.flow_params_serializer.data)
-
- def execute(self, loop_type, array, number, loop_body, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/loop_node/impl/__init__.py b/apps/application/flow/step_node/loop_node/impl/__init__.py
deleted file mode 100644
index 3cd082322a1..00000000000
--- a/apps/application/flow/step_node/loop_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: __init__.py.py
- @date:2025/3/11 18:24
- @desc:
-"""
-from .base_loop_node import BaseLoopNode
diff --git a/apps/application/flow/step_node/loop_node/impl/base_loop_node.py b/apps/application/flow/step_node/loop_node/impl/base_loop_node.py
deleted file mode 100644
index e3f3cfa4e31..00000000000
--- a/apps/application/flow/step_node/loop_node/impl/base_loop_node.py
+++ /dev/null
@@ -1,332 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: base_loop_node.py
- @date:2025/3/11 18:24
- @desc:
-"""
-import time
-import uuid
-from typing import Dict, List
-
-from django.utils.translation import gettext as _
-
-from application.flow.common import Answer, WorkflowMode
-from application.flow.i_step_node import NodeResult, WorkFlowPostHandler, INode
-from application.flow.step_node.loop_node.i_loop_node import ILoopNode
-from application.flow.tools import Reasoning
-from application.models import ChatRecord
-from common.handle.impl.response.loop_to_response import LoopToResponse
-from maxkb.const import CONFIG
-
-max_loop_count = int(CONFIG.get("WORKFLOW_LOOP_NODE_MAX_LOOP_COUNT", 500))
-
-
-def _is_interrupt_exec(node, node_variable: Dict, workflow_variable: Dict):
- return node.context.get('is_interrupt_exec', False)
-
-
-def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow, answer: str,
- reasoning_content: str):
- node.context['answer'] = answer
- node.context['run_time'] = time.time() - node.context['start_time']
- node.context['reasoning_content'] = reasoning_content
- if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
- node.answer_text = answer
-
-
-def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据 (流式)
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点
- @param workflow: 工作流管理器
- """
-
- response = node_variable.get('result')
- workflow_manage = node_variable.get('workflow_manage')
- answer = ''
- reasoning_content = ''
- for chunk in response:
- content_chunk = chunk.get('content', '')
- reasoning_content_chunk = chunk.get('reasoning_content', '')
- reasoning_content += reasoning_content_chunk
- answer += content_chunk
- yield {'content': content_chunk,
- 'reasoning_content': reasoning_content_chunk}
- runtime_details = workflow_manage.get_runtime_details()
- _write_context(node_variable, workflow_variable, node, workflow, answer, reasoning_content)
-
-
-def write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点实例对象
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result')
- model_setting = node.context.get('model_setting',
- {'reasoning_content_enable': False, 'reasoning_content_end': '',
- 'reasoning_content_start': ''})
- reasoning = Reasoning(model_setting.get('reasoning_content_start'), model_setting.get('reasoning_content_end'))
- reasoning_result = reasoning.get_reasoning_content(response)
- reasoning_result_end = reasoning.get_end_reasoning_content()
- content = reasoning_result.get('content') + reasoning_result_end.get('content')
- if 'reasoning_content' in response.response_metadata:
- reasoning_content = response.response_metadata.get('reasoning_content', '')
- else:
- reasoning_content = reasoning_result.get('reasoning_content') + reasoning_result_end.get('reasoning_content')
- _write_context(node_variable, workflow_variable, node, workflow, content, reasoning_content)
-
-
-def get_answer_list(instance, child_node_node_dict, runtime_node_id):
- answer_list = instance.get_record_answer_list()
- for a in answer_list:
- _v = child_node_node_dict.get(a.get('runtime_node_id'))
- if _v:
- a['runtime_node_id'] = runtime_node_id
- a['child_node'] = _v
- return answer_list
-
-
-def insert_or_replace(arr, index, value):
- if index < len(arr):
- arr[index] = value # 替换
- else:
- # 在末尾插入足够多的None,然后替换最后一个
- arr.extend([None] * (index - len(arr) + 1))
- arr[index] = value
- return arr
-
-
-def generate_loop_number(number: int):
- def i(current_index: int):
- return iter([(index, index) for index in range(current_index, number)])
-
- return i
-
-
-def generate_loop_array(array):
- def i(current_index: int):
- return iter([(array[index], index) for index in range(current_index, len(array))])
-
- return i
-
-
-def generate_while_loop(current_index: int):
- index = current_index
- while True:
- yield index, index
- index += 1
-
-
-def loop(workflow_manage_new_instance, node: INode, generate_loop):
- loop_global_data = {}
- break_outer = False
- is_interrupt_exec = False
- loop_node_data = node.context.get('loop_node_data') or []
- loop_answer_data = node.context.get("loop_answer_data") or []
- start_index = node.context.get("current_index") or 0
- current_index = start_index
- node_params = node.node_params
- start_node_id = node_params.get('child_node', {}).get('runtime_node_id')
- loop_type = node_params.get('loop_type')
- start_node_data = None
- chat_record = None
- child_node = None
- if start_node_id:
- chat_record_id = node_params.get('child_node', {}).get('chat_record_id')
- child_node = node_params.get('child_node', {}).get('child_node')
- start_node_data = node_params.get('node_data')
- chat_record = ChatRecord(id=chat_record_id, answer_text_list=[], answer_text='',
- details=loop_node_data[current_index])
-
- for item, index in generate_loop(current_index):
- if 0 < max_loop_count <= index - start_index and loop_type == 'LOOP':
- raise Exception(_('Exceeding the maximum number of cycles'))
- """
- 指定次数循环
- @return:
- """
- instance = workflow_manage_new_instance({'index': index, 'item': item}, loop_global_data, start_node_id,
- start_node_data, chat_record, child_node)
- response = instance.stream()
- answer = ''
- current_index = index
- reasoning_content = ''
- child_node_node_dict = {}
- for chunk in response:
- if chunk.get('node_type') == 'loop-break-node' and chunk.get('content', '') == 'BREAK':
- break_outer = True
- continue
- child_node = chunk.get('child_node')
- runtime_node_id = chunk.get('runtime_node_id', '')
- chat_record_id = chunk.get('chat_record_id', '')
- child_node_node_dict[runtime_node_id] = {
- 'runtime_node_id': runtime_node_id,
- 'chat_record_id': chat_record_id,
- 'child_node': child_node}
- content_chunk = (chunk.get('content', '') or '')
- reasoning_content_chunk = (chunk.get('reasoning_content', '') or '')
- if chunk.get('real_node_id'):
- chunk['real_node_id'] = chunk['real_node_id'] + '__' + node.runtime_node_id + '__' + str(index)
- reasoning_content += reasoning_content_chunk
- answer += content_chunk
- yield chunk
- if chunk.get('node_status', "SUCCESS") == 'ERROR':
- insert_or_replace(loop_node_data, index, instance.get_runtime_details())
- insert_or_replace(loop_answer_data, index,
- get_answer_list(instance, child_node_node_dict, node.runtime_node_id))
- node.context['is_interrupt_exec'] = is_interrupt_exec
- node.context['loop_node_data'] = loop_node_data
- node.context['loop_answer_data'] = loop_answer_data
- node.context["index"] = current_index
- node.context["item"] = current_index
- node.status = 500
- node.err_message = chunk.get('content')
- return
- node_type = chunk.get('node_type')
- if node_type == 'form-node':
- break_outer = True
- is_interrupt_exec = True
- start_node_id = None
- start_node_data = None
- chat_record = None
- child_node = None
- insert_or_replace(loop_node_data, index, instance.get_runtime_details())
- insert_or_replace(loop_answer_data, index,
- get_answer_list(instance, child_node_node_dict, node.runtime_node_id))
- instance._cleanup()
- if break_outer:
- break
- if instance.is_the_task_interrupted():
- break
- node.context['is_interrupt_exec'] = is_interrupt_exec
- node.context['loop_node_data'] = loop_node_data
- node.context['loop_answer_data'] = loop_answer_data
- node.context["index"] = current_index
- node.context["item"] = current_index
- node.context['run_time'] = time.time() - node.context.get("start_time")
-
-
-def get_tokens(loop_node_data):
- message_tokens = 0
- answer_tokens = 0
- for details in (loop_node_data or {}):
- message_tokens += sum([row.get('message_tokens') or 0 for row in details.values() if
- 'message_tokens' in row and row.get('message_tokens') is not None])
- answer_tokens += sum([row.get('answer_tokens') or 0 for row in details.values() if
- 'answer_tokens' in row and row.get('answer_tokens') is not None])
- return {'message_tokens': message_tokens, 'answer_tokens': answer_tokens}
-
-
-def get_write_context(loop_type, array, number, loop_body):
- def inner_write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- if loop_type == 'ARRAY':
- return loop(node_variable['workflow_manage_new_instance'], node, generate_loop_array(array))
- if loop_type == 'LOOP':
- return loop(node_variable['workflow_manage_new_instance'], node, generate_while_loop)
- return loop(node_variable['workflow_manage_new_instance'], node, generate_loop_number(number))
-
- return inner_write_context
-
-
-class LoopWorkFlowPostHandler(WorkFlowPostHandler):
- def handler(self, workflow):
- pass
-
-
-class BaseLoopNode(ILoopNode):
- def save_context(self, details, workflow_manage):
- self.context['loop_context_data'] = details.get('loop_context_data')
- self.context['loop_answer_data'] = details.get('loop_answer_data')
- self.context['loop_node_data'] = details.get('loop_node_data')
- self.context['result'] = details.get('result')
- self.context['params'] = details.get('params')
- self.context['run_time'] = details.get('run_time')
- self.context['index'] = details.get('current_index')
- self.context['item'] = details.get('current_item')
- for key, value in (details.get('loop_context_data') or {}).items():
- self.context[key] = value
- self.answer_text = ""
-
- def get_answer_list(self) -> List[Answer] | None:
- result = []
- for answer_list in (self.context.get("loop_answer_data") or []):
- for a in answer_list:
- if isinstance(a, dict):
- result.append(Answer(**a))
-
- return result
-
- def get_loop_context(self):
- return self.context
-
- def execute(self, loop_type, array, number, loop_body, **kwargs) -> NodeResult:
- from application.flow.loop_workflow_manage import LoopWorkflowManage, Workflow
- from application.flow.knowledge_loop_workflow_manage import KnowledgeLoopWorkflowManage
- from application.flow.tool_loop_workflow_manage import ToolLoopWorkflowManage
- self.node_params['is_result'] = True
-
- def workflow_manage_new_instance(loop_data, global_data, start_node_id=None,
- start_node_data=None, chat_record=None, child_node=None):
- workflow_mode = {WorkflowMode.APPLICATION: WorkflowMode.APPLICATION_LOOP,
- WorkflowMode.KNOWLEDGE: WorkflowMode.KNOWLEDGE_LOOP,
- WorkflowMode.TOOL: WorkflowMode.TOOL_LOOP}.get(
- self.workflow_manage.flow.workflow_mode) or WorkflowMode.APPLICATION
- c = {WorkflowMode.APPLICATION_LOOP: LoopWorkflowManage,
- WorkflowMode.KNOWLEDGE_LOOP: KnowledgeLoopWorkflowManage,
- WorkflowMode.TOOL_LOOP: ToolLoopWorkflowManage}.get(workflow_mode) or LoopWorkflowManage
- workflow_manage = c(Workflow.new_instance(loop_body, workflow_mode),
- self.workflow_manage.params,
- LoopWorkFlowPostHandler(
- self.workflow_manage.work_flow_post_handler.chat_info),
- self.workflow_manage,
- loop_data,
- self.get_loop_context,
- base_to_response=LoopToResponse(),
- start_node_id=start_node_id,
- start_node_data=start_node_data,
- chat_record=chat_record,
- child_node=child_node,
- is_the_task_interrupted=self.workflow_manage.is_the_task_interrupted
- )
-
- return workflow_manage
-
- return NodeResult({'workflow_manage_new_instance': workflow_manage_new_instance}, {},
- _write_context=get_write_context(loop_type, array, number, loop_body),
- _is_interrupt=_is_interrupt_exec)
-
- def get_loop_context_data(self):
- fields = self.node.properties.get('config', {}).get('fields', []) or []
- return {f.get('value'): self.context.get(f.get('value')) for f in fields if
- self.context.get(f.get('value')) is not None}
-
- def get_details(self, index: int, **kwargs):
- tokens = get_tokens(self.context.get("loop_node_data"))
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- "result": self.context.get('result'),
- 'array': self.node_params_serializer.data.get('array'),
- 'number': self.node_params_serializer.data.get('number'),
- "params": self.context.get('params'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'current_index': self.context.get("index"),
- "current_item": self.context.get("item"),
- 'loop_type': self.node_params_serializer.data.get('loop_type'),
- 'status': self.status,
- 'loop_context_data': self.get_loop_context_data(),
- 'loop_node_data': self.context.get("loop_node_data"),
- 'loop_answer_data': self.context.get("loop_answer_data"),
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- 'message_tokens': tokens.get('message_tokens') or 0,
- 'answer_tokens': tokens.get('answer_tokens') or 0,
- }
diff --git a/apps/application/flow/step_node/loop_start_node/__init__.py b/apps/application/flow/step_node/loop_start_node/__init__.py
deleted file mode 100644
index 98a1afcd904..00000000000
--- a/apps/application/flow/step_node/loop_start_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:30
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/loop_start_node/i_loop_start_node.py b/apps/application/flow/step_node/loop_start_node/i_loop_start_node.py
deleted file mode 100644
index 7c3ffa31413..00000000000
--- a/apps/application/flow/step_node/loop_start_node/i_loop_start_node.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_start_node.py
- @date:2024/6/3 16:54
- @desc:
-"""
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class ILoopStarNode(INode):
- type = 'loop-start-node'
- support = [WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL_LOOP]
-
- def _run(self):
- return self.execute(**self.flow_params_serializer.data)
-
- def execute(self, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/loop_start_node/impl/__init__.py b/apps/application/flow/step_node/loop_start_node/impl/__init__.py
deleted file mode 100644
index 76f972fcedb..00000000000
--- a/apps/application/flow/step_node/loop_start_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:36
- @desc:
-"""
-from .base_start_node import BaseLoopStartStepNode
diff --git a/apps/application/flow/step_node/loop_start_node/impl/base_start_node.py b/apps/application/flow/step_node/loop_start_node/impl/base_start_node.py
deleted file mode 100644
index 8058e098b20..00000000000
--- a/apps/application/flow/step_node/loop_start_node/impl/base_start_node.py
+++ /dev/null
@@ -1,59 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_start_node.py
- @date:2024/6/3 17:17
- @desc:
-"""
-from typing import Type
-
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.loop_start_node.i_loop_start_node import ILoopStarNode
-
-
-class BaseLoopStartStepNode(ILoopStarNode):
- def save_context(self, details, workflow_manage):
- self.context['index'] = details.get('current_index')
- self.context['item'] = details.get('current_item')
- self.context['exception_message'] = details.get('err_message')
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- pass
-
- def execute(self, **kwargs) -> NodeResult:
- """
- 开始节点 初始化全局变量
- """
- loop_params = self.workflow_manage.loop_params
- node_variable = {
- 'index': loop_params.get("index"),
- 'item': loop_params.get("item")
- }
- if WorkflowMode.APPLICATION_LOOP == self.workflow_manage.flow.workflow_mode:
- self.workflow_manage.chat_context = self.workflow_manage.get_chat_info().get_chat_variable()
- return NodeResult(node_variable, {})
-
- def get_details(self, index: int, **kwargs):
- global_fields = []
- for field in self.node.properties.get('config')['globalFields']:
- key = field['value']
- global_fields.append({
- 'label': field['label'],
- 'key': key,
- 'value': self.workflow_manage.context[key] if key in self.workflow_manage.context else ''
- })
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- "current_index": self.context.get('index'),
- "current_item": self.context.get('item'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/mcp_node/__init__.py b/apps/application/flow/step_node/mcp_node/__init__.py
deleted file mode 100644
index f3feecc9ce2..00000000000
--- a/apps/application/flow/step_node/mcp_node/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .impl import *
diff --git a/apps/application/flow/step_node/mcp_node/i_mcp_node.py b/apps/application/flow/step_node/mcp_node/i_mcp_node.py
deleted file mode 100644
index 6dd3827d640..00000000000
--- a/apps/application/flow/step_node/mcp_node/i_mcp_node.py
+++ /dev/null
@@ -1,33 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class McpNodeSerializer(serializers.Serializer):
- mcp_servers = serializers.JSONField(required=True, label=_("Mcp servers"))
- mcp_server = serializers.CharField(required=True, label=_("Mcp server"))
- mcp_tool = serializers.CharField(required=True, label=_("Mcp tool"))
- mcp_tool_id = serializers.CharField(required=False, label=_("Mcp tool"), allow_null=True, allow_blank=True)
- mcp_source = serializers.CharField(required=False, label=_("Mcp source"), allow_blank=True, allow_null=True)
- tool_params = serializers.DictField(required=True, label=_("Tool parameters"))
-
-
-class IMcpNode(INode):
- type = 'mcp-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return McpNodeSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, mcp_servers, mcp_server, mcp_tool, mcp_tool_id, mcp_source, tool_params, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/mcp_node/impl/__init__.py b/apps/application/flow/step_node/mcp_node/impl/__init__.py
deleted file mode 100644
index 8c9a5ee197c..00000000000
--- a/apps/application/flow/step_node/mcp_node/impl/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .base_mcp_node import BaseMcpNode
diff --git a/apps/application/flow/step_node/mcp_node/impl/base_mcp_node.py b/apps/application/flow/step_node/mcp_node/impl/base_mcp_node.py
deleted file mode 100644
index 82dd8a8a545..00000000000
--- a/apps/application/flow/step_node/mcp_node/impl/base_mcp_node.py
+++ /dev/null
@@ -1,77 +0,0 @@
-# coding=utf-8
-import asyncio
-import json
-from typing import List
-
-from django.db.models import QuerySet
-from langchain_mcp_adapters.client import MultiServerMCPClient
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.mcp_node.i_mcp_node import IMcpNode
-from tools.models import Tool
-from common.utils.tool_code import ToolExecutor
-
-
-class BaseMcpNode(IMcpNode):
- def save_context(self, details, workflow_manage):
- self.context['result'] = details.get('result')
- self.context['tool_params'] = details.get('tool_params')
- self.context['mcp_tool'] = details.get('mcp_tool')
- self.context['exception_message'] = details.get('err_message')
-
- def execute(self, mcp_servers, mcp_server, mcp_tool, mcp_tool_id, mcp_source, tool_params, **kwargs) -> NodeResult:
- if mcp_source == 'referencing':
- if not mcp_tool_id:
- raise ValueError("MCP tool ID is required when mcp_source is 'referencing'.")
- tool = QuerySet(Tool).filter(id=mcp_tool_id).first()
- if not tool:
- raise ValueError(f"Tool with ID {mcp_tool_id} not found.")
- if not tool.is_active:
- raise ValueError(f"Tool with ID {mcp_tool_id} is inactive.")
- servers = json.loads(tool.code)
- else:
- servers = json.loads(mcp_servers)
-
- servers = self.handle_variables(servers) # 处理servers中的变量
- ToolExecutor().validate_mcp_transport(json.dumps(servers))
- params = json.loads(json.dumps(tool_params))
- params = self.handle_variables(params)
-
- async def call_tool(t, a):
- client = MultiServerMCPClient(servers)
- async with client.session(mcp_server) as s:
- return await s.call_tool(t, a)
-
- res = asyncio.run(call_tool(mcp_tool, params))
- return NodeResult(
- {'result': [content.text for content in res.content], 'tool_params': params, 'mcp_tool': mcp_tool}, {})
-
- def handle_variables(self, tool_params):
- # 处理参数中的变量
- for k, v in tool_params.items():
- if type(v) == str:
- tool_params[k] = self.workflow_manage.generate_prompt(tool_params[k])
- elif type(v) == dict:
- self.handle_variables(v)
- elif (type(v) == list) and len(v) > 0 and (type(v[0]) == str):
- tool_params[k] = self.get_reference_content(v)
- return tool_params
-
- def get_reference_content(self, fields: List[str]):
- return self.workflow_manage.get_reference_field(
- fields[0],
- fields[1:]) if fields else None
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'type': self.node.type,
- 'mcp_tool': self.context.get('mcp_tool'),
- 'tool_params': self.context.get('tool_params'),
- 'result': self.context.get('result'),
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/parameter_extraction_node/__init__.py b/apps/application/flow/step_node/parameter_extraction_node/__init__.py
deleted file mode 100644
index c93d71e9ed1..00000000000
--- a/apps/application/flow/step_node/parameter_extraction_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2025/10/13 14:56
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/parameter_extraction_node/i_parameter_extraction_node.py b/apps/application/flow/step_node/parameter_extraction_node/i_parameter_extraction_node.py
deleted file mode 100644
index 54c60bb096c..00000000000
--- a/apps/application/flow/step_node/parameter_extraction_node/i_parameter_extraction_node.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class VariableSplittingNodeParamsSerializer(serializers.Serializer):
- input_variable = serializers.ListField(required=True,
- label=_("input variable"))
-
- variable_list = serializers.ListField(required=True,
- label=_("Split variables"))
-
- model_params_setting = serializers.DictField(required=False,
- label=_("Model parameter settings"))
-
- model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
-
-
-class IParameterExtractionNode(INode):
- type = 'parameter-extraction-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return VariableSplittingNodeParamsSerializer
-
- def _run(self):
- model_id_type = self.node_params_serializer.data.get('model_id_type')
- model_id_reference = self.node_params_serializer.data.get('model_id_reference')
- model_id = self.node_params_serializer.data.get('model_id')
- model_params_setting = self.node_params_serializer.data.get('model_params_setting')
- # 处理引用类型
- if model_id_type == 'reference' and model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- model_id_reference[0],
- model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- model_id = reference_data.get('model_id', model_id)
- model_params_setting = reference_data.get('model_params_setting')
-
- input_variable = self.workflow_manage.get_reference_field(
- self.node_params_serializer.data.get('input_variable')[0],
- self.node_params_serializer.data.get('input_variable')[1:])
- return self.execute(input_variable, self.node_params_serializer.data['variable_list'],
- model_params_setting, model_id)
-
- def execute(self, input_variable, variable_list, model_params_setting, model_id, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/parameter_extraction_node/impl/__init__.py b/apps/application/flow/step_node/parameter_extraction_node/impl/__init__.py
deleted file mode 100644
index a0d23a10454..00000000000
--- a/apps/application/flow/step_node/parameter_extraction_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2025/10/13 15:01
- @desc:
-"""
-from .base_parameter_extraction_node import *
diff --git a/apps/application/flow/step_node/parameter_extraction_node/impl/base_parameter_extraction_node.py b/apps/application/flow/step_node/parameter_extraction_node/impl/base_parameter_extraction_node.py
deleted file mode 100644
index 2e686743b69..00000000000
--- a/apps/application/flow/step_node/parameter_extraction_node/impl/base_parameter_extraction_node.py
+++ /dev/null
@@ -1,123 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: base_variable_splitting_node.py
- @date:2025/10/13 15:02
- @desc:
-"""
-import json
-import re
-
-from django.db.models import QuerySet
-from langchain_core.messages import HumanMessage
-from langchain_core.prompts import PromptTemplate
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.parameter_extraction_node.i_parameter_extraction_node import IParameterExtractionNode
-from models_provider.models import Model
-from models_provider.tools import get_model_instance_by_model_workspace_id, get_model_credential
-
-prompt = """
-Please strictly process the text according to the following requirements:
-**Task**:
-Extract specified field information from given text
-
-**Enter text**:
-{{question}}
-
-**Extract configuration**:
-{{properties}}
-
-**Rule**:
-- Strictly follow the data and field of Extract configuration
-- If not found, use null value
-- Only return pure JSON without additional text
-- Keep the string format neat
-"""
-
-
-def get_default_model_params_setting(model_id):
- model = QuerySet(Model).filter(id=model_id).first()
- credential = get_model_credential(model.provider, model.model_type, model.model_name)
- model_params_setting = credential.get_model_params_setting_form(
- model.model_name).get_default_form_data()
- return model_params_setting
-
-
-def generate_properties(variable_list):
- return {variable['field']: {'type': variable['parameter_type'], 'description': (variable.get('desc') or ""),
- 'title': variable['label']} for variable in
- variable_list}
-
-
-def generate_example(variable_list):
- return {variable['field']: None for variable in variable_list}
-
-
-def generate_content(input_variable, variable_list):
- properties = generate_properties(variable_list)
- prompt_template = PromptTemplate.from_template(prompt, template_format='jinja2')
- value = prompt_template.format(properties=properties, question=input_variable)
- return value
-
-
-def json_loads(response, variable_list):
- if not response or not isinstance(response, str):
- return generate_example(variable_list)
-
- cleaned = response.strip()
-
- extraction_strategies = [
- lambda: json.loads(cleaned),
- lambda: json.loads(re.search(r'```(?:json)?\s*(\{.*?\})\s*```', cleaned, re.DOTALL).group(1)),
- lambda: json.loads(re.search(r'(\{.*\})', cleaned, flags=re.DOTALL).group(1)),
- ]
- for strategy in extraction_strategies:
- try:
- result = strategy()
- return result
- except:
- continue
- return generate_example(variable_list)
-
-
-class BaseParameterExtractionNode(IParameterExtractionNode):
-
- def save_context(self, details, workflow_manage):
- for key, value in details.get('result').items():
- self.context[key] = value
- self.context['result'] = details.get('result')
- self.context['request'] = details.get('request')
- self.context['exception_message'] = details.get('err_message')
-
- def execute(self, input_variable, variable_list, model_params_setting, model_id, **kwargs) -> NodeResult:
- input_variable = str(input_variable)
- self.context['request'] = input_variable
-
- if not model_id:
- raise Exception(_('Model is not allowed to be empty'))
-
- if model_params_setting is None and model_id:
- model_params_setting = get_default_model_params_setting(model_id)
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id,
- **(model_params_setting or {}))
-
- content = generate_content(input_variable, variable_list)
- response = chat_model.invoke([HumanMessage(content=content)])
- result = json_loads(response.content, variable_list)
- return NodeResult({'result': result, **result}, {})
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'request': self.context.get('request'),
- 'result': self.context.get('result'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/question_node/__init__.py b/apps/application/flow/step_node/question_node/__init__.py
deleted file mode 100644
index 98a1afcd904..00000000000
--- a/apps/application/flow/step_node/question_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:30
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/question_node/i_question_node.py b/apps/application/flow/step_node/question_node/i_question_node.py
deleted file mode 100644
index 2e58b31ea01..00000000000
--- a/apps/application/flow/step_node/question_node/i_question_node.py
+++ /dev/null
@@ -1,55 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_chat_node.py
- @date:2024/6/4 13:58
- @desc:
-"""
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class QuestionNodeSerializer(serializers.Serializer):
- model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
- system = serializers.CharField(required=False, allow_blank=True, allow_null=True,
- label=_("Role Setting"))
- prompt = serializers.CharField(required=True, label=_("Prompt word"))
- # 多轮对话数量
- dialogue_number = serializers.IntegerField(required=True, label=
- _("Number of multi-round conversations"))
-
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
- model_params_setting = serializers.DictField(required=False,
- label=_("Model parameter settings"))
-
-
-class IQuestionNode(INode):
- type = 'question-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return QuestionNodeSerializer
-
- def _run(self):
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL,
- WorkflowMode.TOOL_LOOP].__contains__(self.workflow_manage.flow.workflow_mode):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data,
- **{'history_chat_record': [], 'stream': True, 'chat_id': None, 'chat_record_id': None})
- else:
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, model_id, system, prompt, dialogue_number, history_chat_record, stream, chat_id, chat_record_id,
- model_params_setting=None, model_id_type=None, model_id_reference=None,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/question_node/impl/__init__.py b/apps/application/flow/step_node/question_node/impl/__init__.py
deleted file mode 100644
index d85aa8724ac..00000000000
--- a/apps/application/flow/step_node/question_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:35
- @desc:
-"""
-from .base_question_node import BaseQuestionNode
diff --git a/apps/application/flow/step_node/question_node/impl/base_question_node.py b/apps/application/flow/step_node/question_node/impl/base_question_node.py
deleted file mode 100644
index 34000542db3..00000000000
--- a/apps/application/flow/step_node/question_node/impl/base_question_node.py
+++ /dev/null
@@ -1,172 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_question_node.py
- @date:2024/6/4 14:30
- @desc:
-"""
-import re
-import time
-from functools import reduce
-from typing import List, Dict
-
-from django.db.models import QuerySet
-from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
-
-from application.flow.i_step_node import NodeResult, INode
-from application.flow.step_node.question_node.i_question_node import IQuestionNode
-from models_provider.models import Model
-from models_provider.tools import get_model_instance_by_model_workspace_id, get_model_credential
-
-
-def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow, answer: str):
- chat_model = node_variable.get('chat_model')
- message_tokens = chat_model.get_num_tokens_from_messages(node_variable.get('message_list'))
- answer_tokens = chat_model.get_num_tokens(answer)
- node.context['message_tokens'] = message_tokens
- node.context['answer_tokens'] = answer_tokens
- node.context['answer'] = answer
- node.context['history_message'] = node_variable['history_message']
- node.context['question'] = node_variable['question']
- node.context['run_time'] = time.time() - node.context['start_time']
- if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
- node.answer_text = answer
-
-
-def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据 (流式)
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result')
- answer = ''
- for chunk in response:
- answer += chunk.content
- yield chunk.content
- _write_context(node_variable, workflow_variable, node, workflow, answer)
-
-
-def write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点实例对象
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result')
- answer = response.content
- _write_context(node_variable, workflow_variable, node, workflow, answer)
-
-
-def get_default_model_params_setting(model_id):
- model = QuerySet(Model).filter(id=model_id).first()
- credential = get_model_credential(model.provider, model.model_type, model.model_name)
- model_params_setting = credential.get_model_params_setting_form(
- model.model_name).get_default_form_data()
- return model_params_setting
-
-
-class BaseQuestionNode(IQuestionNode):
- def save_context(self, details, workflow_manage):
- self.context['run_time'] = details.get('run_time')
- self.context['question'] = details.get('question')
- self.context['answer'] = details.get('answer')
- self.context['message_tokens'] = details.get('message_tokens')
- self.context['answer_tokens'] = details.get('answer_tokens')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
-
- def execute(self, model_id, system, prompt, dialogue_number, history_chat_record, stream, chat_id, chat_record_id,
- model_params_setting=None, model_id_type=None, model_id_reference=None,
- **kwargs) -> NodeResult:
- # 处理引用类型
- if model_id_type == 'reference' and model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- model_id_reference[0],
- model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- model_id = reference_data.get('model_id', model_id)
- model_params_setting = reference_data.get('model_params_setting')
- if not model_id:
- raise Exception(_('Model is not allowed to be empty'))
-
- if model_params_setting is None and model_id:
- model_params_setting = get_default_model_params_setting(model_id)
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id,
- **(model_params_setting or {}))
- history_message = self.get_history_message(history_chat_record, dialogue_number)
- self.context['history_message'] = history_message
- question = self.generate_prompt_question(prompt)
- self.context['question'] = question.content
- system = self.workflow_manage.generate_prompt(system)
- self.context['system'] = system
- message_list = self.generate_message_list(system, prompt, history_message)
- self.context['message_list'] = message_list
- if stream:
- r = chat_model.stream(message_list)
- return NodeResult({'result': r, 'chat_model': chat_model, 'message_list': message_list,
- 'history_message': history_message, 'question': question.content}, {},
- _write_context=write_context_stream)
- else:
- r = chat_model.invoke(message_list)
- return NodeResult({'result': r, 'chat_model': chat_model, 'message_list': message_list,
- 'history_message': history_message, 'question': question.content}, {},
- _write_context=write_context)
-
- @staticmethod
- def get_history_message(history_chat_record, dialogue_number):
- start_index = len(history_chat_record) - dialogue_number
- history_message = reduce(lambda x, y: [*x, *y], [
- [history_chat_record[index].get_human_message(), history_chat_record[index].get_ai_message()]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))], [])
- for message in history_message:
- if isinstance(message.content, str):
- message.content = re.sub(r'.*?<\/form_rander>', '', message.content, flags=re.DOTALL)
- return history_message
-
- def generate_prompt_question(self, prompt):
- return HumanMessage(self.workflow_manage.generate_prompt(prompt))
-
- def generate_message_list(self, system: str, prompt: str, history_message):
- if system is not None and len(system) > 0:
- return [SystemMessage(self.workflow_manage.generate_prompt(system)), *history_message,
- HumanMessage(self.workflow_manage.generate_prompt(prompt))]
- else:
- return [*history_message, HumanMessage(self.workflow_manage.generate_prompt(prompt))]
-
- @staticmethod
- def reset_message_list(message_list: List[BaseMessage], answer_text):
- result = [{'role': 'user' if isinstance(message, HumanMessage) else 'ai', 'content': message.content} for
- message
- in
- message_list]
- result.append({'role': 'ai', 'content': answer_text})
- return result
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'system': self.context.get('system'),
- 'history_message': [{'content': message.content, 'role': message.type} for message in
- (self.context.get('history_message') if self.context.get(
- 'history_message') is not None else [])],
- 'question': self.context.get('question'),
- 'answer': self.context.get('answer'),
- 'type': self.node.type,
- 'message_tokens': self.context.get('message_tokens'),
- 'answer_tokens': self.context.get('answer_tokens'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/reranker_node/__init__.py b/apps/application/flow/step_node/reranker_node/__init__.py
deleted file mode 100644
index 881d0f8a393..00000000000
--- a/apps/application/flow/step_node/reranker_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: __init__.py
- @date:2024/9/4 11:37
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/reranker_node/i_reranker_node.py b/apps/application/flow/step_node/reranker_node/i_reranker_node.py
deleted file mode 100644
index af87a6f2003..00000000000
--- a/apps/application/flow/step_node/reranker_node/i_reranker_node.py
+++ /dev/null
@@ -1,84 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: i_reranker_node.py
- @date:2024/9/4 10:40
- @desc:
-"""
-from typing import Type
-
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-from django.utils.translation import gettext_lazy as _
-
-
-class RerankerSettingSerializer(serializers.Serializer):
- # 需要查询的条数
- top_n = serializers.IntegerField(required=True,
- label=_("Reference segment number"))
- # 相似度 0-1之间
- similarity = serializers.FloatField(required=True, max_value=2, min_value=0,
- label=_("Reference segment number"))
- max_paragraph_char_number = serializers.IntegerField(required=True,
- label=_("Maximum number of words in a quoted segment"))
-
-
-class RerankerStepNodeSerializer(serializers.Serializer):
- reranker_setting = RerankerSettingSerializer(required=True)
-
- question_reference_address = serializers.ListField(required=True)
- reranker_model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True)
- reranker_model_id_type = serializers.CharField(required=False, default='custom')
- reranker_model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True)
- reranker_reference_list = serializers.ListField(required=True, child=serializers.ListField(required=True))
- show_knowledge = serializers.BooleanField(required=True,
- label=_("The results are displayed in the knowledge sources"))
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
-
-
-class IRerankerNode(INode):
- type = 'reranker-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return RerankerStepNodeSerializer
-
- def _run(self):
- question = self.workflow_manage.get_reference_field(
- self.node_params_serializer.data.get('question_reference_address')[0],
- self.node_params_serializer.data.get('question_reference_address')[1:])
- reranker_list = [self.workflow_manage.get_reference_field(
- reference[0],
- reference[1:]) for reference in
- self.node_params_serializer.data.get('reranker_reference_list')]
-
- node_params_data = dict(self.node_params_serializer.data)
-
- reranker_model_id_type = node_params_data.pop('reranker_model_id_type', None)
- reranker_model_id_reference = node_params_data.pop('reranker_model_id_reference', None)
- reranker_model_id = node_params_data.pop('reranker_model_id', None)
-
- # 处理引用类型
- if reranker_model_id_type == 'reference' and reranker_model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- reranker_model_id_reference[0],
- reranker_model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- reranker_model_id = reference_data.get('reranker_model_id',
- reference_data.get('model_id', reranker_model_id))
- if reranker_model_id is None or reranker_model_id == '':
- raise Exception(_('Model is not allowed to be empty'))
-
- return self.execute(**node_params_data, question=str(question),
- reranker_list=reranker_list, reranker_model_id=reranker_model_id)
-
- def execute(self, question, reranker_setting, reranker_list, reranker_model_id, show_knowledge,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/reranker_node/impl/__init__.py b/apps/application/flow/step_node/reranker_node/impl/__init__.py
deleted file mode 100644
index ef5ca80585b..00000000000
--- a/apps/application/flow/step_node/reranker_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: __init__.py
- @date:2024/9/4 11:39
- @desc:
-"""
-from .base_reranker_node import *
diff --git a/apps/application/flow/step_node/reranker_node/impl/base_reranker_node.py b/apps/application/flow/step_node/reranker_node/impl/base_reranker_node.py
deleted file mode 100644
index 36dd2144aee..00000000000
--- a/apps/application/flow/step_node/reranker_node/impl/base_reranker_node.py
+++ /dev/null
@@ -1,129 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: base_reranker_node.py
- @date:2024/9/4 11:41
- @desc:
-"""
-from typing import List
-
-from langchain_core.documents import Document
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.reranker_node.i_reranker_node import IRerankerNode
-from models_provider.tools import get_model_instance_by_model_workspace_id
-
-
-def merge_reranker_list(reranker_list, result=None):
- if result is None:
- result = []
- for document in reranker_list:
- if isinstance(document, list):
- merge_reranker_list(document, result)
- elif isinstance(document, dict):
- content = document.get('title', '') + document.get('content', '')
- title = document.get("title")
- result.append(
- Document(page_content=str(document) if len(content) == 0 else content,
- metadata={'title': title, **document}))
- else:
- result.append(Document(page_content=str(document), metadata={}))
- return result
-
-
-def filter_result(document_list: List[Document], max_paragraph_char_number, top_n, similarity):
- use_len = 0
- result = []
- for index in range(len(document_list)):
- document = document_list[index]
- if use_len >= max_paragraph_char_number or index >= top_n or document.metadata.get(
- 'relevance_score') < similarity:
- break
- content = document.page_content[0:max_paragraph_char_number - use_len]
- use_len = use_len + len(content)
- result.append({'page_content': content, 'metadata': document.metadata})
- return result
-
-
-def reset_result_list(result_list: List[Document], document_list: List[Document]):
- r = []
- document_list = document_list.copy()
- for result in result_list:
- filter_result_list = [document for document in document_list if document.page_content == result.page_content]
- if len(filter_result_list) > 0:
- item = filter_result_list[0]
- document_list.remove(item)
- r.append(Document(page_content=item.page_content,
- metadata={**item.metadata, 'relevance_score': result.metadata.get('relevance_score')}))
- else:
- r.append(result)
- return r
-
-
-def get_none_result(question):
- return NodeResult(
- {'document_list': [], 'question': question,
- 'result_list': [], 'result': ''}, {})
-
-
-def reset_metadata(metadata):
- meta = metadata.get('meta')
- if isinstance(metadata.get('meta'), dict):
- if not meta.get('allow_download', False):
- metadata['meta'] = {'allow_download': False}
- return metadata
-
-
-class BaseRerankerNode(IRerankerNode):
- def save_context(self, details, workflow_manage):
- self.context['document_list'] = details.get('document_list', [])
- self.context['question'] = details.get('question')
- self.context['run_time'] = details.get('run_time')
- self.context['result_list'] = details.get('result_list')
- self.context['result'] = details.get('result')
- self.context['exception_message'] = details.get('err_message')
-
- def execute(self, question, reranker_setting, reranker_list, reranker_model_id, show_knowledge,
- **kwargs) -> NodeResult:
- self.context['show_knowledge'] = show_knowledge
- documents = merge_reranker_list(reranker_list)
- documents = [d for d in documents if d.page_content and len(d.page_content) > 0]
- if len(documents) == 0:
- return get_none_result(question)
- top_n = reranker_setting.get('top_n', 3)
- self.context['document_list'] = [
- {'page_content': document.page_content, 'metadata': reset_metadata(document.metadata)} for
- document in documents]
- self.context['question'] = question
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- reranker_model = get_model_instance_by_model_workspace_id(reranker_model_id,
- workspace_id,
- top_n=top_n)
- result = reranker_model.compress_documents(
- documents,
- question)
- similarity = reranker_setting.get('similarity', 0.6)
- max_paragraph_char_number = reranker_setting.get('max_paragraph_char_number', 5000)
- result = reset_result_list(result, documents)
- r = filter_result(result, max_paragraph_char_number, top_n, similarity)
- return NodeResult({'result_list': r, 'result': ''.join([item.get('page_content') for item in r]),
- 'is_hit_handling_method_list': [r for row in r if
- row.get('metadata').get('is_hit_handling_method')]}, {})
-
- def get_details(self, index: int, **kwargs):
- return {
- 'show_knowledge': self.context.get('show_knowledge'),
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'document_list': self.context.get('document_list'),
- "question": self.context.get('question'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'reranker_setting': self.node_params_serializer.data.get('reranker_setting'),
- 'result_list': self.context.get('result_list'),
- 'result': self.context.get('result'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/search_document_node/__init__.py b/apps/application/flow/step_node/search_document_node/__init__.py
deleted file mode 100644
index ce8f10f3e24..00000000000
--- a/apps/application/flow/step_node/search_document_node/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/search_document_node/i_search_document_node.py b/apps/application/flow/step_node/search_document_node/i_search_document_node.py
deleted file mode 100644
index 0a2c99a1e71..00000000000
--- a/apps/application/flow/step_node/search_document_node/i_search_document_node.py
+++ /dev/null
@@ -1,58 +0,0 @@
-# coding=utf-8
-from typing import Type, List
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class SearchDocumentStepNodeSerializer(serializers.Serializer):
- knowledge_id_list = serializers.ListField(
- required=False, child=serializers.UUIDField(required=True),
- label=_("knowledge id list"), default=list
- )
- search_mode = serializers.ChoiceField(
- required=False, choices=['auto', 'custom'], label=_("search mode"), default='auto'
- )
- search_scope_type = serializers.ChoiceField(
- required=False, choices=['custom', 'referencing'], label=_("search scope type"),
- allow_null=True, default='custom'
- )
- search_scope_source = serializers.ChoiceField(
- required=False, choices=['document', 'knowledge'],
- label=_("search scope variable type"), default='knowledge'
- )
- search_scope_reference = serializers.ListField(
- required=False, label=_("search scope variable"), default=list
- )
- question_reference = serializers.ListField(
- required=False, label=_("question reference address"), default=list
- )
- search_condition_type = serializers.ChoiceField(
- required=False, choices=['AND', 'OR'], label=_("search condition type"), default='AND'
- )
- search_condition_list = serializers.ListField(
- required=False, label=_("search condition list"), default=list
- )
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
-
-
-class ISearchDocumentStepNode(INode):
- type = 'search-document-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return SearchDocumentStepNodeSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, knowledge_id_list: List, search_mode: str, search_scope_type: str, search_scope_source: str,
- search_scope_reference: List, question_reference: List, search_condition_type: str,
- search_condition_list: List,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/search_document_node/impl/__init__.py b/apps/application/flow/step_node/search_document_node/impl/__init__.py
deleted file mode 100644
index 74a1aa384a7..00000000000
--- a/apps/application/flow/step_node/search_document_node/impl/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .base_search_document_node import BaseSearchDocumentNode
diff --git a/apps/application/flow/step_node/search_document_node/impl/base_search_document_node.py b/apps/application/flow/step_node/search_document_node/impl/base_search_document_node.py
deleted file mode 100644
index 1d85cff5331..00000000000
--- a/apps/application/flow/step_node/search_document_node/impl/base_search_document_node.py
+++ /dev/null
@@ -1,212 +0,0 @@
-# coding=utf-8
-from typing import List
-
-import jieba
-from django.db.models import Q
-from django.db.models import QuerySet
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.search_document_node.i_search_document_node import ISearchDocumentStepNode
-from common.constants.permission_constants import RoleConstants
-from common.database_model_manage.database_model_manage import DatabaseModelManage
-from common.utils.shared_resource_auth import filter_authorized_ids
-from knowledge.models import Document, DocumentTag, Knowledge
-
-
-class BaseSearchDocumentNode(ISearchDocumentStepNode):
- def save_context(self, details, workflow_manage):
- self.context['document_list'] = details.get('document_list')
- self.context['knowledge_list'] = details.get('knowledge_list')
- self.context['document_items'] = details.get('document_items')
- self.context['knowledge_items'] = details.get('knowledge_items')
- self.context['question'] = details.get('question')
- self.context['run_time'] = details.get('run_time')
- self.context['exception_message'] = details.get('err_message')
-
- def get_reference_content(self, fields: List[str]):
- return self.workflow_manage.get_reference_field(fields[0], fields[1:]) if fields else None
-
- def execute(self, knowledge_id_list: List, search_mode: str, search_scope_type: str, search_scope_source: str,
- search_scope_reference: List, question_reference: List, search_condition_type: str,
- search_condition_list: List,
- **kwargs) -> NodeResult:
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
-
- if search_scope_type == 'custom': # 手动选择知识库
- knowledge_id_list = filter_authorized_ids('knowledge', knowledge_id_list, workspace_id)
- document_id_list = QuerySet(Document).filter(
- knowledge_id__in=knowledge_id_list
- ).values_list('id', flat=True)
- else: # 引用上一步知识库/文档
- if search_scope_source == 'document': # 文档
- document_id_list = self.get_reference_content(search_scope_reference)
- else: # 知识库
- ref_knowledge_ids = filter_authorized_ids('knowledge',
- self.get_reference_content(search_scope_reference),
- workspace_id)
- document_id_list = QuerySet(Document).filter(
- knowledge_id__in=ref_knowledge_ids
- ).values_list('id', flat=True)
-
- # 权限过滤
- get_knowledge_list_of_authorized = DatabaseModelManage.get_model('get_knowledge_list_of_authorized')
- chat_user_type = self.workflow_manage.get_body().get('chat_user_type')
-
- if get_knowledge_list_of_authorized is not None and RoleConstants.CHAT_USER.value.name == chat_user_type:
- actual_knowledge_ids = list(
- QuerySet(Document).filter(id__in=document_id_list)
- .values_list('knowledge_id', flat=True).distinct()
- )
- authorized_knowledge_ids = get_knowledge_list_of_authorized(
- self.workflow_manage.get_body().get('chat_user_id'),
- [str(k_id) for k_id in actual_knowledge_ids]
- )
- document_id_list = QuerySet(Document).filter(
- id__in=document_id_list,
- knowledge_id__in=authorized_knowledge_ids
- ).values_list('id', flat=True)
-
- if search_mode == 'auto': # 通过问题自动检索
- matched_doc_ids = self.handle_auto_tags(document_id_list, question_reference)
-
- final_document_ids = list(matched_doc_ids)
- else: # 自定义检索条件
- matched_document_ids = self.handle_custom_tags(
- document_id_list, search_condition_list, search_condition_type
- )
-
- final_document_ids = list(matched_document_ids)
-
- # UUID to str
- final_document_ids = [str(doc_id) for doc_id in final_document_ids]
- document_items = QuerySet(Document).filter(id__in=final_document_ids).values()
- final_knowledge_ids = list(set(str(doc['knowledge_id']) for doc in document_items))
- knowledge_items = QuerySet(Knowledge).filter(id__in=final_knowledge_ids).values()
-
- return NodeResult({
- 'document_list': final_document_ids,
- 'document_items': list(document_items),
- 'knowledge_list': final_knowledge_ids,
- 'knowledge_items': list(knowledge_items)
- }, {})
-
- def handle_auto_tags(self, document_id_list: list, question_reference: list):
- question = self.get_reference_content(question_reference)
-
- # 使用jieba分词
- keywords = jieba.lcut(question)
- if not keywords:
- return set()
-
- # 构建OR查询,一次性获取所有匹配的文档
- q_objects = Q()
- for keyword in keywords:
- q_objects |= Q(tag__value__icontains=keyword)
-
- # 单次数据库查询
- matched_doc_ids = set(
- QuerySet(DocumentTag)
- .filter(document_id__in=document_id_list)
- .filter(q_objects)
- .values_list('document_id', flat=True)
- .distinct()
- )
-
- return matched_doc_ids
-
- def handle_custom_tags(self, document_id_list: List, search_condition_list: list, search_condition_type: str):
-
- if not search_condition_list:
- return set(document_id_list)
-
- if search_condition_type == 'AND':
- # AND逻辑:使用子查询和聚合
- matched_doc_ids = set(document_id_list)
-
- for condition in search_condition_list:
- tag_key = condition['key']
- field_value = self.workflow_manage.generate_prompt(condition['value'])
- compare_type = condition['compare']
-
- if not field_value or field_value == 'None' or len(field_value) == 0:
- continue
-
- # 构建查询条件
- if compare_type == 'not_contain':
- # 反向查询:找出包含该标签的文档,然后排除
- exclude_docs = set(QuerySet(DocumentTag).filter(
- document_id__in=matched_doc_ids,
- tag__key=tag_key,
- tag__value__icontains=field_value
- ).values_list('document_id', flat=True).distinct())
-
- matched_doc_ids = matched_doc_ids - exclude_docs
- else:
- if compare_type == 'contain':
- q_filter = Q(tag__key=tag_key, tag__value__icontains=field_value)
- elif compare_type == 'eq':
- q_filter = Q(tag__key=tag_key, tag__value=field_value)
- else:
- continue
-
- # 单次查询获取符合条件的文档
- tag_docs = set(QuerySet(DocumentTag).filter(
- document_id__in=matched_doc_ids
- ).filter(q_filter).values_list('document_id', flat=True).distinct())
-
- matched_doc_ids = matched_doc_ids.intersection(tag_docs)
-
- return matched_doc_ids
-
- else:
- # OR逻辑
- matched_docs = set()
-
- for condition in search_condition_list:
- tag_key = condition['key']
- field_value = self.workflow_manage.generate_prompt(condition['value'])
- compare_type = condition['compare']
-
- if not field_value or field_value == 'None' or len(field_value) == 0:
- continue
-
- if compare_type == 'not_contain':
- # 反向查询:找出包含该标签的文档,然后用全集减去
- exclude_docs = set(QuerySet(DocumentTag).filter(
- document_id__in=document_id_list,
- tag__key=tag_key,
- tag__value__icontains=field_value
- ).values_list('document_id', flat=True).distinct())
-
- matched_docs = matched_docs.union(set(document_id_list) - exclude_docs)
- else:
- if compare_type == 'contain':
- q_filter = Q(tag__key=tag_key, tag__value__icontains=field_value)
- elif compare_type == 'eq':
- q_filter = Q(tag__key=tag_key, tag__value=field_value)
- else:
- continue
-
- docs = set(QuerySet(DocumentTag).filter(
- document_id__in=document_id_list
- ).filter(q_filter).values_list('document_id', flat=True).distinct())
-
- matched_docs = matched_docs.union(docs)
-
- return matched_docs
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- 'question': self.context.get('question'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'document_list': self.context.get('document_list'),
- 'knowledge_list': self.context.get('knowledge_list'),
- 'document_items': self.context.get('document_items'),
- 'knowledge_items': self.context.get('knowledge_items'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/search_knowledge_node/__init__.py b/apps/application/flow/step_node/search_knowledge_node/__init__.py
deleted file mode 100644
index 98a1afcd904..00000000000
--- a/apps/application/flow/step_node/search_knowledge_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:30
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/search_knowledge_node/i_search_knowledge_node.py b/apps/application/flow/step_node/search_knowledge_node/i_search_knowledge_node.py
deleted file mode 100644
index 0cf23cb5e5d..00000000000
--- a/apps/application/flow/step_node/search_knowledge_node/i_search_knowledge_node.py
+++ /dev/null
@@ -1,96 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_search_dataset_node.py
- @date:2024/6/3 17:52
- @desc:
-"""
-import re
-from typing import Type
-
-from django.core import validators
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-from common.utils.common import flat_map
-
-
-class DatasetSettingSerializer(serializers.Serializer):
- # 需要查询的条数
- top_n = serializers.IntegerField(required=True,
- label=_("Reference segment number"))
- # 相似度 0-1之间
- similarity = serializers.FloatField(required=True, max_value=2, min_value=0,
- label=_('similarity'))
- search_mode = serializers.CharField(required=True, validators=[
- validators.RegexValidator(regex=re.compile("^embedding|keywords|blend$"),
- message=_("The type only supports embedding|keywords|blend"), code=500)
- ], label=_("Retrieval Mode"))
- max_paragraph_char_number = serializers.IntegerField(required=True,
- label=_("Maximum number of words in a quoted segment"))
-
-
-class SearchDatasetStepNodeSerializer(serializers.Serializer):
- # 需要查询的数据集id列表
- knowledge_id_list = serializers.ListField(required=True, child=serializers.UUIDField(required=True),
- label=_("Dataset id list"))
- knowledge_setting = DatasetSettingSerializer(required=True)
-
- question_reference_address = serializers.ListField(required=True)
-
- show_knowledge = serializers.BooleanField(required=True,
- label=_("The results are displayed in the knowledge sources"))
- search_scope_type = serializers.ChoiceField(
- required=False, choices=['custom', 'referencing'], label=_("search scope type"),
- allow_null=True, default='custom'
- )
- search_scope_source = serializers.ChoiceField(
- required=False, choices=['document', 'knowledge'],
- label=_("search scope variable type"), default='knowledge'
- )
- search_scope_reference = serializers.ListField(
- required=False, label=_("search scope variable"), default=list
- )
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
-
-
-def get_paragraph_list(chat_record, node_id):
- return flat_map([chat_record.details[key].get('paragraph_list', []) for key in chat_record.details if
- (chat_record.details[
- key].get('type', '') == 'search-dataset-node') and chat_record.details[key].get(
- 'paragraph_list', []) is not None and key == node_id])
-
-
-class ISearchKnowledgeStepNode(INode):
- type = 'search-knowledge-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return SearchDatasetStepNodeSerializer
-
- def _run(self):
- question = self.workflow_manage.get_reference_field(
- self.node_params_serializer.data.get('question_reference_address')[0],
- self.node_params_serializer.data.get('question_reference_address')[1:])
- exclude_paragraph_id_list = []
- if self.flow_params_serializer.data.get('re_chat', False):
- history_chat_record = self.flow_params_serializer.data.get('history_chat_record', [])
- paragraph_id_list = [p.get('id') for p in flat_map(
- [get_paragraph_list(chat_record, self.runtime_node_id) for chat_record in history_chat_record if
- chat_record.problem_text == question])]
- exclude_paragraph_id_list = list(set(paragraph_id_list))
-
- return self.execute(**self.node_params_serializer.data, question=str(question),
- exclude_paragraph_id_list=exclude_paragraph_id_list)
-
- def execute(self, dataset_id_list, dataset_setting, question, show_knowledge, search_scope_type,
- search_scope_source,
- search_scope_reference,
- exclude_paragraph_id_list=None,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/search_knowledge_node/impl/__init__.py b/apps/application/flow/step_node/search_knowledge_node/impl/__init__.py
deleted file mode 100644
index 76a70567714..00000000000
--- a/apps/application/flow/step_node/search_knowledge_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:35
- @desc:
-"""
-from .base_search_knowledge_node import BaseSearchKnowledgeNode
diff --git a/apps/application/flow/step_node/search_knowledge_node/impl/base_search_knowledge_node.py b/apps/application/flow/step_node/search_knowledge_node/impl/base_search_knowledge_node.py
deleted file mode 100644
index 35a6fbd19b3..00000000000
--- a/apps/application/flow/step_node/search_knowledge_node/impl/base_search_knowledge_node.py
+++ /dev/null
@@ -1,187 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_search_dataset_node.py
- @date:2024/6/4 11:56
- @desc:
-"""
-import os
-from typing import List, Dict
-
-from django.db import connection
-from django.db.models import QuerySet
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.search_knowledge_node.i_search_knowledge_node import ISearchKnowledgeStepNode
-from common.config.embedding_config import VectorStore
-from common.constants.permission_constants import RoleConstants
-from common.database_model_manage.database_model_manage import DatabaseModelManage
-from common.db.search import native_search
-from common.utils.common import get_file_content
-from common.utils.shared_resource_auth import filter_authorized_ids
-from knowledge.models import Document, Paragraph, Knowledge, SearchMode
-from maxkb.conf import PROJECT_DIR
-from models_provider.tools import get_model_instance_by_model_workspace_id
-
-
-def get_embedding_id(dataset_id_list):
- dataset_list = QuerySet(Knowledge).filter(id__in=dataset_id_list)
- if len(set([dataset.embedding_model_id for dataset in dataset_list])) > 1:
- raise Exception("关联知识库的向量模型不一致,无法召回分段。")
- if len(dataset_list) == 0:
- raise Exception("知识库设置错误,请重新设置知识库")
- return dataset_list[0].embedding_model_id
-
-
-def get_none_result(question):
- return NodeResult(
- {'paragraph_list': [], 'is_hit_handling_method': [], 'question': question, 'data': '',
- 'directly_return': ''}, {})
-
-
-def reset_title(title):
- if title is None or len(title.strip()) == 0:
- return ""
- else:
- return f"#### {title}\n"
-
-
-def reset_meta(meta):
- if not meta.get('allow_download', False):
- return {'allow_download': False}
- return meta
-
-
-class BaseSearchKnowledgeNode(ISearchKnowledgeStepNode):
- def save_context(self, details, workflow_manage):
- result = details.get('paragraph_list', [])
- knowledge_setting = self.node_params_serializer.data.get('knowledge_setting')
- directly_return = '\n'.join(
- [f"{paragraph.get('title', '')}:{paragraph.get('content')}" for paragraph in result if
- paragraph.get('is_hit_handling_method')])
- self.context['paragraph_list'] = result
- self.context['question'] = details.get('question')
- self.context['run_time'] = details.get('run_time')
- self.context['is_hit_handling_method_list'] = [row for row in result if row.get('is_hit_handling_method')]
- self.context['data'] = '\n'.join(
- [f"{paragraph.get('title', '')}:{paragraph.get('content')}" for paragraph in
- result])[0:knowledge_setting.get('max_paragraph_char_number', 5000)]
- self.context['directly_return'] = directly_return
- self.context['exception_message'] = details.get('err_message')
-
- def get_reference_content(self, fields: List[str]):
- return self.workflow_manage.get_reference_field(fields[0], fields[1:]) if fields else None
-
- def execute(self, knowledge_id_list, knowledge_setting, question, show_knowledge, search_scope_type,
- search_scope_source,
- search_scope_reference,
- exclude_paragraph_id_list=None,
- **kwargs) -> NodeResult:
- self.context['question'] = question
- self.context['show_knowledge'] = show_knowledge
-
- document_id_list = None
- if search_scope_type == 'referencing': # 引用上一步知识库/文档
- if search_scope_source == 'knowledge': # 知识库
- knowledge_id_list = self.get_reference_content(search_scope_reference)
- else: # 文档
- document_id_list = self.get_reference_content(search_scope_reference)
- knowledge_id_list = [str(k) for k in QuerySet(Document).filter(
- id__in=document_id_list
- ).values_list(
- 'knowledge_id', flat=True
- ).distinct()]
-
- get_knowledge_list_of_authorized = DatabaseModelManage.get_model('get_knowledge_list_of_authorized')
- chat_user_type = self.workflow_manage.get_body().get('chat_user_type')
- if get_knowledge_list_of_authorized is not None and RoleConstants.CHAT_USER.value.name == chat_user_type:
- knowledge_id_list = get_knowledge_list_of_authorized(self.workflow_manage.get_body().get('chat_user_id'),
- knowledge_id_list)
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- knowledge_id_list = filter_authorized_ids('knowledge', knowledge_id_list, workspace_id)
- if len(knowledge_id_list) == 0:
- return get_none_result(question)
- model_id = get_embedding_id(knowledge_id_list)
- embedding_model = get_model_instance_by_model_workspace_id(model_id, workspace_id)
- embedding_value = embedding_model.embed_query(question)
- vector = VectorStore.get_embedding_vector()
- exclude_document_id_list = [str(document.id) for document in
- QuerySet(Document).filter(
- knowledge_id__in=knowledge_id_list,
- is_active=False)]
- embedding_list = vector.query(question, embedding_value, knowledge_id_list, document_id_list,
- exclude_document_id_list,
- exclude_paragraph_id_list, True, knowledge_setting.get('top_n'),
- knowledge_setting.get('similarity'),
- SearchMode(knowledge_setting.get('search_mode')))
- # 手动关闭数据库连接
- connection.close()
- if embedding_list is None:
- return get_none_result(question)
- paragraph_list = self.list_paragraph(embedding_list, vector)
- result = [self.reset_paragraph(paragraph, embedding_list) for paragraph in paragraph_list]
- result = sorted(result, key=lambda p: p.get('similarity'), reverse=True)
- return NodeResult({'paragraph_list': result,
- 'is_hit_handling_method_list': [row for row in result if row.get('is_hit_handling_method')],
- 'data': '\n'.join(
- [f"{reset_title(paragraph.get('title', ''))}{paragraph.get('content')}" for paragraph in
- result])[0:knowledge_setting.get('max_paragraph_char_number', 5000)],
- 'directly_return': '\n'.join(
- [paragraph.get('content') for paragraph in
- result if
- paragraph.get('is_hit_handling_method')]),
- 'question': question},
-
- {})
-
- @staticmethod
- def reset_paragraph(paragraph: Dict, embedding_list: List):
- filter_embedding_list = [embedding for embedding in embedding_list if
- str(embedding.get('paragraph_id')) == str(paragraph.get('id'))]
- if filter_embedding_list is not None and len(filter_embedding_list) > 0:
- find_embedding = filter_embedding_list[-1]
- return {
- **paragraph,
- 'similarity': find_embedding.get('similarity'),
- 'is_hit_handling_method': find_embedding.get('similarity') > paragraph.get(
- 'directly_return_similarity') and paragraph.get('hit_handling_method') == 'directly_return',
- 'update_time': paragraph.get('update_time').strftime("%Y-%m-%d %H:%M:%S"),
- 'create_time': paragraph.get('create_time').strftime("%Y-%m-%d %H:%M:%S"),
- 'id': str(paragraph.get('id')),
- 'knowledge_id': str(paragraph.get('knowledge_id')),
- 'document_id': str(paragraph.get('document_id')),
- 'meta': reset_meta(paragraph.get('meta'))
- }
-
- @staticmethod
- def list_paragraph(embedding_list: List, vector):
- paragraph_id_list = [row.get('paragraph_id') for row in embedding_list]
- if paragraph_id_list is None or len(paragraph_id_list) == 0:
- return []
- paragraph_list = native_search(QuerySet(Paragraph).filter(id__in=paragraph_id_list),
- get_file_content(
- os.path.join(PROJECT_DIR, "apps", "application", 'sql',
- 'list_knowledge_paragraph_by_paragraph_id.sql')),
- with_table_name=True)
- # 如果向量库中存在脏数据 直接删除
- if len(paragraph_list) != len(paragraph_id_list):
- exist_paragraph_list = [row.get('id') for row in paragraph_list]
- for paragraph_id in paragraph_id_list:
- if not exist_paragraph_list.__contains__(paragraph_id):
- vector.delete_by_paragraph_id(paragraph_id)
- return paragraph_list
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- 'show_knowledge': self.context.get('show_knowledge'),
- 'question': self.context.get('question'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'paragraph_list': self.context.get('paragraph_list'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/speech_to_text_step_node/__init__.py b/apps/application/flow/step_node/speech_to_text_step_node/__init__.py
deleted file mode 100644
index f3feecc9ce2..00000000000
--- a/apps/application/flow/step_node/speech_to_text_step_node/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .impl import *
diff --git a/apps/application/flow/step_node/speech_to_text_step_node/i_speech_to_text_node.py b/apps/application/flow/step_node/speech_to_text_step_node/i_speech_to_text_node.py
deleted file mode 100644
index 32e1bb752fd..00000000000
--- a/apps/application/flow/step_node/speech_to_text_step_node/i_speech_to_text_node.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class SpeechToTextNodeSerializer(serializers.Serializer):
- stt_model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- stt_model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- stt_model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- audio_list = serializers.ListField(required=True,
- label=_("The audio file cannot be empty"))
- model_params_setting = serializers.DictField(required=False,
- label=_("Model parameter settings"))
-
-
-class ISpeechToTextNode(INode):
- type = 'speech-to-text-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP,WorkflowMode.TOOL,WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return SpeechToTextNodeSerializer
-
- def _run(self):
- res = self.workflow_manage.get_reference_field(self.node_params_serializer.data.get('audio_list')[0],
- self.node_params_serializer.data.get('audio_list')[1:])
- for audio in res:
- if 'file_id' not in audio:
- raise ValueError(
- _("Parameter value error: The uploaded audio lacks file_id, and the audio upload fails"))
-
- return self.execute(audio=res, **self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, stt_model_id,
- audio, model_params_setting=None, stt_model_id_type=None, stt_model_id_reference=None,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/speech_to_text_step_node/impl/__init__.py b/apps/application/flow/step_node/speech_to_text_step_node/impl/__init__.py
deleted file mode 100644
index 9d2da615820..00000000000
--- a/apps/application/flow/step_node/speech_to_text_step_node/impl/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .base_speech_to_text_node import BaseSpeechToTextNode
diff --git a/apps/application/flow/step_node/speech_to_text_step_node/impl/base_speech_to_text_node.py b/apps/application/flow/step_node/speech_to_text_step_node/impl/base_speech_to_text_node.py
deleted file mode 100644
index 1df3f85cdeb..00000000000
--- a/apps/application/flow/step_node/speech_to_text_step_node/impl/base_speech_to_text_node.py
+++ /dev/null
@@ -1,89 +0,0 @@
-# coding=utf-8
-import os
-import tempfile
-from concurrent.futures import ThreadPoolExecutor
-
-from django.db.models import QuerySet
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.speech_to_text_step_node.i_speech_to_text_node import ISpeechToTextNode
-from common.utils.common import split_and_transcribe, any_to_mp3
-from knowledge.models import File
-from models_provider.tools import get_model_instance_by_model_workspace_id
-
-
-class BaseSpeechToTextNode(ISpeechToTextNode):
-
- def save_context(self, details, workflow_manage):
- self.context['answer'] = details.get('answer')
- self.context['result'] = details.get('answer')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
- self.context['exception_message'] = details.get('err_message')
-
- def execute(self, stt_model_id, audio, model_params_setting=None, stt_model_id_type=None, stt_model_id_reference=None,**kwargs) -> NodeResult:
-
- # 处理引用类型
- if stt_model_id_type == 'reference' and stt_model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- stt_model_id_reference[0],
- stt_model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- stt_model_id = reference_data.get('stt_model_id', reference_data.get('model_id', stt_model_id))
- model_params_setting = reference_data.get('model_params_setting')
-
- from django.utils.translation import gettext_lazy as _
-
- if stt_model_id is None or stt_model_id == '':
- raise Exception(_('Model is not allowed to be empty'))
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- stt_model = get_model_instance_by_model_workspace_id(stt_model_id, workspace_id, **(model_params_setting or {}))
- audio_list = audio
- self.context['audio_list'] = audio
-
- def process_audio_item(audio_item, model):
- file = QuerySet(File).filter(id=audio_item['file_id']).first()
- # 根据file_name 吧文件转成mp3格式
- file_format = file.file_name.split('.')[-1]
- with tempfile.NamedTemporaryFile(delete=False, suffix=f'.{file_format}') as temp_file:
- temp_file.write(file.get_bytes())
- temp_file_path = temp_file.name
- with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as temp_amr_file:
- temp_mp3_path = temp_amr_file.name
- any_to_mp3(temp_file_path, temp_mp3_path)
- try:
- transcription = split_and_transcribe(temp_mp3_path, model)
- return {file.file_name: transcription}
- finally:
- os.remove(temp_file_path)
- os.remove(temp_mp3_path)
-
- def process_audio_items(audio_list, model):
- with ThreadPoolExecutor(max_workers=5) as executor:
- results = list(executor.map(lambda item: process_audio_item(item, model), audio_list))
- return results
-
- result = process_audio_items(audio_list, stt_model)
- content = []
- result_content = []
- for item in result:
- for key, value in item.items():
- content.append(f'### {key}\n{value}')
- result_content.append(value)
- return NodeResult({'answer': '\n'.join(result_content), 'result': '\n'.join(result_content),
- 'content': content}, {})
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'answer': self.context.get('answer'),
- 'content': self.context.get('content'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'audio_list': self.context.get('audio_list'),
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/start_node/__init__.py b/apps/application/flow/step_node/start_node/__init__.py
deleted file mode 100644
index 98a1afcd904..00000000000
--- a/apps/application/flow/step_node/start_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:30
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/start_node/i_start_node.py b/apps/application/flow/step_node/start_node/i_start_node.py
deleted file mode 100644
index 40caf0199bf..00000000000
--- a/apps/application/flow/step_node/start_node/i_start_node.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_start_node.py
- @date:2024/6/3 16:54
- @desc:
-"""
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class IStarNode(INode):
- type = 'start-node'
- support = [WorkflowMode.APPLICATION]
-
- def _run(self):
- return self.execute(**self.flow_params_serializer.data)
-
- def execute(self, question, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/start_node/impl/__init__.py b/apps/application/flow/step_node/start_node/impl/__init__.py
deleted file mode 100644
index b68a92d021f..00000000000
--- a/apps/application/flow/step_node/start_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:36
- @desc:
-"""
-from .base_start_node import BaseStartStepNode
diff --git a/apps/application/flow/step_node/start_node/impl/base_start_node.py b/apps/application/flow/step_node/start_node/impl/base_start_node.py
deleted file mode 100644
index 81a23eb25e4..00000000000
--- a/apps/application/flow/step_node/start_node/impl/base_start_node.py
+++ /dev/null
@@ -1,121 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_start_node.py
- @date:2024/6/3 17:17
- @desc:
-"""
-import time
-from datetime import datetime
-from typing import List, Type
-
-from django.db.models import QuerySet
-from django.utils import timezone
-from rest_framework import serializers
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.start_node.i_start_node import IStarNode
-from application.models import ApplicationLongTermMemory
-
-
-def get_default_global_variable(input_field_list: List):
- return {
- item.get('variable') or item.get('field'): item.get('default_value')
- for item in input_field_list
- if item.get('default_value', None) is not None
- }
-
-
-def get_global_variable(node):
- body = node.workflow_manage.get_body()
- history_chat_record = node.flow_params_serializer.data.get('history_chat_record', [])
- history_context = [{'question': chat_record.problem_text, 'answer': chat_record.answer_text} for chat_record in
- history_chat_record]
- chat_id = node.flow_params_serializer.data.get('chat_id')
- return {'time': timezone.localtime(timezone.now()).strftime('%Y-%m-%d %H:%M:%S'), 'start_time': time.time(),
- 'history_context': history_context, 'chat_id': str(chat_id), **node.workflow_manage.form_data,
- 'chat_user_id': body.get('chat_user_id'),
- 'chat_user_type': body.get('chat_user_type'),
- 'chat_user': body.get('chat_user'),
- 'chat_user_group': body.get('chat_user_group')
- }
-
-
-class BaseStartStepNode(IStarNode):
- def save_context(self, details, workflow_manage):
- base_node = self.workflow_manage.get_base_node()
- default_global_variable = get_default_global_variable(base_node.properties.get('user_input_field_list', []))
- default_api_global_variable = get_default_global_variable(base_node.properties.get('api_input_field_list', []))
- workflow_variable = {**default_global_variable, **default_api_global_variable, **get_global_variable(self)}
- self.context['question'] = details.get('question')
- self.context['run_time'] = details.get('run_time')
- self.context['document'] = details.get('document_list')
- self.context['image'] = details.get('image_list')
- self.context['audio'] = details.get('audio_list')
- self.context['video'] = details.get('video_list')
- self.context['other'] = details.get('other_list')
- self.context['exception_message'] = details.get('err_message')
- self.status = details.get('status')
- self.err_message = details.get('err_message')
- for key, value in workflow_variable.items():
- workflow_manage.context[key] = value
- for item in details.get('global_fields', []):
- workflow_manage.context[item.get('key')] = item.get('value')
- self.workflow_manage.chat_context = self.workflow_manage.get_chat_info().get_chat_variable()
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- pass
-
- def execute(self, question, **kwargs) -> NodeResult:
- base_node = self.workflow_manage.get_base_node()
- default_global_variable = get_default_global_variable(base_node.properties.get('user_input_field_list', []))
- default_api_global_variable = get_default_global_variable(base_node.properties.get('api_input_field_list', []))
- workflow_variable = {**default_global_variable, **default_api_global_variable, **get_global_variable(self)}
- chat_user_id = workflow_variable.get('chat_user_id')
- long_term_memory = None
- if chat_user_id:
- long_term_memory = QuerySet(ApplicationLongTermMemory).filter(
- chat_user_id=chat_user_id, application_id=self.workflow_params.get('application_id')
- ).first()
- """
- 开始节点 初始化全局变量
- """
- node_variable = {
- 'question': question,
- 'image': self.workflow_manage.image_list,
- 'document': self.workflow_manage.document_list,
- 'audio': self.workflow_manage.audio_list,
- 'video': self.workflow_manage.video_list,
- 'other': self.workflow_manage.other_list,
- 'memory': long_term_memory.memory if long_term_memory else ''
- }
- workflow_variable['memory'] = node_variable['memory']
- self.workflow_manage.chat_context = self.workflow_manage.get_chat_info().get_chat_variable()
- return NodeResult(node_variable, workflow_variable)
-
- def get_details(self, index: int, **kwargs):
- global_fields = []
- for field in self.node.properties.get('config')['globalFields']:
- key = field['value']
- global_fields.append({
- 'label': field['label'],
- 'key': key,
- 'value': self.workflow_manage.context[key] if key in self.workflow_manage.context else ''
- })
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- "question": self.context.get('question'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'image_list': self.context.get('image'),
- 'video_list': self.context.get('video'),
- 'document_list': self.context.get('document'),
- 'audio_list': self.context.get('audio'),
- 'other_list': self.context.get('other'),
- 'global_fields': global_fields,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/text_to_speech_step_node/__init__.py b/apps/application/flow/step_node/text_to_speech_step_node/__init__.py
deleted file mode 100644
index f3feecc9ce2..00000000000
--- a/apps/application/flow/step_node/text_to_speech_step_node/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .impl import *
diff --git a/apps/application/flow/step_node/text_to_speech_step_node/i_text_to_speech_node.py b/apps/application/flow/step_node/text_to_speech_step_node/i_text_to_speech_node.py
deleted file mode 100644
index 0dde27fea51..00000000000
--- a/apps/application/flow/step_node/text_to_speech_step_node/i_text_to_speech_node.py
+++ /dev/null
@@ -1,42 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-from django.utils.translation import gettext_lazy as _
-
-
-class TextToSpeechNodeSerializer(serializers.Serializer):
- tts_model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- tts_model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- tts_model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- content_list = serializers.ListField(required=True, label=_("Text content"))
- model_params_setting = serializers.DictField(required=False,
- label=_("Model parameter settings"))
-
-
-class ITextToSpeechNode(INode):
- type = 'text-to-speech-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return TextToSpeechNodeSerializer
-
- def _run(self):
- content = self.workflow_manage.get_reference_field(self.node_params_serializer.data.get('content_list')[0],
- self.node_params_serializer.data.get('content_list')[1:])
- return self.execute(content=content, **self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, tts_model_id,
- content, model_params_setting=None, tts_model_id_type=None, tts_model_id_reference=None,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/text_to_speech_step_node/impl/__init__.py b/apps/application/flow/step_node/text_to_speech_step_node/impl/__init__.py
deleted file mode 100644
index 385b9718f6e..00000000000
--- a/apps/application/flow/step_node/text_to_speech_step_node/impl/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .base_text_to_speech_node import BaseTextToSpeechNode
diff --git a/apps/application/flow/step_node/text_to_speech_step_node/impl/base_text_to_speech_node.py b/apps/application/flow/step_node/text_to_speech_step_node/impl/base_text_to_speech_node.py
deleted file mode 100644
index 6e740c5971a..00000000000
--- a/apps/application/flow/step_node/text_to_speech_step_node/impl/base_text_to_speech_node.py
+++ /dev/null
@@ -1,178 +0,0 @@
-# coding=utf-8
-import io
-import mimetypes
-
-from django.core.files.uploadedfile import InMemoryUploadedFile
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.text_to_speech_step_node.i_text_to_speech_node import ITextToSpeechNode
-from common.utils.common import _remove_empty_lines
-from knowledge.models import FileSourceType
-from models_provider.tools import get_model_instance_by_model_workspace_id
-from oss.serializers.file import FileSerializer
-from pydub import AudioSegment
-
-
-def bytes_to_uploaded_file(file_bytes, file_name="generated_audio.mp3"):
- content_type, _ = mimetypes.guess_type(file_name)
- if content_type is None:
- # 如果未能识别,设置为默认的二进制文件类型
- content_type = "application/octet-stream"
- # 创建一个内存中的字节流对象
- file_stream = io.BytesIO(file_bytes)
-
- # 获取文件大小
- file_size = len(file_bytes)
-
- uploaded_file = InMemoryUploadedFile(
- file=file_stream,
- field_name=None,
- name=file_name,
- content_type=content_type,
- size=file_size,
- charset=None,
- )
- return uploaded_file
-
-
-class BaseTextToSpeechNode(ITextToSpeechNode):
- def save_context(self, details, workflow_manage):
- self.context['answer'] = details.get('answer')
- self.context['result'] = details.get('result')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
-
- def execute(self, tts_model_id,
- content, model_params_setting=None, tts_model_id_type=None, tts_model_id_reference=None,
- max_length=1024, **kwargs) -> NodeResult:
- # 处理引用类型
- if tts_model_id_type == 'reference' and tts_model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- tts_model_id_reference[0],
- tts_model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- tts_model_id = reference_data.get('tts_model_id', reference_data.get('model_id', tts_model_id))
- model_params_setting = reference_data.get('model_params_setting')
-
- from django.utils.translation import gettext_lazy as _
-
- if tts_model_id is None or tts_model_id == '':
- raise Exception(_('Model is not allowed to be empty'))
- # 分割文本为合理片段
- content = _remove_empty_lines(content)
- content_chunks = [content[i:i + max_length]
- for i in range(0, len(content), max_length)]
-
- # 生成并收集所有音频片段
- audio_segments = []
- temp_files = []
-
- for i, chunk in enumerate(content_chunks):
- self.context['content'] = chunk
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- model = get_model_instance_by_model_workspace_id(
- tts_model_id, workspace_id, **(model_params_setting or {}))
-
- audio_byte = model.text_to_speech(chunk)
-
- # 保存为临时音频文件用于合并
- temp_file = io.BytesIO(audio_byte)
- audio_segment = AudioSegment.from_file(temp_file)
- audio_segments.append(audio_segment)
- temp_files.append(temp_file)
-
- # 合并所有音频片段
- combined_audio = AudioSegment.empty()
- for segment in audio_segments:
- combined_audio += segment
-
- # 将合并后的音频转为字节流
- output_buffer = io.BytesIO()
- combined_audio.export(output_buffer, format="mp3")
- combined_bytes = output_buffer.getvalue()
- file_name = 'combined_audio.mp3'
- file = bytes_to_uploaded_file(combined_bytes, file_name)
- # 存储合并后的音频文件
- file_url = self.upload_file(file)
- # 生成音频标签
- audio_label = f''
- file_id = file_url.split('/')[-1]
- audio_list = [{'file_id': file_id, 'file_name': file_name, 'url': file_url}]
-
- # 关闭所有临时文件
- for temp_file in temp_files:
- temp_file.close()
- output_buffer.close()
-
- return NodeResult({
- 'answer': audio_label,
- 'result': audio_list
- }, {})
-
- def upload_file(self, file):
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.upload_knowledge_file(file)
- if [WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP].__contains__(self.workflow_manage.flow.workflow_mode):
- return self.upload_tool_file(file)
- return self.upload_application_file(file)
-
- def upload_knowledge_file(self, file):
- knowledge_id = self.workflow_params.get('knowledge_id')
- meta = {
- 'debug': False,
- 'knowledge_id': knowledge_id,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': knowledge_id,
- 'source_type': FileSourceType.KNOWLEDGE.value
- }).upload()
- return file_url
-
- def upload_tool_file(self, file):
- tool_id = self.workflow_params.get('tool_id')
- meta = {
- 'debug': False,
- 'tool_id': tool_id,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': tool_id,
- 'source_type': FileSourceType.TOOL.value
- }).upload()
- return file_url
-
- def upload_application_file(self, file):
- application = self.workflow_manage.work_flow_post_handler.chat_info.application
- chat_id = self.workflow_params.get('chat_id')
- meta = {
- 'debug': False if application.id else True,
- 'chat_id': chat_id,
- 'application_id': str(application.id) if application.id else None,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': meta['application_id'],
- 'source_type': FileSourceType.APPLICATION.value
- }).upload()
- return file_url
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'content': self.context.get('content'),
- 'err_message': self.err_message,
- 'answer': self.context.get('answer'),
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/text_to_video_step_node/__init__.py b/apps/application/flow/step_node/text_to_video_step_node/__init__.py
deleted file mode 100644
index f3feecc9ce2..00000000000
--- a/apps/application/flow/step_node/text_to_video_step_node/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .impl import *
diff --git a/apps/application/flow/step_node/text_to_video_step_node/i_text_to_video_node.py b/apps/application/flow/step_node/text_to_video_step_node/i_text_to_video_node.py
deleted file mode 100644
index cf0f0252332..00000000000
--- a/apps/application/flow/step_node/text_to_video_step_node/i_text_to_video_node.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class TextToVideoNodeSerializer(serializers.Serializer):
- model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
- prompt = serializers.CharField(required=True, label=_("Prompt word (positive)"))
-
- negative_prompt = serializers.CharField(required=False, label=_("Prompt word (negative)"),
- allow_null=True, allow_blank=True, )
- # 多轮对话数量
- dialogue_number = serializers.IntegerField(required=False, default=0,
- label=_("Number of multi-round conversations"))
-
- dialogue_type = serializers.CharField(required=False, default='NODE',
- label=_("Conversation storage type"))
-
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- model_params_setting = serializers.JSONField(required=False, default=dict,
- label=_("Model parameter settings"))
-
-
-class ITextToVideoNode(INode):
- type = 'text-to-video-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return TextToVideoNodeSerializer
-
- def _run(self):
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL,
- WorkflowMode.TOOL_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data,
- **{'history_chat_record': [], 'stream': True, 'chat_id': None, 'chat_record_id': None})
- else:
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, model_id, prompt, negative_prompt, dialogue_number, dialogue_type, history_chat_record,
- model_params_setting,
- chat_record_id,
- model_id_type=None, model_id_reference=None,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/text_to_video_step_node/impl/__init__.py b/apps/application/flow/step_node/text_to_video_step_node/impl/__init__.py
deleted file mode 100644
index be03d57a2fa..00000000000
--- a/apps/application/flow/step_node/text_to_video_step_node/impl/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .base_text_to_video_node import BaseTextToVideoNode
diff --git a/apps/application/flow/step_node/text_to_video_step_node/impl/base_text_to_video_node.py b/apps/application/flow/step_node/text_to_video_step_node/impl/base_text_to_video_node.py
deleted file mode 100644
index fd4ae5ad2f3..00000000000
--- a/apps/application/flow/step_node/text_to_video_step_node/impl/base_text_to_video_node.py
+++ /dev/null
@@ -1,190 +0,0 @@
-# coding=utf-8
-from functools import reduce
-from typing import List
-
-import requests
-from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.text_to_video_step_node.i_text_to_video_node import ITextToVideoNode
-from common.utils.common import bytes_to_uploaded_file
-from knowledge.models import FileSourceType
-from oss.serializers.file import FileSerializer
-from models_provider.tools import get_model_instance_by_model_workspace_id
-from django.utils.translation import gettext
-
-
-class BaseTextToVideoNode(ITextToVideoNode):
- def save_context(self, details, workflow_manage):
- self.context['answer'] = details.get('answer')
- self.context['exception_message'] = details.get('err_message')
- self.context['question'] = details.get('question')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
-
- def execute(self, model_id, prompt, negative_prompt, dialogue_number, dialogue_type, history_chat_record,
- model_params_setting,
- chat_record_id,
- model_id_type=None, model_id_reference=None,
- **kwargs) -> NodeResult:
- # 处理引用类型
- if model_id_type == 'reference' and model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- model_id_reference[0],
- model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- model_id = reference_data.get('model_id', model_id)
- model_params_setting = reference_data.get('model_params_setting')
-
- from django.utils.translation import gettext_lazy as _
-
- if model_id is None or model_id == '':
- raise Exception(_('Model is not allowed to be empty'))
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- ttv_model = get_model_instance_by_model_workspace_id(model_id, workspace_id,
- **(model_params_setting or {}))
- history_message = self.get_history_message(history_chat_record, dialogue_number)
- self.context['history_message'] = history_message
- question = self.generate_prompt_question(prompt)
- self.context['question'] = question
- message_list = self.generate_message_list(question, history_message)
- self.context['message_list'] = message_list
- self.context['dialogue_type'] = dialogue_type
- self.context['negative_prompt'] = self.generate_prompt_question(negative_prompt)
- video_urls = ttv_model.generate_video(question, negative_prompt)
- # 保存图片
- if video_urls is None:
- return NodeResult({'answer': gettext('Failed to generate video')}, {})
- file_name = 'generated_video.mp4'
- if isinstance(video_urls, str) and video_urls.startswith('http'):
- video_urls = requests.get(video_urls).content
- file = bytes_to_uploaded_file(video_urls, file_name)
- file_url = self.upload_file(file)
- video_label = f''
- video_list = [{'file_id': file_url.split('/')[-1], 'file_name': file_name, 'url': file_url}]
- return NodeResult({'answer': video_label, 'chat_model': ttv_model, 'message_list': message_list,
- 'video': video_list,
- 'history_message': history_message, 'question': question}, {})
-
- def upload_file(self, file):
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.upload_knowledge_file(file)
- if [WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP].__contains__(self.workflow_manage.flow.workflow_mode):
- return self.upload_tool_file(file)
- return self.upload_application_file(file)
-
- def upload_knowledge_file(self, file):
- knowledge_id = self.workflow_params.get('knowledge_id')
- meta = {
- 'debug': False,
- 'knowledge_id': knowledge_id
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': knowledge_id,
- 'source_type': FileSourceType.KNOWLEDGE.value
- }).upload()
- return file_url
-
- def upload_tool_file(self, file):
- tool_id = self.workflow_params.get('tool_id')
- meta = {
- 'debug': False,
- 'tool_id': tool_id,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': tool_id,
- 'source_type': FileSourceType.TOOL.value
- }).upload()
- return file_url
-
- def upload_application_file(self, file):
- application = self.workflow_manage.work_flow_post_handler.chat_info.application
- chat_id = self.workflow_params.get('chat_id')
- meta = {
- 'debug': False if application.id else True,
- 'chat_id': chat_id,
- 'application_id': str(application.id) if application.id else None,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': meta['application_id'],
- 'source_type': FileSourceType.APPLICATION.value
- }).upload()
- return file_url
-
- def generate_history_ai_message(self, chat_record):
- for val in chat_record.details.values():
- if self.node.id == val['node_id'] and 'image_list' in val:
- if val['dialogue_type'] == 'WORKFLOW':
- return chat_record.get_ai_message()
- image_list = val['image_list']
- return AIMessage(content=[
- *[{'type': 'image_url', 'image_url': {'url': f'{file_url}'}} for file_url in image_list]
- ])
- return chat_record.get_ai_message()
-
- def get_history_message(self, history_chat_record, dialogue_number):
- start_index = len(history_chat_record) - dialogue_number
- history_message = reduce(lambda x, y: [*x, *y], [
- [self.generate_history_human_message(history_chat_record[index]),
- self.generate_history_ai_message(history_chat_record[index])]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))], [])
- return history_message
-
- def generate_history_human_message(self, chat_record):
-
- for data in chat_record.details.values():
- if self.node.id == data['node_id'] and 'image_list' in data:
- image_list = data['image_list']
- if len(image_list) == 0 or data['dialogue_type'] == 'WORKFLOW':
- return HumanMessage(content=chat_record.problem_text)
- return HumanMessage(content=data['question'])
- return HumanMessage(content=chat_record.problem_text)
-
- def generate_prompt_question(self, prompt):
- return self.workflow_manage.generate_prompt(prompt)
-
- def generate_message_list(self, question: str, history_message):
- return [
- *history_message,
- question
- ]
-
- @staticmethod
- def reset_message_list(message_list: List[BaseMessage], answer_text):
- result = [{'role': 'user' if isinstance(message, HumanMessage) else 'ai', 'content': message.content} for
- message
- in
- message_list]
- result.append({'role': 'ai', 'content': answer_text})
- return result
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'history_message': [{'content': message.content, 'role': message.type} for message in
- (self.context.get('history_message') if self.context.get(
- 'history_message') is not None else [])],
- 'question': self.context.get('question'),
- 'answer': self.context.get('answer'),
- 'type': self.node.type,
- 'message_tokens': self.context.get('message_tokens'),
- 'answer_tokens': self.context.get('answer_tokens'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'image_list': self.context.get('image_list'),
- 'dialogue_type': self.context.get('dialogue_type'),
- 'negative_prompt': self.context.get('negative_prompt'),
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/tool_lib_node/__init__.py b/apps/application/flow/step_node/tool_lib_node/__init__.py
deleted file mode 100644
index 7422965c365..00000000000
--- a/apps/application/flow/step_node/tool_lib_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: __init__.py
- @date:2024/8/8 17:45
- @desc:
-"""
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/tool_lib_node/i_tool_lib_node.py b/apps/application/flow/step_node/tool_lib_node/i_tool_lib_node.py
deleted file mode 100644
index 08f3e3a845d..00000000000
--- a/apps/application/flow/step_node/tool_lib_node/i_tool_lib_node.py
+++ /dev/null
@@ -1,54 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: i_function_lib_node.py
- @date:2024/8/8 16:21
- @desc:
-"""
-from typing import Type
-
-from django.db import connection
-from django.db.models import QuerySet
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-from common.field.common import ObjectField
-from tools.models.tool import Tool
-
-
-class InputField(serializers.Serializer):
- name = serializers.CharField(required=True, label=_('Variable Name'))
- value = ObjectField(required=True, label=_("Variable Value"), model_type_list=[str, list])
-
-
-class FunctionLibNodeParamsSerializer(serializers.Serializer):
- tool_lib_id = serializers.UUIDField(required=True, label=_('Library ID'))
- input_field_list = InputField(required=True, many=True)
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
- f_lib = QuerySet(Tool).filter(id=self.data.get('tool_lib_id')).first()
- # 归还链接到连接池
- connection.close()
- if f_lib is None:
- raise Exception(_('The function has been deleted'))
-
-
-class IToolLibNode(INode):
- type = 'tool-lib-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return FunctionLibNodeParamsSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, tool_lib_id, input_field_list, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/tool_lib_node/impl/__init__.py b/apps/application/flow/step_node/tool_lib_node/impl/__init__.py
deleted file mode 100644
index c6c0d832175..00000000000
--- a/apps/application/flow/step_node/tool_lib_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: __init__.py
- @date:2024/8/8 17:48
- @desc:
-"""
-from .base_tool_lib_node import BaseToolLibNodeNode
diff --git a/apps/application/flow/step_node/tool_lib_node/impl/base_tool_lib_node.py b/apps/application/flow/step_node/tool_lib_node/impl/base_tool_lib_node.py
deleted file mode 100644
index 3cd056b9534..00000000000
--- a/apps/application/flow/step_node/tool_lib_node/impl/base_tool_lib_node.py
+++ /dev/null
@@ -1,311 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: base_function_lib_node.py
- @date:2024/8/8 17:49
- @desc:
-"""
-
-import base64
-import io
-import json
-import mimetypes
-import time
-import traceback
-from typing import Dict
-
-import uuid_utils.compat as uuid
-from django.core.files.uploadedfile import InMemoryUploadedFile
-from django.db.models import QuerySet
-from django.utils.translation import gettext as _
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.tool_lib_node.i_tool_lib_node import IToolLibNode
-from common.database_model_manage.database_model_manage import DatabaseModelManage
-from common.exception.app_exception import AppApiException
-from common.utils.common import common_convert_value
-from common.utils.logger import maxkb_logger
-from common.utils.rsa_util import rsa_long_decrypt
-from common.utils.tool_code import ToolExecutor
-from knowledge.models import FileSourceType
-from knowledge.models.knowledge_action import State
-from oss.serializers.file import FileSerializer
-from tools.models import Tool, ToolRecord, ToolTaskTypeChoices
-
-function_executor = ToolExecutor()
-
-
-def write_context(step_variable: Dict, global_variable: Dict, node, workflow):
- if step_variable is not None:
- for key in step_variable:
- node.context[key] = step_variable[key]
- if workflow.is_result(node, NodeResult(step_variable, global_variable)) and 'result' in step_variable:
- result = str(step_variable['result']) + '\n'
- yield result
- node.answer_text = result
- node.context['run_time'] = time.time() - node.context['start_time']
-
-
-def get_field_value(debug_field_list, name, is_required):
- result = [field for field in debug_field_list if field.get('name') == name]
- if len(result) > 0:
- return result[-1]['value']
- if is_required:
- raise AppApiException(500, _('Field: {name} No value set').format(name=name))
- return None
-
-
-def valid_reference_value(_type, value, name):
- if _type == 'int':
- instance_type = int | float
- elif _type == 'boolean':
- instance_type = bool
- elif _type == 'float':
- instance_type = float | int
- elif _type == 'dict':
- value = json.loads(value) if isinstance(value, str) else value
- instance_type = dict
- elif _type == 'array':
- value = json.loads(value) if isinstance(value, str) else value
- instance_type = list
- elif _type == 'string':
- instance_type = str
- else:
- maxkb_logger.error(_(
- 'Field: {name} Type: {_type} Value: {value} Unsupported this type'
- ).format(name=name, _type=_type, value=value))
- return value
- if not isinstance(value, instance_type):
- raise Exception(_(
- 'Field: {name} Type: {_type} Value: {value} Type error'
- ).format(name=name, _type=_type, value=value))
- return value
-
-
-def convert_value(name: str, value, _type, is_required, source, node):
- if not is_required and (value is None or ((isinstance(value, str) or isinstance(value, list)) and len(value) == 0)):
- return None
- if source == 'reference':
- value = node.workflow_manage.get_reference_field(
- value[0],
- value[1:])
- if value is None:
- if not is_required:
- return None
- else:
- raise Exception(_(
- 'Field: {name} Type: {_type} is required'
- ).format(name=name, _type=_type))
- value = valid_reference_value(_type, value, name)
- if _type == 'int':
- return int(value)
- if _type == 'float':
- return float(value)
- return value
- try:
- value = node.workflow_manage.generate_prompt(value)
- return common_convert_value(_type, value)
- except Exception as e:
- raise Exception(
- _('Field: {name} Type: {_type} Value: {value} Type error').format(name=name, _type=_type,
- value=value))
-
-
-def valid_function(tool_lib, workspace_id):
- if tool_lib is None:
- raise Exception(_('Tool does not exist'))
- get_authorized_tool = DatabaseModelManage.get_model("get_authorized_tool")
- if tool_lib and tool_lib.workspace_id != workspace_id and get_authorized_tool is not None:
- tool_lib = get_authorized_tool(QuerySet(Tool).filter(id=tool_lib.id), workspace_id).first()
- if tool_lib is None:
- raise Exception(_("Tool does not exist"))
- if not tool_lib.is_active:
- raise Exception(_("Tool is not active"))
-
-
-def _filter_file_bytes(data):
- """递归过滤掉所有层级的 file_bytes"""
- if isinstance(data, dict):
- return {k: _filter_file_bytes(v) for k, v in data.items() if k != 'file_bytes'}
- elif isinstance(data, list):
- return [_filter_file_bytes(item) for item in data]
- else:
- return data
-
-
-def bytes_to_uploaded_file(file_bytes, file_name="unknown"):
- content_type, _ = mimetypes.guess_type(file_name)
- if content_type is None:
- # 如果未能识别,设置为默认的二进制文件类型
- content_type = "application/octet-stream"
- # 创建一个内存中的字节流对象
- file_stream = io.BytesIO(file_bytes)
-
- # 获取文件大小
- file_size = len(file_bytes)
-
- uploaded_file = InMemoryUploadedFile(
- file=file_stream,
- field_name=None,
- name=file_name,
- content_type=content_type,
- size=file_size,
- charset=None,
- )
- return uploaded_file
-
-
-def _get_result_detail(result):
- if isinstance(result, dict):
- result_dict = {k: (str(v)[:500] if len(str(v)) > 500 else v) for k, v in result.items()}
- elif isinstance(result, list):
- result_dict = [str(item)[:500] if len(str(item)) > 500 else item for item in result]
- elif isinstance(result, str):
- result_dict = result[:500] if len(result) > 500 else result
- else:
- result_dict = result
- return result_dict
-
-
-class BaseToolLibNodeNode(IToolLibNode):
- def save_context(self, details, workflow_manage):
- self.context['result'] = details.get('result')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result'):
- self.answer_text = str(details.get('result'))
-
- def execute(self, tool_lib_id, input_field_list, **kwargs) -> NodeResult:
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- tool_lib = QuerySet(Tool).filter(id=tool_lib_id).first()
- valid_function(tool_lib, workspace_id)
- params = {
- field.get('name'): convert_value(
- field.get('name'), field.get('value'), field.get('type'),
- field.get('is_required'),
- field.get('source'), self
- )
- for field in [
- {
- 'value': get_field_value(input_field_list, field.get('name'), field.get('is_required'), ), **field
- } for field in tool_lib.input_field_list
- ]
- }
-
- self.context['params'] = params
- # 合并初始化参数
- init_params_default_value = {i["field"]: i.get('default_value') for i in tool_lib.init_field_list}
- if tool_lib.init_params is not None:
- all_params = init_params_default_value | json.loads(rsa_long_decrypt(tool_lib.init_params)) | params
- else:
- all_params = init_params_default_value | params
- if self.node.properties.get('kind') == 'data-source':
- exist = function_executor.exec_code(
- f'{tool_lib.code}\ndef function_exist(function_name): return callable(globals().get(function_name))',
- {'function_name': 'get_download_file_list'})
- all_params = {**all_params, **self.workflow_params.get('data_source')}
- if exist:
- download_file_list = []
- download_list = function_executor.exec_code(tool_lib.code,
- all_params,
- function_name='get_download_file_list')
- for item in download_list:
- result = function_executor.exec_code(tool_lib.code,
- {**all_params, 'download_item': item},
- function_name='download')
- file_bytes = result.get('file_bytes', [])
- chunks = []
- for chunk in file_bytes:
- chunks.append(base64.b64decode(chunk))
- file = bytes_to_uploaded_file(b''.join(chunks), result.get('name'))
- file_url = self.upload_knowledge_file(file)
- download_file_list.append({'file_id': file_url.split('/')[-1], 'name': result.get('name')})
- result = download_file_list
- else:
- result = function_executor.exec_code(tool_lib.code, all_params)
- else:
- result = self.tool_exec_record(tool_lib, all_params)
- return NodeResult({'result': result},
- (self.workflow_manage.params.get('knowledge_base') or {}) if self.node.properties.get(
- 'kind') == 'data-source' else {}, _write_context=write_context)
-
- def tool_exec_record(self, tool_lib, all_params):
- task_record_id = uuid.uuid7()
- start_time = time.time()
- filtered_args = all_params
- try:
- # 过滤掉 tool_init_params 中的参数
- tool_init_params = json.loads(rsa_long_decrypt(tool_lib.init_params)) if tool_lib.init_params else {}
- if tool_init_params:
- filtered_args = {
- k: v for k, v in all_params.items()
- if k not in tool_init_params
- }
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- source_id = self.workflow_manage.params.get('knowledge_id')
- source_type = ToolTaskTypeChoices.KNOWLEDGE.value
- elif [WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP].__contains__(self.workflow_manage.flow.workflow_mode):
- source_id = self.workflow_manage.params.get('tool_id')
- source_type = ToolTaskTypeChoices.TOOL.value
- else:
- source_id = self.workflow_manage.params.get('application_id')
- source_type = ToolTaskTypeChoices.APPLICATION.value
-
- ToolRecord(
- id=task_record_id,
- workspace_id=tool_lib.workspace_id,
- tool_id=tool_lib.id,
- source_type=source_type,
- source_id=source_id,
- meta={'input': filtered_args, 'output': {}},
- state=State.STARTED
- ).save()
-
- result = function_executor.exec_code(tool_lib.code, all_params)
- result_dict = _get_result_detail(result)
- QuerySet(ToolRecord).filter(id=task_record_id).update(
- state=State.SUCCESS,
- run_time=time.time() - start_time,
- meta={'input': filtered_args, 'output': result_dict}
- )
-
- return result
- except Exception as e:
- maxkb_logger.error(f"Tool execution error: {traceback.format_exc()}")
- QuerySet(ToolRecord).filter(id=task_record_id).update(
- state=State.FAILURE,
- run_time=time.time() - start_time,
- meta={'input': filtered_args, 'output': 'Error: ' + str(e)}
- )
-
- def upload_knowledge_file(self, file):
- knowledge_id = self.workflow_params.get('knowledge_id')
- meta = {
- 'debug': False,
- 'knowledge_id': knowledge_id,
- }
- file_url = FileSerializer(data={
- 'file': file,
- 'meta': meta,
- 'source_id': knowledge_id,
- 'source_type': FileSourceType.KNOWLEDGE.value
- }).upload().replace("./oss/file/", '')
- file.close()
- return file_url
-
- def get_details(self, index: int, **kwargs):
- result = _filter_file_bytes(self.context.get('result'))
-
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- "result": result,
- "params": self.context.get('params'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/tool_node/__init__.py b/apps/application/flow/step_node/tool_node/__init__.py
deleted file mode 100644
index ebfbe8d8bb4..00000000000
--- a/apps/application/flow/step_node/tool_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: __init__.py.py
- @date:2024/8/13 10:43
- @desc:
-"""
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/tool_node/i_tool_node.py b/apps/application/flow/step_node/tool_node/i_tool_node.py
deleted file mode 100644
index 4f8343a67db..00000000000
--- a/apps/application/flow/step_node/tool_node/i_tool_node.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: i_function_lib_node.py
- @date:2024/8/8 16:21
- @desc:
-"""
-import re
-from typing import Type
-
-from django.core import validators
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-from rest_framework.utils.formatting import lazy_format
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-from common.exception.app_exception import AppApiException
-from common.field.common import ObjectField
-
-
-class InputField(serializers.Serializer):
- name = serializers.CharField(required=True, label=_('Variable Name'))
- is_required = serializers.BooleanField(required=True, label=_("Is this field required"))
- type = serializers.CharField(required=True, label=_("type"), validators=[
- validators.RegexValidator(regex=re.compile("^string|int|dict|array|float|boolean$"),
- message=_("The field only supports string|int|dict|array|float"), code=500)
- ])
- source = serializers.CharField(required=True, label=_("source"), validators=[
- validators.RegexValidator(regex=re.compile("^custom|reference$"),
- message=_("The field only supports custom|reference"), code=500)
- ])
- value = ObjectField(required=True, label=_("Variable Value"), model_type_list=[str, list])
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
- is_required = self.data.get('is_required')
- if is_required and self.data.get('value') is None:
- message = lazy_format(_('{field}, this field is required.'), field=self.data.get("name"))
- raise AppApiException(500, message)
-
-
-class FunctionNodeParamsSerializer(serializers.Serializer):
- input_field_list = InputField(required=True, many=True)
- code = serializers.CharField(required=True, label=_("function"))
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
-
-
-class IToolNode(INode):
- type = 'tool-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return FunctionNodeParamsSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, input_field_list, code, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/tool_node/impl/__init__.py b/apps/application/flow/step_node/tool_node/impl/__init__.py
deleted file mode 100644
index 0ef86c3b687..00000000000
--- a/apps/application/flow/step_node/tool_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: __init__.py.py
- @date:2024/8/13 11:19
- @desc:
-"""
-from .base_tool_node import BaseToolNodeNode
diff --git a/apps/application/flow/step_node/tool_node/impl/base_tool_node.py b/apps/application/flow/step_node/tool_node/impl/base_tool_node.py
deleted file mode 100644
index c5595bc805e..00000000000
--- a/apps/application/flow/step_node/tool_node/impl/base_tool_node.py
+++ /dev/null
@@ -1,118 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: base_function_lib_node.py
- @date:2024/8/8 17:49
- @desc:
-"""
-import json
-import time
-from typing import Dict
-
-from django.utils.translation import gettext as _
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.tool_node.i_tool_node import IToolNode
-from common.utils.common import common_convert_value
-from common.utils.logger import maxkb_logger
-from common.utils.tool_code import ToolExecutor
-from maxkb.const import CONFIG
-
-function_executor = ToolExecutor()
-
-
-def write_context(step_variable: Dict, global_variable: Dict, node, workflow):
- if step_variable is not None:
- for key in step_variable:
- node.context[key] = step_variable[key]
- if workflow.is_result(node, NodeResult(step_variable, global_variable)) and 'result' in step_variable:
- result = str(step_variable['result']) + '\n'
- yield result
- node.answer_text = result
- node.context['run_time'] = time.time() - node.context['start_time']
-
-
-def valid_reference_value(_type, value, name):
- if _type == 'int':
- instance_type = int | float
- elif _type == 'boolean':
- instance_type = bool
- elif _type == 'float':
- instance_type = float | int
- elif _type == 'dict':
- value = json.loads(value) if isinstance(value, str) else value
- instance_type = dict
- elif _type == 'array':
- value = json.loads(value) if isinstance(value, str) else value
- instance_type = list
- elif _type == 'string':
- instance_type = str
- else:
- maxkb_logger.error(_(
- 'Field: {name} Type: {_type} Value: {value} Unsupported this type'
- ).format(name=name, _type=_type, value=value))
- return value
- if not isinstance(value, instance_type):
- raise Exception(_(
- 'Field: {name} Type: {_type} Value: {value} Type error'
- ).format(name=name, _type=_type, value=value))
- return value
-
-
-def convert_value(name: str, value, _type, is_required, source, node):
- if not is_required and (value is None or ((isinstance(value, str) or isinstance(value, list)) and len(value) == 0)):
- return None
- if source == 'reference':
- value = node.workflow_manage.get_reference_field(
- value[0],
- value[1:])
- if value is None:
- if not is_required:
- return None
- else:
- raise Exception(_(
- 'Field: {name} Type: {_type} is required'
- ).format(name=name, _type=_type))
- value = valid_reference_value(_type, value, name)
- if _type == 'int':
- return int(value)
- if _type == 'float':
- return float(value)
- return value
- try:
- value = node.workflow_manage.generate_prompt(value)
- return common_convert_value(_type, value)
- except Exception as e:
- raise Exception(
- _('Field: {name} Type: {_type} Value: {value} Type error').format(name=name, _type=_type,
- value=value))
-
-
-class BaseToolNodeNode(IToolNode):
- def save_context(self, details, workflow_manage):
- self.context['result'] = details.get('result')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = str(details.get('result'))
-
- def execute(self, input_field_list, code, **kwargs) -> NodeResult:
- params = {field.get('name'): convert_value(field.get('name'), field.get('value'), field.get('type'),
- field.get('is_required'), field.get('source'), self)
- for field in input_field_list}
- result = function_executor.exec_code(code, params)
- self.context['params'] = params
- return NodeResult({'result': result}, {}, _write_context=write_context)
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- "result": self.context.get('result'),
- "params": self.context.get('params'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/tool_start_node/__init__.py b/apps/application/flow/step_node/tool_start_node/__init__.py
deleted file mode 100644
index 98a1afcd904..00000000000
--- a/apps/application/flow/step_node/tool_start_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:30
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/tool_start_node/i_tool_start_node.py b/apps/application/flow/step_node/tool_start_node/i_tool_start_node.py
deleted file mode 100644
index ca313277376..00000000000
--- a/apps/application/flow/step_node/tool_start_node/i_tool_start_node.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: i_start_node.py
- @date:2024/6/3 16:54
- @desc:
-"""
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class IToolStartNode(INode):
- type = 'tool-start-node'
- support = [WorkflowMode.TOOL]
-
- def _run(self):
- return self.execute(**self.flow_params_serializer.data)
-
- def execute(self, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/tool_start_node/impl/__init__.py b/apps/application/flow/step_node/tool_start_node/impl/__init__.py
deleted file mode 100644
index 6fcd243dc5c..00000000000
--- a/apps/application/flow/step_node/tool_start_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 15:36
- @desc:
-"""
-from .base_tool_start_node import BaseToolStartStepNode
diff --git a/apps/application/flow/step_node/tool_start_node/impl/base_tool_start_node.py b/apps/application/flow/step_node/tool_start_node/impl/base_tool_start_node.py
deleted file mode 100644
index 5b24722f76e..00000000000
--- a/apps/application/flow/step_node/tool_start_node/impl/base_tool_start_node.py
+++ /dev/null
@@ -1,66 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: base_start_node.py
- @date:2024/6/3 17:17
- @desc:
-"""
-from typing import Type
-
-from rest_framework import serializers
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.tool_start_node.i_tool_start_node import IToolStartNode
-
-
-class BaseToolStartStepNode(IToolStartNode):
- def save_context(self, details, workflow_manage):
- base_node = self.workflow_manage.get_base_node()
- workflow_variable = {}
- self.context['exception_message'] = details.get('err_message')
- self.status = details.get('status')
- self.err_message = details.get('err_message')
- for key, value in workflow_variable.items():
- workflow_manage.context[key] = value
- for item in details.get('global_fields', []):
- workflow_manage.context[item.get('key')] = item.get('value')
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- pass
-
- def execute(self, **kwargs) -> NodeResult:
- base_node = self.workflow_manage.get_base_node()
- global_value = {}
- params = self.workflow_manage.get_body()
- for item in base_node.properties.get('user_input_field_list', []):
- global_value[item.get('field')] = params.get(item.get('field'))
-
- self.workflow_manage.out_context = {
- item.get('field'): None
- for item in base_node.properties.get('user_output_field_list', [])
- if item.get('default_value', None) is not None
- }
- return NodeResult({}, global_value)
-
- def get_details(self, index: int, **kwargs):
- global_fields = []
- for field in self.node.properties.get('config')['globalFields']:
- key = field['value']
- global_fields.append({
- 'label': field.get('label'),
- 'key': key,
- 'value': self.workflow_manage.context[key] if key in self.workflow_manage.context else ''
- })
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- "question": self.context.get('question'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'err_message': self.err_message,
- 'global_fields': global_fields,
- '': '',
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/tool_workflow_lib_node/__init__.py b/apps/application/flow/step_node/tool_workflow_lib_node/__init__.py
deleted file mode 100644
index d417d531251..00000000000
--- a/apps/application/flow/step_node/tool_workflow_lib_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2026/3/16 13:53
- @desc:
-"""
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/tool_workflow_lib_node/i_tool_workflow_lib_node.py b/apps/application/flow/step_node/tool_workflow_lib_node/i_tool_workflow_lib_node.py
deleted file mode 100644
index 82b73d0904b..00000000000
--- a/apps/application/flow/step_node/tool_workflow_lib_node/i_tool_workflow_lib_node.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎
- @file: i_function_lib_node.py
- @date:2024/8/8 16:21
- @desc:
-"""
-from typing import Type
-
-from django.db import connection
-from django.db.models import QuerySet
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-from common.field.common import ObjectField
-from tools.models.tool import Tool, ToolType
-
-
-class InputField(serializers.Serializer):
- field = serializers.CharField(required=True, label=_('Variable Name'))
- label = serializers.CharField(required=True, label=_('Variable Label'))
- source = serializers.CharField(required=True, label=_('Variable Source'))
- type = serializers.CharField(required=True, label=_('Variable Type'))
- value = ObjectField(required=True, label=_("Variable Value"), model_type_list=[str, list, bool, dict, int, float])
-
-
-class FunctionLibNodeParamsSerializer(serializers.Serializer):
- tool_lib_id = serializers.UUIDField(required=True, label=_('Library ID'))
- input_field_list = InputField(required=True, many=True)
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
- f_lib = QuerySet(Tool).filter(id=self.data.get('tool_lib_id'), tool_type=ToolType.WORKFLOW).first()
- # 归还链接到连接池
- connection.close()
- if f_lib is None:
- raise Exception(_('The function has been deleted'))
-
-
-class IToolWorkflowLibNode(INode):
- type = 'tool-workflow-lib-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return FunctionLibNodeParamsSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, tool_lib_id, input_field_list, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/tool_workflow_lib_node/impl/__init__.py b/apps/application/flow/step_node/tool_workflow_lib_node/impl/__init__.py
deleted file mode 100644
index 0b593554784..00000000000
--- a/apps/application/flow/step_node/tool_workflow_lib_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2026/3/16 13:53
- @desc:
-"""
-from .base_tool_workflow_lib_node import *
diff --git a/apps/application/flow/step_node/tool_workflow_lib_node/impl/base_tool_workflow_lib_node.py b/apps/application/flow/step_node/tool_workflow_lib_node/impl/base_tool_workflow_lib_node.py
deleted file mode 100644
index d158878454e..00000000000
--- a/apps/application/flow/step_node/tool_workflow_lib_node/impl/base_tool_workflow_lib_node.py
+++ /dev/null
@@ -1,245 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: base_tool_workflow_lib_node.py.py
- @date:2026/3/16 13:55
- @desc:
-"""
-
-import time
-from typing import Dict
-
-import uuid_utils.compat as uuid
-from django.db.models import QuerySet
-from django.utils.translation import gettext_lazy as _
-
-from application.flow.common import WorkflowMode, Workflow
-from application.flow.i_step_node import NodeResult, ToolWorkflowPostHandler, INode
-from application.flow.step_node.tool_workflow_lib_node.i_tool_workflow_lib_node import IToolWorkflowLibNode
-from application.models import ChatRecord
-from application.serializers.common import ToolExecute
-from common.database_model_manage.database_model_manage import DatabaseModelManage
-from common.exception.app_exception import ChatException
-from common.handle.impl.response.loop_to_response import LoopToResponse
-from tools.models import ToolWorkflowVersion, Tool
-
-
-def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow, answer: str,
- reasoning_content: str):
- result = node_variable.get('result')
- node.context['application_node_dict'] = node_variable.get('application_node_dict')
- node.context['node_dict'] = node_variable.get('node_dict', {})
- node.context['is_interrupt_exec'] = node_variable.get('is_interrupt_exec')
- node.context['message_tokens'] = result.get('usage', {}).get('prompt_tokens', 0)
- node.context['answer_tokens'] = result.get('usage', {}).get('completion_tokens', 0)
- node.context['answer'] = answer
- node.context['result'] = answer
- node.context['reasoning_content'] = reasoning_content
- node.context['run_time'] = time.time() - node.context['start_time']
- if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
- node.answer_text = answer
-
-
-def get_answer_list(instance, child_node_node_dict, runtime_node_id):
- answer_list = instance.get_record_answer_list()
- for a in answer_list:
- _v = child_node_node_dict.get(a.get('runtime_node_id'))
- if _v:
- a['runtime_node_id'] = runtime_node_id
- a['child_node'] = _v
- return answer_list
-
-
-def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据 (流式)
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点
- @param workflow: 工作流管理器
- """
- workflow_manage_new_instance = node_variable.get('workflow_manage_new_instance')
- node_params = node.node_params
- start_node_id = node_params.get('child_node', {}).get('runtime_node_id')
- child_node_data = node.context.get('child_node_data') or []
- start_node_data = None
- chat_record = None
- child_node = None
- if start_node_id:
- chat_record_id = node_params.get('child_node', {}).get('chat_record_id')
- child_node = node_params.get('child_node', {}).get('child_node')
- start_node_data = node_params.get('node_data')
- chat_record = ChatRecord(id=chat_record_id, answer_text_list=[], answer_text='',
- details=child_node_data)
- instance = workflow_manage_new_instance(start_node_id,
- start_node_data, chat_record, child_node)
- answer = ''
- reasoning_content = ''
- usage = {}
- node_child_node = {}
- is_interrupt_exec = False
- response = instance.stream()
- child_node_node_dict = {}
- for chunk in response:
- response_content = chunk
- content = (response_content.get('content', '') or '')
- runtime_node_id = response_content.get('runtime_node_id', '')
- chat_record_id = response_content.get('chat_record_id', '')
- child_node = response_content.get('child_node')
- node_type = response_content.get('node_type')
- _reasoning_content = (response_content.get('reasoning_content', '') or '')
- if node_type == 'form-node':
- is_interrupt_exec = True
- answer += content
- reasoning_content += _reasoning_content
- node_child_node = {'runtime_node_id': runtime_node_id, 'chat_record_id': chat_record_id,
- 'child_node': child_node}
-
- child_node = chunk.get('child_node')
- runtime_node_id = chunk.get('runtime_node_id', '')
- chat_record_id = chunk.get('chat_record_id', '')
- child_node_node_dict[runtime_node_id] = {
- 'runtime_node_id': runtime_node_id,
- 'chat_record_id': chat_record_id,
- 'child_node': child_node}
- content_chunk = (chunk.get('content', '') or '')
- reasoning_content_chunk = (chunk.get('reasoning_content', '') or '')
- reasoning_content += reasoning_content_chunk
- answer += content_chunk
- yield chunk
- if chunk.get('node_status', "SUCCESS") == 'ERROR':
- is_interrupt_exec = True
- node.status = 500
- node.err_message = chunk.get('content')
- usage = response_content.get('usage', {})
- child_answer_data = get_answer_list(instance, child_node_node_dict, node.runtime_node_id)
- node.context['usage'] = {'usage': usage}
- node.context['child_node'] = node_child_node
- node.context['details'] = instance.get_runtime_details()
- node.context['is_interrupt_exec'] = is_interrupt_exec
- node.context['child_answer_data'] = child_answer_data
- node.context['run_time'] = time.time() - node.context.get("start_time")
- node.extra['input_field_list'] = instance.get_input_field_list()
- node.extra['output_field_list'] = instance.get_output_field_list()
- node.extra['input'] = instance.get_input()
- node.extra['output'] = instance.out_context
- for key, value in instance.out_context.items():
- node.context[key] = value
-
-
-def _is_interrupt_exec(node, node_variable: Dict, workflow_variable: Dict):
- return node.context.get('is_interrupt_exec', False)
-
-
-def valid_function(tool_lib, workspace_id):
- if tool_lib is None:
- raise Exception(_('Tool does not exist'))
- get_authorized_tool = DatabaseModelManage.get_model("get_authorized_tool")
- if tool_lib and tool_lib.workspace_id != workspace_id and get_authorized_tool is not None:
- tool_lib = get_authorized_tool(QuerySet(Tool).filter(id=tool_lib.id), workspace_id).first()
- if tool_lib is None:
- raise Exception(_("Tool does not exist"))
- if not tool_lib.is_active:
- raise Exception(_("Tool is not active"))
-
-
-class BaseToolWorkflowLibNodeNode(IToolWorkflowLibNode):
- def get_parameters(self, input_field_list):
- result = {}
- for input in input_field_list:
- source = input.get('source')
- value = input.get('value')
- if source == 'reference':
- value = self.workflow_manage.get_reference_field(
- value[0],
- value[1:])
- result[input.get('field')] = value
-
- return result
-
- def save_context(self, details, workflow_manage):
- self.context['child_answer_data'] = details.get('child_answer_data')
- self.context['details'] = details.get('details')
- self.extra['input_field_list'] = details.get('input_field_list')
- self.extra['output_field_list'] = details.get('output_field_list')
- self.extra['input'] = details.get('input')
- self.extra['output'] = details.get('output')
- self.context['result'] = details.get('result')
- self.context['exception_message'] = details.get('err_message')
- for key, value in (details.get('output') or {}).items():
- self.context[key] = value
- if self.node_params.get('is_result'):
- self.answer_text = str(details.get('result'))
-
- @staticmethod
- def to_chat_record(record):
- if record is None:
- return None
- return ChatRecord(
- answer_text_list=record.meta.get('answer_text_list'),
- details=record.meta.get('details'),
- answer_text='',
- )
-
- def execute(self, tool_lib_id, input_field_list, **kwargs) -> NodeResult:
- from application.flow.tool_workflow_manage import ToolWorkflowManage
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- tool_workflow_version = QuerySet(ToolWorkflowVersion).filter(tool_id=tool_lib_id).order_by(
- '-create_time')[0:1].first()
- if tool_workflow_version is None:
- raise ChatException(500, _("The tool has not been published. Please use it after publishing."))
- tool_lib = QuerySet(Tool).filter(id=tool_lib_id).first()
- valid_function(tool_lib, workspace_id)
- parameters = self.get_parameters(input_field_list)
- tool_record_id = (self.node_params.get('child_node') or {}).get('chat_record_id') or str(uuid.uuid7())
- took_execute = ToolExecute(tool_lib_id, tool_record_id,
- workspace_id,
- self.workflow_manage.get_source_type(),
- self.workflow_manage.get_source_id(),
- False)
-
- def workflow_manage_new_instance(start_node_id=None,
- start_node_data=None, chat_record=None, child_node=None):
- work_flow_manage = ToolWorkflowManage(
- Workflow.new_instance(tool_workflow_version.work_flow, WorkflowMode.TOOL),
- {
- 'chat_record_id': tool_record_id,
- 'tool_id': tool_lib_id,
- 'stream': True,
- 'workspace_id': workspace_id,
- **parameters},
- ToolWorkflowPostHandler(took_execute, tool_lib_id),
- base_to_response=LoopToResponse(),
- start_node_id=start_node_id,
- start_node_data=start_node_data,
- child_node=child_node,
- chat_record=self.to_chat_record(took_execute.get_record()),
- is_the_task_interrupted=lambda: False)
-
- return work_flow_manage
-
- return NodeResult({'workflow_manage_new_instance': workflow_manage_new_instance},
- {}, _write_context=write_context_stream,
- _is_interrupt=_is_interrupt_exec)
-
- def get_details(self, index: int, **kwargs):
- result = self.context.get('result')
-
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- "result": result,
- "params": self.context.get('params'),
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'status': self.status,
- 'input': self.extra.get('input'),
- 'output': self.extra.get('output'),
- 'input_field_list': self.extra.get('input_field_list'),
- 'output_field_list': self.extra.get('output_field_list'),
- 'details': self.context.get("details"),
- 'child_answer_data': self.context.get("child_answer_data"),
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/variable_aggregation_node/i_variable_aggregation_node.py b/apps/application/flow/step_node/variable_aggregation_node/i_variable_aggregation_node.py
deleted file mode 100644
index 86a38778292..00000000000
--- a/apps/application/flow/step_node/variable_aggregation_node/i_variable_aggregation_node.py
+++ /dev/null
@@ -1,42 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class VariableListSerializer(serializers.Serializer):
- v_id = serializers.CharField(required=True, label=_("Variable id"))
- key = serializers.CharField(required=False, label=_("Key"), allow_null=True, allow_blank=True, )
- variable = serializers.ListField(required=True, label=_("Variable"))
-
-
-class VariableGroupSerializer(serializers.Serializer):
- id = serializers.CharField(required=True, label=_("Group id"))
- field = serializers.CharField(required=True, label=_("group_name"))
- label = serializers.CharField(required=True)
- variable_list = VariableListSerializer(many=True)
-
-
-class VariableAggregationNodeSerializer(serializers.Serializer):
- strategy = serializers.CharField(required=True, label=_("Strategy"))
- group_list = VariableGroupSerializer(many=True)
-
-
-class IVariableAggregation(INode):
- type = 'variable-aggregation-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return VariableAggregationNodeSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, strategy, group_list, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/variable_aggregation_node/impl/base_variable_aggregation_node.py b/apps/application/flow/step_node/variable_aggregation_node/impl/base_variable_aggregation_node.py
deleted file mode 100644
index 341f2e0eab9..00000000000
--- a/apps/application/flow/step_node/variable_aggregation_node/impl/base_variable_aggregation_node.py
+++ /dev/null
@@ -1,98 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎²
- @file: base_variable_aggregation_node.py
- @date:2025/10/23 17:42
- @desc:
-"""
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.variable_aggregation_node.i_variable_aggregation_node import IVariableAggregation
-
-
-def _filter_file_bytes(data):
- """递归过滤掉所有层级的 file_bytes"""
- if isinstance(data, dict):
- return {k: _filter_file_bytes(v) for k, v in data.items() if k != 'file_bytes'}
- elif isinstance(data, list):
- return [_filter_file_bytes(item) for item in data]
- else:
- return data
-
-
-class BaseVariableAggregationNode(IVariableAggregation):
-
- def save_context(self, details, workflow_manage):
- for key, value in details.get('result').items():
- self.context[key] = value
- self.context['result'] = details.get('result')
- self.context['strategy'] = details.get('strategy')
- self.context['group_list'] = details.get('group_list')
- self.context['exception_message'] = details.get('err_message')
-
- def get_first_non_null(self, variable_list):
- for variable in variable_list:
- v = self.workflow_manage.get_reference_field(
- variable.get('variable')[0],
- variable.get('variable')[1:])
- if v is not None and not (isinstance(v, (str, list, dict)) and len(v) == 0):
- return v
- return None
-
- def set_variable_to_array(self, variable_list):
- return [self.workflow_manage.get_reference_field(
- variable.get('variable')[0],
- variable.get('variable')[1:]) for variable in variable_list]
-
- def set_variable_to_dict(self, variable_list):
- return {(variable.get('key') or variable.get('variable')[-1]): self.workflow_manage.get_reference_field(
- variable.get('variable')[0],
- variable.get('variable')[1:]) for variable in variable_list}
-
- def reset_variable(self, variable):
- value = self.workflow_manage.get_reference_field(
- variable.get('variable')[0],
- variable.get('variable')[1:])
- node_id = variable.get('variable')[0]
- node = self.workflow_manage.flow.get_node(node_id)
- return {"value": value, 'node_name': node.properties.get('stepName') if node is not None else node_id,
- 'field': variable.get('variable')[1]}
-
- def reset_group_list(self, group_list):
- result = []
- for g in group_list:
- b = {'label': g.get('label'),
- 'variable_list': [self.reset_variable(variable) for variable in g.get('variable_list')]}
- result.append(b)
- return result
-
- def execute(self, strategy, group_list, **kwargs) -> NodeResult:
- strategy_map = {'first_non_null': self.get_first_non_null,
- 'variable_to_array': self.set_variable_to_array,
- 'variable_to_dict': self.set_variable_to_dict,
- }
-
- # 向下兼容
- if strategy == 'variable_to_json':
- strategy = 'variable_to_array'
-
- result = {item.get('field'): strategy_map[strategy](item.get('variable_list')) for item in group_list}
-
- return NodeResult(
- {'result': result, 'strategy': strategy, 'group_list': self.reset_group_list(group_list), **result}, {})
-
- def get_details(self, index: int, **kwargs):
- result = _filter_file_bytes(self.context.get('result'))
- group_list = _filter_file_bytes(self.context.get('group_list'))
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'result': result,
- 'strategy': self.context.get('strategy'),
- 'group_list': group_list,
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/variable_assign_node/__init__.py b/apps/application/flow/step_node/variable_assign_node/__init__.py
deleted file mode 100644
index 2d231e6066d..00000000000
--- a/apps/application/flow/step_node/variable_assign_node/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .impl import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/variable_assign_node/i_variable_assign_node.py b/apps/application/flow/step_node/variable_assign_node/i_variable_assign_node.py
deleted file mode 100644
index 6652cbe9e9a..00000000000
--- a/apps/application/flow/step_node/variable_assign_node/i_variable_assign_node.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class VariableAssignNodeParamsSerializer(serializers.Serializer):
- variable_list = serializers.ListField(required=True,
- label=_("Reference Field"))
-
-
-class IVariableAssignNode(INode):
- type = 'variable-assign-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return VariableAssignNodeParamsSerializer
-
- def _run(self):
- return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, variable_list, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/variable_assign_node/impl/__init__.py b/apps/application/flow/step_node/variable_assign_node/impl/__init__.py
deleted file mode 100644
index 7585cdd8fe4..00000000000
--- a/apps/application/flow/step_node/variable_assign_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/6/11 17:49
- @desc:
-"""
-from .base_variable_assign_node import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/variable_assign_node/impl/base_variable_assign_node.py b/apps/application/flow/step_node/variable_assign_node/impl/base_variable_assign_node.py
deleted file mode 100644
index b9572805acf..00000000000
--- a/apps/application/flow/step_node/variable_assign_node/impl/base_variable_assign_node.py
+++ /dev/null
@@ -1,125 +0,0 @@
-# coding=utf-8
-import json
-from typing import List
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.variable_assign_node.i_variable_assign_node import IVariableAssignNode
-
-
-class BaseVariableAssignNode(IVariableAssignNode):
- def save_context(self, details, workflow_manage):
- self.context['variable_list'] = details.get('variable_list')
- self.context['result_list'] = details.get('result_list')
- self.context['exception_message'] = details.get('err_message')
-
- def global_evaluation(self, variable, value):
- from application.flow.loop_workflow_manage import LoopWorkflowManage
- if isinstance(self.workflow_manage, LoopWorkflowManage):
- self.workflow_manage.parentWorkflowManage.context[variable['fields'][1]] = value
- else:
- self.workflow_manage.context[variable['fields'][1]] = value
-
- def loop_evaluation(self, variable, value):
- from application.flow.loop_workflow_manage import LoopWorkflowManage
- if isinstance(self.workflow_manage, LoopWorkflowManage):
- self.workflow_manage.get_loop_context()[variable['fields'][1]] = value
-
- def chat_evaluation(self, variable, value):
- from application.flow.loop_workflow_manage import LoopWorkflowManage
- if isinstance(self.workflow_manage, LoopWorkflowManage):
- self.workflow_manage.parentWorkflowManage.chat_context[variable['fields'][1]] = value
- else:
- self.workflow_manage.chat_context[variable['fields'][1]] = value
-
- def out_evaluation(self, variable, value):
- from application.flow.loop_workflow_manage import LoopWorkflowManage
- if isinstance(self.workflow_manage, LoopWorkflowManage):
- self.workflow_manage.parentWorkflowManage.out_context[variable['fields'][1]] = value
- else:
- self.workflow_manage.out_context[variable['fields'][1]] = value
-
- def handle(self, variable, evaluation):
- result = {
- 'name': variable['name'],
- 'input_value': self.get_reference_content(variable['fields']),
- }
- if variable['source'] == 'custom':
- if variable['type'] == 'json':
- if isinstance(variable['value'], dict) or isinstance(variable['value'], list):
- val = variable['value']
- else:
- val = json.loads(variable['value'])
- evaluation(variable, val)
- result['output_value'] = variable['value'] = val
- elif variable['type'] == 'string':
- # 变量解析 例如:{{global.xxx}}
- val = self.workflow_manage.generate_prompt(variable['value'])
- evaluation(variable, val)
- result['output_value'] = val
- else:
- val = variable['value']
- evaluation(variable, val)
- result['output_value'] = val
- elif variable['source'] == 'referencing':
- reference = self.get_reference_content(variable['reference'])
- evaluation(variable, reference)
- result['output_value'] = reference
- else:
- val = None
- evaluation(variable, val)
- result['output_value'] = val
-
- # 获取输入输出值的类型,用于显示在执行详情页面中
- result['input_type'] = type(result.get('input_value')).__name__ if result.get('input_value') is not None else 'null'
- result['output_type'] = type(result.get('output_value')).__name__ if result.get('output_value') is not None else 'null'
-
- return result
-
- def execute(self, variable_list, **kwargs) -> NodeResult:
- result_list = []
- contains_chat_variable = False
- for variable in variable_list:
- if not variable.get('fields'):
- continue
-
- field0 = variable['fields'][0]
- if 'global' == field0:
- result = self.handle(variable, self.global_evaluation)
- result_list.append(result)
- elif 'chat' == field0:
- result = self.handle(variable, self.chat_evaluation)
- result_list.append(result)
- contains_chat_variable = True
- elif 'loop' == field0:
- result = self.handle(variable, self.loop_evaluation)
- result_list.append(result)
- elif 'output' == field0:
- result = self.handle(variable, self.out_evaluation)
- result_list.append(result)
-
- if contains_chat_variable:
- from application.flow.loop_workflow_manage import LoopWorkflowManage
- if isinstance(self.workflow_manage, LoopWorkflowManage):
- self.workflow_manage.parentWorkflowManage.get_chat_info().set_chat_variable(
- self.workflow_manage.parentWorkflowManage.chat_context)
- else:
- self.workflow_manage.get_chat_info().set_chat_variable(self.workflow_manage.chat_context)
- return NodeResult({'variable_list': variable_list, 'result_list': result_list}, {})
-
- def get_reference_content(self, fields: List[str]):
- return self.workflow_manage.get_reference_field(
- fields[0],
- fields[1:]) if fields else None
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'variable_list': self.context.get('variable_list'),
- 'result_list': self.context.get('result_list'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/variable_splitting_node/__init__.py b/apps/application/flow/step_node/variable_splitting_node/__init__.py
deleted file mode 100644
index c93d71e9ed1..00000000000
--- a/apps/application/flow/step_node/variable_splitting_node/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2025/10/13 14:56
- @desc:
-"""
-from .impl import *
diff --git a/apps/application/flow/step_node/variable_splitting_node/i_variable_splitting_node.py b/apps/application/flow/step_node/variable_splitting_node/i_variable_splitting_node.py
deleted file mode 100644
index 39c48f817be..00000000000
--- a/apps/application/flow/step_node/variable_splitting_node/i_variable_splitting_node.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class VariableSplittingNodeParamsSerializer(serializers.Serializer):
- input_variable = serializers.ListField(required=True,
- label=_("input variable"))
-
- variable_list = serializers.ListField(required=True,
- label=_("Split variables"))
-
-
-class IVariableSplittingNode(INode):
- type = 'variable-splitting-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return VariableSplittingNodeParamsSerializer
-
- def _run(self):
- input_variable = self.workflow_manage.get_reference_field(
- self.node_params_serializer.data.get('input_variable')[0],
- self.node_params_serializer.data.get('input_variable')[1:])
- return self.execute(input_variable, self.node_params_serializer.data['variable_list'])
-
- def execute(self, input_variable, variable_list, **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/variable_splitting_node/impl/__init__.py b/apps/application/flow/step_node/variable_splitting_node/impl/__init__.py
deleted file mode 100644
index 1ef0d7ac519..00000000000
--- a/apps/application/flow/step_node/variable_splitting_node/impl/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: __init__.py.py
- @date:2025/10/13 15:01
- @desc:
-"""
-from .base_variable_splitting_node import *
\ No newline at end of file
diff --git a/apps/application/flow/step_node/variable_splitting_node/impl/base_variable_splitting_node.py b/apps/application/flow/step_node/variable_splitting_node/impl/base_variable_splitting_node.py
deleted file mode 100644
index 274604e2328..00000000000
--- a/apps/application/flow/step_node/variable_splitting_node/impl/base_variable_splitting_node.py
+++ /dev/null
@@ -1,80 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: base_variable_splitting_node.py
- @date:2025/10/13 15:02
- @desc:
-"""
-import json
-from jsonpath_ng.ext import parse
-from common.cache.mem_cache import MemCache
-
-from application.flow.i_step_node import NodeResult
-from application.flow.step_node.variable_splitting_node.i_variable_splitting_node import IVariableSplittingNode
-
-jsonpath_expr_cache = MemCache('parse_path', {
- 'TIMEOUT': 3600, # 缓存有效期为 1 小时
- 'OPTIONS': {
- 'MAX_ENTRIES': 1000, # 最多缓存 1000 个条目
- 'CULL_FREQUENCY': 10, # 达到上限时,删除约 1/10 的缓存
- },
-})
-
-def parse_and_cache(path):
- jsonpath_expr = jsonpath_expr_cache.get(path)
- if not jsonpath_expr:
- jsonpath_expr = parse(path)
- jsonpath_expr_cache.set(path, jsonpath_expr)
- return jsonpath_expr
-
-def smart_jsonpath_search(data: dict, path: str):
- """
- 智能JSON Path搜索
- 返回:
- - 单个匹配: 直接返回值
- - 多个匹配: 返回值的列表
- - 无匹配: 返回None
- """
- jsonpath_expr = parse_and_cache(path)
- matches = jsonpath_expr.find(data)
-
- if not matches:
- return None
- elif len(matches) == 1:
- return matches[0].value
- else:
- return [match.value for match in matches]
-
-
-class BaseVariableSplittingNode(IVariableSplittingNode):
- def save_context(self, details, workflow_manage):
- for key, value in details.get('result').items():
- self.context[key] = value
- self.context['result'] = details.get('result')
- self.context['request'] = details.get('request')
- self.context['exception_message'] = details.get('err_message')
-
- def execute(self, input_variable, variable_list, **kwargs) -> NodeResult:
- if isinstance(input_variable, str):
- try:
- input_variable = json.loads(input_variable)
- except Exception:
- pass
-
- self.context['request'] = input_variable
- response = {v['field']: smart_jsonpath_search(input_variable, v['expression']) for v in variable_list}
- return NodeResult({'result': response, **response}, {})
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'type': self.node.type,
- 'request': self.context.get('request'),
- 'result': self.context.get('result'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/step_node/video_understand_step_node/__init__.py b/apps/application/flow/step_node/video_understand_step_node/__init__.py
deleted file mode 100644
index f3feecc9ce2..00000000000
--- a/apps/application/flow/step_node/video_understand_step_node/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .impl import *
diff --git a/apps/application/flow/step_node/video_understand_step_node/i_video_understand_node.py b/apps/application/flow/step_node/video_understand_step_node/i_video_understand_node.py
deleted file mode 100644
index 8d854291686..00000000000
--- a/apps/application/flow/step_node/video_understand_step_node/i_video_understand_node.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# coding=utf-8
-
-from typing import Type
-
-from django.utils.translation import gettext_lazy as _
-from rest_framework import serializers
-
-from application.flow.common import WorkflowMode
-from application.flow.i_step_node import INode, NodeResult
-
-
-class VideoUnderstandNodeSerializer(serializers.Serializer):
- model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
- model_id_type = serializers.CharField(required=False, default='custom', label=_("Model id type"))
- model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True,
- label=_("Reference Field"))
- system = serializers.CharField(required=False, allow_blank=True, allow_null=True,
- label=_("Role Setting"))
- prompt = serializers.CharField(required=True, label=_("Prompt word"))
- # 多轮对话数量
- dialogue_number = serializers.IntegerField(required=True, label=_("Number of multi-round conversations"))
-
- dialogue_type = serializers.CharField(required=True, label=_("Conversation storage type"))
-
- is_result = serializers.BooleanField(required=False,
- label=_('Whether to return content'))
-
- video_list = serializers.ListField(required=False, label=_("video"))
-
- model_params_setting = serializers.JSONField(required=False, default=dict,
- label=_("Model parameter settings"))
- model_setting = serializers.DictField(required=False,
- label='Model settings')
-
-
-class IVideoUnderstandNode(INode):
- type = 'video-understand-node'
- support = [WorkflowMode.APPLICATION, WorkflowMode.APPLICATION_LOOP, WorkflowMode.KNOWLEDGE,
- WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL, WorkflowMode.TOOL_LOOP]
-
- def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
- return VideoUnderstandNodeSerializer
-
- def _run(self):
- res = self.workflow_manage.get_reference_field(self.node_params_serializer.data.get('video_list')[0],
- self.node_params_serializer.data.get('video_list')[1:])
-
- if [WorkflowMode.KNOWLEDGE, WorkflowMode.KNOWLEDGE_LOOP, WorkflowMode.TOOL,
- WorkflowMode.TOOL_LOOP].__contains__(
- self.workflow_manage.flow.workflow_mode):
- return self.execute(video=res, **self.node_params_serializer.data, **self.flow_params_serializer.data,
- **{'history_chat_record': [], 'stream': True, 'chat_id': None, 'chat_record_id': None})
- else:
- return self.execute(video=res, **self.node_params_serializer.data, **self.flow_params_serializer.data)
-
- def execute(self, model_id, system, prompt, dialogue_number, dialogue_type, history_chat_record, stream,
- model_params_setting,
- chat_record_id,
- video,
- model_id_type=None, model_id_reference=None,
- model_setting=None,
- **kwargs) -> NodeResult:
- pass
diff --git a/apps/application/flow/step_node/video_understand_step_node/impl/__init__.py b/apps/application/flow/step_node/video_understand_step_node/impl/__init__.py
deleted file mode 100644
index 555faa26b66..00000000000
--- a/apps/application/flow/step_node/video_understand_step_node/impl/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-# coding=utf-8
-
-from .base_video_understand_node import BaseVideoUnderstandNode
diff --git a/apps/application/flow/step_node/video_understand_step_node/impl/base_video_understand_node.py b/apps/application/flow/step_node/video_understand_step_node/impl/base_video_understand_node.py
deleted file mode 100644
index ea497be27d0..00000000000
--- a/apps/application/flow/step_node/video_understand_step_node/impl/base_video_understand_node.py
+++ /dev/null
@@ -1,335 +0,0 @@
-# coding=utf-8
-
-import time
-from functools import reduce
-from typing import List, Dict
-
-from django.db.models import QuerySet
-from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
-
-from application.flow.i_step_node import NodeResult, INode
-from application.flow.step_node.video_understand_step_node.i_video_understand_node import IVideoUnderstandNode
-from application.flow.tools import Reasoning
-from knowledge.models import File
-from models_provider.tools import get_model_instance_by_model_workspace_id
-
-
-def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow, answer: str,
- reasoning_content: str):
- chat_model = node_variable.get('chat_model')
- message_tokens = node_variable['usage_metadata']['output_tokens'] if 'usage_metadata' in node_variable else 0
- answer_tokens = chat_model.get_num_tokens(answer)
- node.context['message_tokens'] = message_tokens
- node.context['answer_tokens'] = answer_tokens
- node.context['answer'] = answer
- node.context['history_message'] = node_variable['history_message']
- node.context['question'] = node_variable['question']
- node.context['run_time'] = time.time() - node.context['start_time']
- node.context['reasoning_content'] = reasoning_content
- if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
- node.answer_text = answer
-
-
-def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据 (流式)
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result')
- answer = ''
- reasoning_content = ''
- model_setting = node.context.get('model_setting',
- {'reasoning_content_enable': False, 'reasoning_content_end': '',
- 'reasoning_content_start': ''})
- reasoning = Reasoning(model_setting.get('reasoning_content_start', ''),
- model_setting.get('reasoning_content_end', ''))
- response_reasoning_content = False
-
- for chunk in response:
- if workflow.is_the_task_interrupted():
- break
-
- # 处理 reasoning content
- reasoning_chunk = reasoning.get_reasoning_content(chunk)
- content_chunk = reasoning_chunk.get('content')
- if 'reasoning_content' in chunk.additional_kwargs:
- response_reasoning_content = True
- reasoning_content_chunk = chunk.additional_kwargs.get('reasoning_content', '')
- else:
- reasoning_content_chunk = reasoning_chunk.get('reasoning_content')
-
- answer += content_chunk
- if reasoning_content_chunk is None:
- reasoning_content_chunk = ''
- reasoning_content += reasoning_content_chunk
-
- # 处理 chunk.content 为 list 的情况
- if isinstance(chunk.content, list):
- for chunk_item in chunk.content:
- text = chunk_item.get("text", "")
- yield {'content': text,
- 'reasoning_content': reasoning_content_chunk if model_setting.get('reasoning_content_enable',
- False) else ''}
- else:
- text = chunk.content or ""
- yield {'content': text,
- 'reasoning_content': reasoning_content_chunk if model_setting.get('reasoning_content_enable',
- False) else ''}
-
- reasoning_chunk = reasoning.get_end_reasoning_content()
- answer += reasoning_chunk.get('content')
- reasoning_content_chunk = ""
- if not response_reasoning_content:
- reasoning_content_chunk = reasoning_chunk.get(
- 'reasoning_content')
- yield {'content': reasoning_chunk.get('content'),
- 'reasoning_content': reasoning_content_chunk if model_setting.get('reasoning_content_enable',
- False) else ''}
- _write_context(node_variable, workflow_variable, node, workflow, answer, reasoning_content)
-
-
-def write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
- """
- 写入上下文数据
- @param node_variable: 节点数据
- @param workflow_variable: 全局数据
- @param node: 节点实例对象
- @param workflow: 工作流管理器
- """
- response = node_variable.get('result')
- model_setting = node.context.get('model_setting',
- {'reasoning_content_enable': False, 'reasoning_content_end': '',
- 'reasoning_content_start': ''})
- reasoning = Reasoning(model_setting.get('reasoning_content_start'), model_setting.get('reasoning_content_end'))
- reasoning_result = reasoning.get_reasoning_content(response)
- reasoning_result_end = reasoning.get_end_reasoning_content()
- content = reasoning_result.get('content') + reasoning_result_end.get('content')
- meta = {**response.response_metadata, **response.additional_kwargs}
- if 'reasoning_content' in meta:
- reasoning_content = (meta.get('reasoning_content', '') or '')
- else:
- reasoning_content = (reasoning_result.get('reasoning_content') or '') + (
- reasoning_result_end.get('reasoning_content') or '')
- _write_context(node_variable, workflow_variable, node, workflow, content, reasoning_content)
-
-
-def file_id_to_base64(file_id: str, video_model):
- file = QuerySet(File).filter(id=file_id).first()
- file_bytes = file.get_bytes()
- url = video_model.upload_file_and_get_url(file_bytes, file.file_name)
- return url
-
-
-class BaseVideoUnderstandNode(IVideoUnderstandNode):
- def save_context(self, details, workflow_manage):
- self.context['answer'] = details.get('answer')
- self.context['question'] = details.get('question')
- self.context['exception_message'] = details.get('err_message')
- if self.node_params.get('is_result', False):
- self.answer_text = details.get('answer')
-
- def execute(self, model_id, system, prompt, dialogue_number, dialogue_type, history_chat_record, stream,
- model_params_setting,
- chat_record_id,
- video,
- model_id_type=None, model_id_reference=None,
- model_setting=None,
- **kwargs) -> NodeResult:
- # 处理引用类型
- if model_id_type == 'reference' and model_id_reference:
- reference_data = self.workflow_manage.get_reference_field(
- model_id_reference[0],
- model_id_reference[1:],
- )
- if reference_data and isinstance(reference_data, dict):
- model_id = reference_data.get('model_id', model_id)
- model_params_setting = reference_data.get('model_params_setting')
-
- from django.utils.translation import gettext_lazy as _
-
- if model_id is None or model_id == '':
- raise Exception(_('Model is not allowed to be empty'))
-
- workspace_id = self.workflow_manage.get_body().get('workspace_id')
- if model_setting is None:
- model_setting = {'reasoning_content_enable': False, 'reasoning_content_end': '',
- 'reasoning_content_start': ''}
- self.context['model_setting'] = model_setting
- video_model = get_model_instance_by_model_workspace_id(model_id, workspace_id,
- **(model_params_setting or {}))
- # 执行详情中的历史消息不需要图片内容
- history_message = self.get_history_message_for_details(history_chat_record, dialogue_number)
- self.context['history_message'] = history_message
- system = self.workflow_manage.generate_prompt(system)
- self.context['system'] = system
- question = self.generate_prompt_question(prompt)
- self.context['question'] = question.content
- # 生成消息列表, 真实的history_message
- message_list = self.generate_message_list(video_model, system, prompt,
- self.get_history_message(history_chat_record, dialogue_number,
- video_model), video)
- self.context['message_list'] = message_list
- self.generate_context_video(video)
- self.context['dialogue_type'] = dialogue_type
- if stream:
- r = video_model.stream(message_list)
- return NodeResult({'result': r, 'chat_model': video_model, 'message_list': message_list,
- 'history_message': history_message, 'question': question.content}, {},
- _write_context=write_context_stream)
- else:
- r = video_model.invoke(message_list)
- return NodeResult({'result': r, 'chat_model': video_model, 'message_list': message_list,
- 'history_message': history_message, 'question': question.content}, {},
- _write_context=write_context)
-
- def generate_context_video(self, video):
- if isinstance(video, str) and video.startswith('http'):
- self.context['video_list'] = [{'url': video}]
- elif video is not None and len(video) > 0:
- self.context['video_list'] = video
-
- def get_history_message_for_details(self, history_chat_record, dialogue_number):
- start_index = len(history_chat_record) - dialogue_number
- history_message = reduce(lambda x, y: [*x, *y], [
- [self.generate_history_human_message_for_details(history_chat_record[index]),
- self.generate_history_ai_message(history_chat_record[index])]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))], [])
- return history_message
-
- def generate_history_ai_message(self, chat_record):
- for val in chat_record.details.values():
- if self.node.id == val['node_id'] and 'video_list' in val:
- if val['dialogue_type'] == 'WORKFLOW':
- return chat_record.get_ai_message()
- return AIMessage(content=val.get('answer') or val.get('err_message') or '')
- return chat_record.get_ai_message()
-
- def generate_history_human_message_for_details(self, chat_record):
- for data in chat_record.details.values():
- if self.node.id == data['node_id'] and 'video_list' in data:
- video_list = data['video_list'] or []
- # 增加对 None 和空列表的检查
- if not video_list or len(video_list) == 0 or data['dialogue_type'] == 'WORKFLOW':
- return HumanMessage(content=chat_record.problem_text)
- file_id_list = []
- url_list = []
- for image in video_list:
- if 'file_id' in image:
- file_id_list.append(image.get('file_id'))
- elif 'url' in image:
- url_list.append(image.get('url'))
- return HumanMessage(content=[
- {'type': 'text', 'text': data['question']},
- *[{'type': 'video_url', 'video_url': {'url': f'./oss/file/{file_id}'}} for file_id in file_id_list],
- *[{'type': 'video_url', 'video_url': {'url': url}} for url in url_list],
- ])
- return HumanMessage(content=chat_record.problem_text)
-
- def get_history_message(self, history_chat_record, dialogue_number, video_model):
- start_index = len(history_chat_record) - dialogue_number
- history_message = reduce(lambda x, y: [*x, *y], [
- [self.generate_history_human_message(history_chat_record[index], video_model),
- self.generate_history_ai_message(history_chat_record[index])]
- for index in
- range(start_index if start_index > 0 else 0, len(history_chat_record))], [])
- return history_message
-
- def generate_history_human_message(self, chat_record, video_model):
-
- for data in chat_record.details.values():
- if self.node.id == data['node_id'] and 'video_list' in data:
- video_list = data['video_list'] or []
- if video_list is None or len(video_list) == 0 or data['dialogue_type'] == 'WORKFLOW':
- return HumanMessage(content=chat_record.problem_text)
- file_id_list = []
- url_list = []
- for image in video_list:
- if 'file_id' in image:
- file_id_list.append(image.get('file_id'))
- elif 'url' in image:
- url_list.append(image.get('url'))
- video_base64_list = [file_id_to_base64(video.get('file_id'), video_model) for video in video_list]
- return HumanMessage(
- content=[
- {'type': 'text', 'text': data['question']},
- *[{'type': 'video_url',
- 'video_url': {'url': f'{base64_video}'}} for
- base64_video in video_base64_list]
- ])
- return HumanMessage(content=chat_record.problem_text)
-
- def generate_prompt_question(self, prompt):
- return HumanMessage(self.workflow_manage.generate_prompt(prompt))
-
- def _process_videos(self, image, video_model):
- videos = []
- if isinstance(image, str) and image.startswith('http'):
- videos.append({'type': 'video_url', 'video_url': {'url': image}})
- elif image is not None and len(image) > 0:
- for img in image:
- if 'file_id' in img:
- file_id = img['file_id']
- file = QuerySet(File).filter(id=file_id).first()
- url = video_model.upload_file_and_get_url(file.get_bytes(), file.file_name)
- videos.append(
- {'type': 'video_url', 'video_url': {'url': url}})
- elif 'url' in img and img['url'].startswith('http'):
- videos.append(
- {'type': 'video_url', 'video_url': {'url': img['url']}})
- return videos
-
- def generate_message_list(self, video_model, system: str, prompt: str, history_message, video):
- prompt_text = self.workflow_manage.generate_prompt(prompt)
- videos = self._process_videos(video, video_model)
-
- if videos:
- messages = [HumanMessage(content=[{'type': 'text', 'text': prompt_text}, *videos])]
- else:
- messages = [HumanMessage(prompt_text)]
-
- if system is not None and len(system) > 0:
- return [
- SystemMessage(system),
- *history_message,
- *messages
- ]
- else:
- return [
- *history_message,
- *messages
- ]
-
- @staticmethod
- def reset_message_list(message_list: List[BaseMessage], answer_text):
- result = [{'role': 'user' if isinstance(message, HumanMessage) else 'ai', 'content': message.content} for
- message
- in
- message_list]
- result.append({'role': 'ai', 'content': answer_text})
- return result
-
- def get_details(self, index: int, **kwargs):
- return {
- 'name': self.node.properties.get('stepName'),
- "index": index,
- 'run_time': self.context.get('run_time'),
- 'system': self.context.get('system'),
- 'history_message': [{'content': message.content, 'role': message.type} for message in
- (self.context.get('history_message') if self.context.get(
- 'history_message') is not None else [])],
- 'question': self.context.get('question'),
- 'answer': self.context.get('answer'),
- 'reasoning_content': self.context.get('reasoning_content'),
- 'type': self.node.type,
- 'message_tokens': self.context.get('message_tokens'),
- 'answer_tokens': self.context.get('answer_tokens'),
- 'status': self.status,
- 'err_message': self.err_message,
- 'video_list': self.context.get('video_list'),
- 'dialogue_type': self.context.get('dialogue_type'),
- 'enableException': self.node.properties.get('enableException'),
- }
diff --git a/apps/application/flow/tool_loop_workflow_manage.py b/apps/application/flow/tool_loop_workflow_manage.py
deleted file mode 100644
index 9fc2425f014..00000000000
--- a/apps/application/flow/tool_loop_workflow_manage.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: workflow_manage.py
- @date:2024/1/9 17:40
- @desc:
-"""
-from application.flow.i_step_node import ToolFlowParamsSerializer
-from application.flow.loop_workflow_manage import LoopWorkflowManage
-
-
-class ToolLoopWorkflowManage(LoopWorkflowManage):
- def get_params_serializer_class(self):
- return ToolFlowParamsSerializer
-
- def get_source_type(self):
- return "TOOL"
-
- def get_source_id(self):
- return self.params.get('tool_id')
diff --git a/apps/application/flow/tool_workflow_manage.py b/apps/application/flow/tool_workflow_manage.py
deleted file mode 100644
index be63ca45e12..00000000000
--- a/apps/application/flow/tool_workflow_manage.py
+++ /dev/null
@@ -1,88 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: tool_workflow_manage.py
- @date:2026/3/12 15:17
- @desc:
-"""
-import time
-from concurrent.futures import ThreadPoolExecutor
-
-from django.db import close_old_connections
-from django.utils.translation import get_language
-
-from application.flow.common import Workflow
-from application.flow.i_step_node import WorkFlowPostHandler, ToolFlowParamsSerializer
-from application.flow.workflow_manage import WorkflowManage
-from common.handle.base_to_response import BaseToResponse
-from common.handle.impl.response.system_to_response import SystemToResponse
-
-executor = ThreadPoolExecutor(max_workers=200)
-
-
-class ToolWorkflowManage(WorkflowManage):
- def __init__(self, flow: Workflow, params, work_flow_post_handler: WorkFlowPostHandler,
- base_to_response: BaseToResponse = SystemToResponse(), form_data=None,
- start_node_id=None,
- start_node_data=None, chat_record=None, child_node=None, is_the_task_interrupted=lambda: False):
- super().__init__(flow, params, work_flow_post_handler, base_to_response, form_data, None, None, None,
- None, None, start_node_id, start_node_data, chat_record, child_node, is_the_task_interrupted)
- self.out_context = {}
-
- def get_params_serializer_class(self):
- return ToolFlowParamsSerializer
-
- def run(self):
- self.context['start_time'] = time.time()
- close_old_connections()
- language = get_language()
- if self.params.get('stream'):
- return self.run_stream(self.start_node, None, language)
- return self.run_block(language)
-
- def stream(self):
- close_old_connections()
- language = get_language()
- self.run_chain_async(self.start_node, None, language)
- return self.await_result(is_cleanup=False)
-
- def get_start_node(self):
- return self.flow.get_node('tool-start-node')
-
- def get_base_node(self):
- """
- 获取基础节点
- @return:
- """
- return self.flow.get_node('tool-base-node')
-
- def get_input_field_list(self):
- """
- 获取输入字段列表
- @return: 输入字段配置
- """
- base_node = self.get_base_node()
- return base_node.properties.get("user_input_field_list") or []
-
- def get_output_field_list(self):
- """
- 获取输出字段列表配置
- @return: 输出字段列表配置
- """
- base_node = self.get_base_node()
- return base_node.properties.get("user_output_field_list") or []
-
- def get_input(self):
- """
- 获取用户输入
- @return: 用户输入
- """
- input_field_list = self.get_input_field_list()
- return {f.get('field'): self.params.get(f.get('field')) for f in input_field_list}
-
- def get_source_type(self):
- return "TOOL"
-
- def get_source_id(self):
- return self.params.get('tool_id')
diff --git a/apps/application/flow/tools.py b/apps/application/flow/tools.py
deleted file mode 100644
index 7ee3bd7b1b5..00000000000
--- a/apps/application/flow/tools.py
+++ /dev/null
@@ -1,1100 +0,0 @@
-# coding=utf-8
-"""
-@project: maxkb
-@Author:虎
-@file: utils.py
-@date:2024/6/6 15:15
-@desc:
-"""
-
-import asyncio
-import io
-import json
-import os
-import queue
-import re
-import shutil
-import threading
-import zipfile
-from functools import reduce
-from typing import Iterator
-
-# ---------------------------------------------------------------------------
-# Fix: qwen's OpenAI-compatible streaming sends id='' (empty string) for
-# intermediate tool_call_chunks while only the first chunk carries the real
-# id ('call_xxx...'). langchain-core's merge_lists treats '' != 'call_xxx' as
-# an ID conflict and _appends_ instead of merging → the accumulated AIMessage
-# ends up with two separate tool_calls (one with empty args, one with empty
-# id) instead of one correct entry. This causes the Qwen API to reject the
-# next request with "function.arguments must be in JSON format".
-#
-# Patch: normalise id='' → None for items that have an 'index' key
-# (i.e. tool_call_chunk dicts). merge_lists treats None as "no id" and will
-# merge with any existing entry, keeping the real id from the first chunk.
-# ---------------------------------------------------------------------------
-import langchain_core.messages.ai as _lc_ai_module
-import uuid_utils.compat as uuid
-from asgiref.sync import sync_to_async
-from common.result import result
-from common.utils.logger import maxkb_logger
-from common.utils.tool_code import ToolExecutor
-from deepagents import create_deep_agent
-from django.db.models import OuterRef, QuerySet, Subquery
-from django.http import StreamingHttpResponse
-from knowledge.models import File
-from knowledge.models.knowledge_action import State
-from langchain_core.messages import AIMessageChunk, BaseMessage, BaseMessageChunk, ToolMessage
-from langchain_core.tools import StructuredTool
-from langchain_core.utils._merge import merge_lists as _original_merge_lists
-from langchain_mcp_adapters.client import MultiServerMCPClient
-from langgraph.checkpoint.memory import MemorySaver
-from maxkb.const import CONFIG
-from pydantic import Field, create_model
-from tools.models import Tool, ToolRecord, ToolScope, ToolType, ToolWorkflowVersion
-
-from application.flow.backend.sandbox_shell import SandboxShellBackend
-from application.flow.common import Workflow, WorkflowMode
-from application.flow.i_step_node import ToolWorkflowPostHandler, WorkFlowPostHandler
-from application.serializers.common import ToolExecute
-
-
-def _merge_lists_normalize_empty_tool_chunk_ids(left, *others):
- """Wrapper around merge_lists that normalises empty-string IDs to None in
- tool_call_chunk items (those with an 'index' key) so that qwen streaming
- chunks with id='' are merged correctly by index."""
-
- def _norm(lst):
- if lst is None:
- return lst
- result = []
- for item in lst:
- if isinstance(item, dict) and "index" in item and item.get("id") == "":
- item = {**item, "id": None}
- result.append(item)
- return result
-
- return _original_merge_lists(
- _norm(left),
- *[_norm(o) for o in others],
- )
-
-
-# Replace the module-level reference used by add_ai_message_chunks in ai.py
-_lc_ai_module.merge_lists = _merge_lists_normalize_empty_tool_chunk_ids
-
-
-class Reasoning:
- def __init__(self, reasoning_content_start, reasoning_content_end):
- self.content = ""
- self.reasoning_content = ""
- self.all_content = ""
- self.reasoning_content_start_tag = reasoning_content_start
- self.reasoning_content_end_tag = reasoning_content_end
- self.reasoning_content_start_tag_len = (
- len(reasoning_content_start) if reasoning_content_start is not None else 0
- )
- self.reasoning_content_end_tag_len = len(reasoning_content_end) if reasoning_content_end is not None else 0
- self.reasoning_content_end_tag_prefix = (
- reasoning_content_end[0] if self.reasoning_content_end_tag_len > 0 else ""
- )
- self.reasoning_content_is_start = False
- self.reasoning_content_is_end = False
- self.reasoning_content_chunk = ""
-
- def get_end_reasoning_content(self):
- if not self.reasoning_content_is_start and not self.reasoning_content_is_end:
- r = {"content": self.all_content, "reasoning_content": ""}
- self.reasoning_content_chunk = ""
- return r
- if self.reasoning_content_is_start and not self.reasoning_content_is_end:
- r = {"content": "", "reasoning_content": self.reasoning_content_chunk}
- self.reasoning_content_chunk = ""
- return r
- return {"content": "", "reasoning_content": ""}
-
- def _normalize_content(self, content):
- """将不同类型的内容统一转换为字符串"""
- if isinstance(content, str):
- return content
- elif isinstance(content, list):
- # 处理包含多种内容类型的列表
- normalized_parts = []
- for item in content:
- if isinstance(item, dict):
- if item.get("type") == "text":
- normalized_parts.append(item.get("text", ""))
- return "".join(normalized_parts)
- else:
- return str(content)
-
- def get_reasoning_content(self, chunk):
- # 如果没有开始思考过程标签那么就全是结果
- if self.reasoning_content_start_tag is None or len(self.reasoning_content_start_tag) == 0:
- self.content += chunk.content
- return {"content": chunk.content, "reasoning_content": ""}
- # 如果没有结束思考过程标签那么就全部是思考过程
- if self.reasoning_content_end_tag is None or len(self.reasoning_content_end_tag) == 0:
- return {"content": "", "reasoning_content": chunk.content}
- chunk.content = self._normalize_content(chunk.content)
- self.all_content += chunk.content
- if not self.reasoning_content_is_start and len(self.all_content) >= self.reasoning_content_start_tag_len:
- if self.all_content.startswith(self.reasoning_content_start_tag):
- self.reasoning_content_is_start = True
- self.reasoning_content_chunk = self.all_content[self.reasoning_content_start_tag_len :]
- else:
- if not self.reasoning_content_is_end:
- self.reasoning_content_is_end = True
- self.content += self.all_content
- return {
- "content": self.all_content,
- "reasoning_content": chunk.additional_kwargs.get("reasoning_content", "")
- if chunk.additional_kwargs
- else "",
- }
- else:
- if self.reasoning_content_is_start:
- self.reasoning_content_chunk += chunk.content
- reasoning_content_end_tag_prefix_index = self.reasoning_content_chunk.find(
- self.reasoning_content_end_tag_prefix
- )
- if self.reasoning_content_is_end:
- self.content += chunk.content
- return {
- "content": chunk.content,
- "reasoning_content": chunk.additional_kwargs.get("reasoning_content", "")
- if chunk.additional_kwargs
- else "",
- }
- # 是否包含结束
- if reasoning_content_end_tag_prefix_index > -1:
- if (
- len(self.reasoning_content_chunk) - reasoning_content_end_tag_prefix_index
- >= self.reasoning_content_end_tag_len
- ):
- reasoning_content_end_tag_index = self.reasoning_content_chunk.find(self.reasoning_content_end_tag)
- if reasoning_content_end_tag_index > -1:
- reasoning_content_chunk = self.reasoning_content_chunk[0:reasoning_content_end_tag_index]
- content_chunk = self.reasoning_content_chunk[
- reasoning_content_end_tag_index + self.reasoning_content_end_tag_len :
- ]
- self.reasoning_content += reasoning_content_chunk
- self.content += content_chunk
- self.reasoning_content_chunk = ""
- self.reasoning_content_is_end = True
- return {"content": content_chunk, "reasoning_content": reasoning_content_chunk}
- else:
- reasoning_content_chunk = self.reasoning_content_chunk[
- 0 : reasoning_content_end_tag_prefix_index + 1
- ]
- self.reasoning_content_chunk = self.reasoning_content_chunk.replace(reasoning_content_chunk, "")
- self.reasoning_content += reasoning_content_chunk
- return {"content": "", "reasoning_content": reasoning_content_chunk}
- else:
- return {"content": "", "reasoning_content": ""}
-
- else:
- if self.reasoning_content_is_end:
- self.content += chunk.content
- return {
- "content": chunk.content,
- "reasoning_content": chunk.additional_kwargs.get("reasoning_content", "")
- if chunk.additional_kwargs
- else "",
- }
- else:
- # aaa
- result = {"content": "", "reasoning_content": self.reasoning_content_chunk}
- self.reasoning_content += self.reasoning_content_chunk
- self.reasoning_content_chunk = ""
- return result
-
-
-def event_content(chat_id, chat_record_id, response, workflow, write_context, post_handler: WorkFlowPostHandler):
- """
- 用于处理流式输出
- @param chat_id: 会话id
- @param chat_record_id: 对话记录id
- @param response: 响应数据
- @param workflow: 工作流管理器
- @param write_context 写入节点上下文
- @param post_handler: 后置处理器
- """
- answer = ""
- try:
- for chunk in response:
- answer += chunk.content
- yield (
- "data: "
- + json.dumps(
- {
- "chat_id": str(chat_id),
- "id": str(chat_record_id),
- "operate": True,
- "content": chunk.content,
- "is_end": False,
- },
- ensure_ascii=False,
- )
- + "\n\n"
- )
- write_context(answer, 200)
- post_handler.handler(chat_id, chat_record_id, answer, workflow)
- yield (
- "data: "
- + json.dumps(
- {"chat_id": str(chat_id), "id": str(chat_record_id), "operate": True, "content": "", "is_end": True},
- ensure_ascii=False,
- )
- + "\n\n"
- )
- except Exception as e:
- answer = str(e)
- write_context(answer, 500)
- post_handler.handler(chat_id, chat_record_id, answer, workflow)
- yield (
- "data: "
- + json.dumps(
- {
- "chat_id": str(chat_id),
- "id": str(chat_record_id),
- "operate": True,
- "content": answer,
- "is_end": True,
- },
- ensure_ascii=False,
- )
- + "\n\n"
- )
-
-
-def to_stream_response(
- chat_id, chat_record_id, response: Iterator[BaseMessageChunk], workflow, write_context, post_handler
-):
- """
- 将结果转换为服务流输出
- @param chat_id: 会话id
- @param chat_record_id: 对话记录id
- @param response: 响应数据
- @param workflow: 工作流管理器
- @param write_context 写入节点上下文
- @param post_handler: 后置处理器
- @return: 响应
- """
- r = StreamingHttpResponse(
- streaming_content=event_content(chat_id, chat_record_id, response, workflow, write_context, post_handler),
- content_type="text/event-stream;charset=utf-8",
- charset="utf-8",
- )
-
- r["Cache-Control"] = "no-cache"
- return r
-
-
-def to_response(
- chat_id, chat_record_id, response: BaseMessage, workflow, write_context, post_handler: WorkFlowPostHandler
-):
- """
- 将结果转换为服务输出
-
- @param chat_id: 会话id
- @param chat_record_id: 对话记录id
- @param response: 响应数据
- @param workflow: 工作流管理器
- @param write_context 写入节点上下文
- @param post_handler: 后置处理器
- @return: 响应
- """
- answer = response.content
- write_context(answer)
- post_handler.handler(chat_id, chat_record_id, answer, workflow)
- return result.success(
- {"chat_id": str(chat_id), "id": str(chat_record_id), "operate": True, "content": answer, "is_end": True}
- )
-
-
-def to_response_simple(chat_id, chat_record_id, response: BaseMessage, workflow, post_handler: WorkFlowPostHandler):
- answer = response.content
- post_handler.handler(chat_id, chat_record_id, answer, workflow)
- return result.success(
- {"chat_id": str(chat_id), "id": str(chat_record_id), "operate": True, "content": answer, "is_end": True}
- )
-
-
-def to_stream_response_simple(stream_event):
- r = StreamingHttpResponse(
- streaming_content=stream_event, content_type="text/event-stream;charset=utf-8", charset="utf-8"
- )
-
- r["Cache-Control"] = "no-cache"
- return r
-
-
-def generate_tool_message_complete(icon, name, input_content, output_content):
- """生成包含输入和输出的工具消息模版"""
- # 确保输入内容是字符串,如果不是则尝试转换为 JSON 字符串
- if not isinstance(input_content, str):
- input_content = json.dumps(input_content, ensure_ascii=False)
- # 格式化输出
- if not isinstance(output_content, str):
- output_content = json.dumps(output_content, ensure_ascii=False)
- content = {
- "icon": icon,
- "title": name,
- "type": "simple-tool-calls",
- "content": {"input": input_content, "output": output_content},
- }
- return f"{json.dumps(content, ensure_ascii=False)}"
-
-
-# 全局单例事件循环
-_global_loop = None
-_loop_thread = None
-_loop_lock = threading.Lock()
-
-
-def get_global_loop():
- """获取全局共享的事件循环"""
- global _global_loop, _loop_thread
-
- with _loop_lock:
- if _global_loop is None:
- _global_loop = asyncio.new_event_loop()
-
- def run_forever():
- asyncio.set_event_loop(_global_loop)
- _global_loop.run_forever()
-
- _loop_thread = threading.Thread(target=run_forever, daemon=True, name="GlobalAsyncLoop")
- _loop_thread.start()
-
- return _global_loop
-
-
-def _extract_tool_id(raw_id):
- """从 raw_id 中提取最后一个符合 call_... 模式的 id,若无匹配则返回原值或 None"""
- if not raw_id:
- return None
- if not isinstance(raw_id, str):
- raw_id = str(raw_id)
-
- s = raw_id
- prefix = "call_"
- positions = [m.start() for m in re.finditer(re.escape(prefix), s)]
- if not positions:
- return raw_id
-
- # 取最后一个前缀位置,截到下一个前缀或结尾
- start = positions[-1]
- end = len(s)
- for pos in positions:
- if pos > start:
- end = pos
- break
-
- tool_id = s[start:end]
- return tool_id or raw_id
-
-
-async def _initialize_skills(mcp_servers, temp_dir):
- skills_dir = os.path.join(temp_dir, "skills")
- mcp_config = json.loads(mcp_servers)
- if "skills" in mcp_config:
- skill_file_items = mcp_config.pop("skills")
- for skill_file in skill_file_items:
- # 使用 sync_to_async 包装 ORM 查询
- file = await sync_to_async(lambda: QuerySet(File).filter(id=skill_file["file_id"]).first())()
- if not file:
- continue
- # get_bytes 可能也涉及 IO,也用 sync_to_async 包装
- file_bytes = await sync_to_async(file.get_bytes)()
- params = skill_file.get("params", {})
- with zipfile.ZipFile(io.BytesIO(file_bytes), "r") as zip_ref:
- members = [m for m in zip_ref.namelist() if not m.startswith("__MACOSX/") and "__MACOSX" not in m]
- for member in members:
- if ".." in member or member.startswith("/"):
- raise ValueError(f"非法路径: {member}")
- zip_ref.extractall(skills_dir, members=members)
-
- # 获取技能解压后的顶级目录名
- top_level_dirs = set()
- for member in members:
- parts = member.split("/")
- if parts[0]:
- top_level_dirs.add(parts[0])
-
- # 将 params 写入每个顶级目录下的 .env 文件
- if params:
- env_lines = []
- for key, value in params.items():
- # 对含空格或特殊字符的值加引号
- env_lines.append(f"{key}={value}")
- env_content = "\n".join(env_lines) + "\n"
- for top_dir in top_level_dirs:
- env_path = os.path.join(skills_dir, top_dir, ".env")
- with open(env_path, "w", encoding="utf-8") as f:
- f.write(env_content)
-
- os.system("chmod -R g+rx " + temp_dir) # 确保技能目录可访问
-
- client = MultiServerMCPClient(mcp_config)
-
- return client
-
-
-async def _yield_mcp_response(
- chat_model,
- system_prompt,
- message_list,
- mcp_servers,
- mcp_output_enable=True,
- tool_init_params={},
- source_id=None,
- source_type=None,
- temp_dir=None,
- chat_id=None,
- extra_tools=None,
-):
- try:
- checkpointer = MemorySaver()
- client = await _initialize_skills(mcp_servers, temp_dir)
- tools = await client.get_tools()
- for tool in tools:
- tool.handle_tool_error = True
- if extra_tools:
- for tool in extra_tools:
- tools.append(tool)
-
- agent = create_deep_agent(
- model=chat_model,
- backend=SandboxShellBackend(root_dir=temp_dir, virtual_mode=True),
- skills=["/skills"],
- tools=tools,
- system_prompt=system_prompt,
- interrupt_on={"write_file": False, "read_file": False, "edit_file": False},
- checkpointer=checkpointer,
- )
- recursion_limit = int(CONFIG.get("LANGCHAIN_GRAPH_RECURSION_LIMIT", "100"))
- response = agent.astream(
- {"messages": message_list},
- config={"recursion_limit": recursion_limit, "configurable": {"thread_id": chat_id}},
- stream_mode="messages",
- )
-
- tool_calls_info = {} # tool_id -> {'name': ..., 'input': ...}
- # key(index/id) -> {'id': ..., 'name': ..., 'arguments': ...}
- _tool_fragments = {}
-
- def _merge_arguments(entry, part_args):
- if not isinstance(part_args, str):
- try:
- part_args = json.dumps(part_args, ensure_ascii=False)
- except Exception:
- part_args = str(part_args) if part_args else ""
- if not part_args:
- return
-
- # Some providers first emit placeholder args like "{}" and then
- # stream the real JSON fragments via later chunks. Prefer fragments.
- if entry["arguments"] in ("{}", "[]") and part_args.startswith("{"):
- entry["arguments"] = part_args
- return
-
- if entry["arguments"]:
- try:
- existing_obj = json.loads(entry["arguments"])
- new_obj = json.loads(part_args)
- if isinstance(existing_obj, dict) and isinstance(new_obj, dict):
- merged = {**existing_obj, **new_obj}
- entry["arguments"] = json.dumps(merged, ensure_ascii=False)
- else:
- entry["arguments"] += part_args
- except (json.JSONDecodeError, ValueError):
- entry["arguments"] += part_args
- else:
- entry["arguments"] = part_args
-
- def _get_fragment_key(idx, raw_id):
- if idx is not None:
- return f"idx:{idx}"
- if raw_id and str(raw_id).strip():
- return f"id:{_extract_tool_id(str(raw_id).strip())}"
- return None
-
- def _upsert_fragment(key, raw_id, func_name, part_args):
- if key is None:
- return
- entry = _tool_fragments.setdefault(key, {"id": "", "name": "", "arguments": ""})
-
- if raw_id and str(raw_id).strip():
- new_id = str(raw_id).strip()
- if entry.get("completed") and entry.get("id") and entry["id"] != new_id:
- maxkb_logger.debug(f"Resetting completed fragment {key}: old ID {entry['id']} -> new ID {new_id}")
- entry.clear()
- entry.update({"id": "", "name": "", "arguments": ""})
- entry["id"] = new_id
-
- if func_name:
- entry["name"] = func_name
-
- _merge_arguments(entry, part_args)
-
- async for chunk in response:
- # print(chunk)
- if isinstance(chunk[0], AIMessageChunk):
- # ----------------------------------------------------------------
- # 1. 从 tool_call_chunks 中聚合工具调用片段
- # (qwen/OpenAI streaming 通过 tool_call_chunks 传递,
- # additional_kwargs['tool_calls'] 在流式时通常为空)
- # ----------------------------------------------------------------
- for tc_chunk in chunk[0].tool_call_chunks or []:
- raw_id = tc_chunk.get("id")
- key = _get_fragment_key(tc_chunk.get("index"), raw_id)
- _upsert_fragment(key, raw_id, tc_chunk.get("name"), tc_chunk.get("args", ""))
-
- # ----------------------------------------------------------------
- # 1.1 兼容部分模型将工具调用放在 chunk.tool_calls,且 tool_call_chunks
- # 的 index 为空(例如 ollama/qwen)
- # ----------------------------------------------------------------
- has_tool_call_chunks = bool(chunk[0].tool_call_chunks)
- for tool_call in chunk[0].tool_calls or []:
- raw_id = tool_call.get("id")
- part_args = tool_call.get("args", "")
- # qwen-plus often emits {} here as a placeholder while
- # the real args are split in tool_call_chunks/invalid_tool_calls.
- if has_tool_call_chunks and (part_args == "" or part_args == {} or part_args == []):
- part_args = ""
- key = _get_fragment_key(tool_call.get("index"), raw_id)
- _upsert_fragment(key, raw_id, tool_call.get("name"), part_args)
-
- # ----------------------------------------------------------------
- # 1.2 兼容 invalid_tool_calls 分片(部分模型会把中间 JSON 片段放这里)
- # ----------------------------------------------------------------
- for invalid_tool_call in chunk[0].invalid_tool_calls or []:
- raw_id = invalid_tool_call.get("id")
- key = _get_fragment_key(invalid_tool_call.get("index"), raw_id)
- _upsert_fragment(key, raw_id, invalid_tool_call.get("name"), invalid_tool_call.get("args", ""))
-
- # ----------------------------------------------------------------
- # 2. 兼容 additional_kwargs['tool_calls'] 方式(旧格式/非流式情况)
- # ----------------------------------------------------------------
- legacy_tool_calls = chunk[0].additional_kwargs.get("tool_calls", [])
- for tool_call in legacy_tool_calls:
- raw_id = tool_call.get("id")
- func = tool_call.get("function", {})
- if isinstance(func, dict):
- func_name = func.get("name")
- part_args = func.get("arguments", "")
- else:
- func_name = tool_call.get("name")
- part_args = tool_call.get("arguments", "")
- key = _get_fragment_key(tool_call.get("index"), raw_id)
- _upsert_fragment(key, raw_id, func_name, part_args)
-
- # ----------------------------------------------------------------
- # 3. 检测工具调用结束,更新 tool_calls_info
- # ----------------------------------------------------------------
- is_finish_chunk = (
- chunk[0].response_metadata.get("finish_reason") == "tool_calls" or chunk[0].chunk_position == "last"
- )
-
- if is_finish_chunk:
- # 在 finish chunk 时,将所有未完成的 fragment 标记完成并更新 tool_calls_info
- maxkb_logger.debug(f"Processing finish chunk. Tool fragments: {_tool_fragments}")
- for idx, entry in _tool_fragments.items():
- if entry.get("completed"):
- maxkb_logger.debug(f"Skipping fragment {idx}: already completed")
- continue
- if not entry.get("id"):
- maxkb_logger.debug(f"Skipping fragment {idx}: missing id. Fragment: {entry}")
- continue
- if not entry.get("arguments"):
- maxkb_logger.debug(f"Skipping fragment {idx}: missing arguments. Fragment: {entry}")
- continue
-
- if not entry.get("completed") and entry.get("id") and entry.get("arguments"):
- try:
- parsed_args = json.loads(entry["arguments"])
- filtered_args = (
- {k: v for k, v in parsed_args.items() if k not in tool_init_params}
- if tool_init_params
- else parsed_args
- )
- normalized_id = _extract_tool_id(entry["id"])
- info = {"name": entry["name"], "input": json.dumps(filtered_args, ensure_ascii=False)}
- tool_calls_info[entry["id"]] = info
- if normalized_id and normalized_id != entry["id"]:
- tool_calls_info[normalized_id] = info
- entry["completed"] = True
- maxkb_logger.debug(f"Added tool call {entry['id']} to tool_calls_info")
- except (json.JSONDecodeError, ValueError) as e:
- # JSON parsing failed, but still add to tool_calls_info with raw arguments
- # to prevent "Tool ID not found" errors when ToolMessage arrives
- maxkb_logger.warning(
- f"Failed to parse tool arguments at finish for tool {entry.get('id', 'unknown')}: "
- f"{entry['arguments']}, error: {e}. Using raw arguments."
- )
- normalized_id = _extract_tool_id(entry["id"])
- info = {
- "name": entry["name"],
- # Use raw arguments
- "input": entry["arguments"],
- }
- tool_calls_info[entry["id"]] = info
- if normalized_id and normalized_id != entry["id"]:
- tool_calls_info[normalized_id] = info
- entry["completed"] = True
-
- # ----------------------------------------------------------------
- # 4. 修复 tool_call_chunks 中的空 id(回填已知 id)
- # ----------------------------------------------------------------
- if chunk[0].tool_call_chunks:
- for tc_chunk in chunk[0].tool_call_chunks:
- key = _get_fragment_key(tc_chunk.get("index"), tc_chunk.get("id"))
- if key is not None:
- frag = _tool_fragments.get(key)
- if frag and frag.get("id") and not tc_chunk.get("id"):
- tc_chunk["id"] = frag["id"]
-
- # ----------------------------------------------------------------
- # 5. 修复 additional_kwargs['tool_calls'](兼容旧格式)
- # 仅在 finish chunk 时写入完整参数,避免污染中间 chunk 的
- # additional_kwargs(中间 chunk 会被 ainvoke 累积,如果写入
- # 不完整 JSON 会导致下一轮 API 调用出现 arguments 非 JSON 格式错误)
- # ----------------------------------------------------------------
- if legacy_tool_calls and is_finish_chunk:
- fixed_tool_calls = []
- for tool_call in legacy_tool_calls:
- key = _get_fragment_key(tool_call.get("index"), tool_call.get("id"))
- frag = _tool_fragments.get(key) if key is not None else None
- tc = dict(tool_call)
- if frag and frag.get("id") and not tc.get("id"):
- tc["id"] = frag["id"]
- if frag and isinstance(tc.get("function"), dict):
- tc["function"] = dict(tc["function"])
- if frag.get("completed"):
- tc["function"]["arguments"] = frag["arguments"]
- fixed_tool_calls.append(tc)
- chunk[0].additional_kwargs["tool_calls"] = fixed_tool_calls
-
- yield chunk[0]
-
- if mcp_output_enable and isinstance(chunk[0], ToolMessage):
- tool_id = chunk[0].tool_call_id
- normalized_tool_id = _extract_tool_id(tool_id)
- tool_info = tool_calls_info.get(tool_id) or tool_calls_info.get(normalized_tool_id)
-
- if tool_info:
- try:
- if isinstance(chunk[0].content, str):
- tool_result = json.loads(chunk[0].content)
- elif isinstance(chunk[0].content, dict):
- tool_result = chunk[0].content
- elif isinstance(chunk[0].content, list):
- tool_result = chunk[0].content[0] if len(chunk[0].content) > 0 else {}
- else:
- tool_result = {}
- text = tool_result.get("text") if "text" in tool_result else None
- text_result = json.loads(text) if text else tool_result
- if text:
- tool_lib_id = text_result.pop("tool_id") if "tool_id" in text_result else None
- else:
- tool_lib_id = tool_result.pop("tool_id") if "tool_id" in tool_result else None
- if tool_lib_id:
- await save_tool_record(tool_lib_id, tool_info, tool_result, source_id, source_type)
- tool_result = json.dumps(text_result, ensure_ascii=False)
- except Exception as e:
- tool_result = chunk[0].content
- content = generate_tool_message_complete(
- tool_info.get("icon", ""), tool_info["name"], tool_info["input"], tool_result
- )
- chunk[0].content = content
- else:
- maxkb_logger.warning(
- f"Tool ID {tool_id} not found in tool_calls_info. "
- f"Normalized Tool ID: {normalized_tool_id}. "
- f"Available IDs: {list(tool_calls_info.keys())}. "
- f"Tool fragments at this point: {_tool_fragments}"
- )
-
- yield chunk[0]
-
- except ExceptionGroup as eg:
-
- def get_real_error(exc):
- if isinstance(exc, ExceptionGroup):
- return get_real_error(exc.exceptions[0])
- return exc
-
- real_error = get_real_error(eg)
- error_msg = f"{type(real_error).__name__}: {str(real_error)}"
- raise RuntimeError(error_msg) from None
-
- except Exception as e:
- error_msg = f"{type(e).__name__}: {str(e)}"
- raise RuntimeError(error_msg) from None
-
-
-async def save_tool_record(tool_id, tool_info, tool_result, source_id, source_type):
- tool = await sync_to_async(lambda: QuerySet(Tool).filter(id=tool_id).first())()
- tool_info["icon"] = tool.icon
- tool_record = ToolRecord(
- id=uuid.uuid7(),
- workspace_id=tool.workspace_id,
- tool_id=tool_id,
- source_type=source_type,
- source_id=source_id,
- meta={"input": tool_info["input"], "output": tool_result},
- state=State.SUCCESS,
- )
- await sync_to_async(tool_record.save)()
-
-
-def mcp_response_generator(
- chat_model,
- system_prompt,
- message_list,
- mcp_servers,
- mcp_output_enable=True,
- tool_init_params={},
- source_id=None,
- source_type=None,
- chat_id=None,
- extra_tools=None,
-):
- """使用全局事件循环,不创建新实例"""
- result_queue = queue.Queue()
- loop = get_global_loop() # 使用共享循环
- # 创建临时文件夹
- if chat_id:
- temp_dir = os.path.join("/tmp", chat_id)
- else:
- temp_dir = os.path.join("/tmp", str(uuid.uuid7()))
- skills_dir = os.path.join(temp_dir, "skills")
- os.makedirs(skills_dir, exist_ok=True)
-
- # print(f"Initializing skills in temporary directory: {skills_dir}")
-
- async def _run():
- try:
- async_gen = _yield_mcp_response(
- chat_model,
- system_prompt,
- message_list,
- mcp_servers,
- mcp_output_enable,
- tool_init_params,
- source_id,
- source_type,
- temp_dir,
- chat_id,
- extra_tools,
- )
- async for chunk in async_gen:
- result_queue.put(("data", chunk))
- except Exception as e:
- maxkb_logger.error(f"Exception: {e}", exc_info=True)
- result_queue.put(("error", e))
- finally:
- result_queue.put(("done", None))
-
- # 在全局循环中调度任务
- asyncio.run_coroutine_threadsafe(_run(), loop)
-
- while True:
- msg_type, data = result_queue.get()
- if msg_type == "done":
- # 清理临时文件夹
- shutil.rmtree(temp_dir, ignore_errors=True)
- break
- if msg_type == "error":
- # 清理临时文件夹
- shutil.rmtree(temp_dir, ignore_errors=True)
- raise data
- yield data
-
-
-async def anext_async(agen):
- return await agen.__anext__()
-
-
-target_source_node_mapping = {
- "TOOL": {
- "tool-lib-node": lambda n: [n.get("properties").get("node_data").get("tool_lib_id")],
- "ai-chat-node": lambda n: [
- *(n.get("properties").get("node_data").get("mcp_tool_ids") or []),
- *(n.get("properties").get("node_data").get("tool_ids") or []),
- *(n.get("properties").get("node_data").get("skill_tool_ids") or []),
- ],
- "mcp-node": lambda n: [n.get("properties").get("node_data").get("mcp_tool_id")],
- "tool-workflow-lib-node": lambda n: [n.get("properties").get("node_data").get("tool_lib_id")],
- },
- "MODEL": {
- "ai-chat-node": lambda n: [n.get("properties").get("node_data").get("model_id")],
- "question-node": lambda n: [n.get("properties").get("node_data").get("model_id")],
- "speech-to-text-node": lambda n: [n.get("properties").get("node_data").get("stt_model_id")],
- "text-to-speech-node": lambda n: [n.get("properties").get("node_data").get("tts_model_id")],
- "image-to-video-node": lambda n: [n.get("properties").get("node_data").get("model_id")],
- "image-generate-node": lambda n: [n.get("properties").get("node_data").get("model_id")],
- "intent-node": lambda n: [n.get("properties").get("node_data").get("model_id")],
- "image-understand-node": lambda n: [n.get("properties").get("node_data").get("model_id")],
- "parameter-extraction-node": lambda n: [n.get("properties").get("node_data").get("model_id")],
- "video-understand-node": lambda n: [n.get("properties").get("node_data").get("model_id")],
- "reranker-node": lambda n: [n.get("properties").get("node_data").get("reranker_model_id")],
- },
- "KNOWLEDGE": {
- "search-knowledge-node": lambda n: n.get("properties").get("node_data").get("knowledge_id_list"),
- "search-document-node": lambda n: n.get("properties").get("node_data").get("knowledge_id_list"),
- },
- "APPLICATION": {
- "application-node": lambda n: [n.get("properties").get("node_data").get("application_id")],
- "ai-chat-node": lambda n: [*(n.get("properties").get("node_data").get("application_ids") or [])],
- },
-}
-
-
-def get_node_handle_callback(source_type, source_id):
- def node_handle_callback(node):
- from system_manage.models.resource_mapping import ResourceMapping
-
- response = []
- for key, value in target_source_node_mapping.items():
- if node.get("type") in value:
- call = value.get(node.get("type"))
- target_source_id_list = call(node)
- for target_source_id in target_source_id_list:
- if target_source_id:
- response.append(
- ResourceMapping(
- source_type=source_type,
- target_type=key,
- source_id=source_id,
- target_id=target_source_id,
- )
- )
- return response
-
- return node_handle_callback
-
-
-def get_workflow_resource(workflow, node_handle):
- response = []
- if "nodes" in workflow:
- for node in workflow.get("nodes"):
- rs = node_handle(node)
- if rs:
- for r in rs:
- response.append(r)
- if node.get("type") == "loop-node":
- r = get_workflow_resource(node.get("properties", {}).get("node_data", {}).get("loop_body"), node_handle)
- for rn in r:
- response.append(rn)
- return list({(str(item.target_type) + str(item.target_id)): item for item in response}.values())
- return []
-
-
-application_instance_field_call_dict = {
- "TOOL": [
- lambda instance: instance.mcp_tool_ids or [],
- lambda instance: instance.skill_tool_ids or [],
- lambda instance: instance.tool_ids or [],
- ],
- "APPLICATION": [
- lambda instance: instance.application_ids or [],
- ],
- "MODEL": [
- lambda instance: [instance.model_id] if instance.model_id else [],
- lambda instance: [instance.long_term_model_id] if instance.long_term_model_id else [],
- lambda instance: [instance.tts_model_id] if instance.tts_model_id else [],
- lambda instance: [instance.stt_model_id] if instance.stt_model_id else [],
- ],
-}
-knowledge_instance_field_call_dict = {
- "MODEL": [lambda instance: [instance.embedding_model_id] if instance.embedding_model_id else []],
-}
-
-
-def get_instance_resource(instance, source_type, source_id, instance_field_call_dict):
- response = []
- from system_manage.models.resource_mapping import ResourceMapping
-
- for target_type, call_list in instance_field_call_dict.items():
- target_id_list = reduce(lambda x, y: [*x, *y], [call(instance) for call in call_list], [])
- if target_id_list:
- for target_id in target_id_list:
- response.append(
- ResourceMapping(
- source_type=source_type, target_type=target_type, source_id=source_id, target_id=target_id
- )
- )
- return response
-
-
-def save_workflow_mapping(workflow, source_type, source_id, other_resource_mapping=None):
- if not other_resource_mapping:
- other_resource_mapping = []
- from django.db.models import QuerySet
- from system_manage.models.resource_mapping import ResourceMapping
-
- QuerySet(ResourceMapping).filter(source_type=source_type, source_id=source_id).delete()
- resource_mapping_list = get_workflow_resource(workflow, get_node_handle_callback(source_type, source_id))
- resource_mapping_list += other_resource_mapping
- if resource_mapping_list:
- QuerySet(ResourceMapping).bulk_create(
- {(str(item.target_type) + str(item.target_id)): item for item in resource_mapping_list}.values()
- )
-
-
-def get_tool_id_list(workflow, with_deep=False):
- from tools.models import ToolType, ToolWorkflow
-
- _result = []
- for node in workflow.get("nodes", []):
- if node.get("type") == "tool-lib-node":
- tool_id = node.get("properties", {}).get("node_data", {}).get("tool_lib_id")
- if tool_id:
- _result.append(tool_id)
- elif node.get("type") == "loop-node":
- r = get_tool_id_list(node.get("properties", {}).get("node_data", {}).get("loop_body", {}))
- for item in r:
- _result.append(item)
- elif node.get("type") == "tool-workflow-lib-node":
- tool_id = node.get("properties", {}).get("node_data", {}).get("tool_lib_id")
- if tool_id:
- _result.append(tool_id)
- elif node.get("type") == "ai-chat-node":
- node_data = node.get("properties", {}).get("node_data", {})
- mcp_tool_ids = node_data.get("mcp_tool_ids") or []
- skill_tool_ids = node_data.get("skill_tool_ids") or []
- tool_ids = node_data.get("tool_ids") or []
- for _id in mcp_tool_ids + tool_ids + skill_tool_ids:
- _result.append(_id)
- elif node.get("type") == "mcp-node":
- mcp_tool_id = node.get("properties", {}).get("node_data", {}).get("mcp_tool_id")
- if mcp_tool_id:
- _result.append(mcp_tool_id)
- if with_deep:
- workflow_list = QuerySet(Tool).filter(id__in=_result, tool_type=ToolType.WORKFLOW)
- tool_work_flow_list = QuerySet(ToolWorkflow).filter(tool_id__in=[wl.id for wl in workflow_list])
- for tool_work_flow in tool_work_flow_list:
- child_tool_id_list = get_child_tool_id_list(tool_work_flow.work_flow, [])
- for c in child_tool_id_list:
- _result.append(c)
- return _result
-
-
-def get_child_tool_id_list(work_flow, response):
- from tools.models import ToolType, ToolWorkflow
-
- tool_id_list = get_tool_id_list(work_flow, False)
- tool_id_list = [tool_id for tool_id in tool_id_list if len([r for r in response if r == tool_id]) == 0]
- tool_list = []
- if len(tool_id_list) > 0:
- tool_list = QuerySet(Tool).filter(id__in=tool_id_list).exclude(scope=ToolScope.SHARED)
- work_flow_tools = [tool for tool in tool_list if tool.tool_type == ToolType.WORKFLOW]
- if len(work_flow_tools) > 0:
- work_flow_tool_dict = {
- tw.tool_id: tw for tw in QuerySet(ToolWorkflow).filter(tool_id__in=[t.id for t in work_flow_tools])
- }
- for tool in tool_list:
- response.append(str(tool.id))
- if tool.tool_type == ToolType.WORKFLOW:
- get_child_tool_id_list(work_flow_tool_dict.get(tool.id).work_flow, response)
- else:
- for tool in tool_list:
- response.append(str(tool.id))
- return response
-
-
-def build_schema(fields: dict):
- return create_model("dynamicSchema", **fields)
-
-
-def get_type(_type: str):
- if _type == "float":
- return float
- if _type == "string":
- return str
- if _type == "int":
- return int
- if _type == "dict":
- return dict
- if _type == "array":
- return list
- if _type == "boolean":
- return bool
- return object
-
-
-def get_workflow_args(tool, qv):
- for node in qv.work_flow.get("nodes"):
- if node.get("type") == "tool-base-node":
- input_field_list = node.get("properties").get("user_input_field_list")
- return build_schema(
- {
- field.get("field"): (
- get_type(field.get("type")),
- Field(..., required=True, description=field.get("desc")) if field.get("is_required") else Field(default=None, required=False, description=field.get("desc"))
- )
- for field in input_field_list
- }
- )
-
- return build_schema({})
-
-
-def get_workflow_func(source_type, source_id, tool, qv, workspace_id):
- tool_id = tool.id
- tool_record_id = str(uuid.uuid7())
- took_execute = ToolExecute(tool_id, tool_record_id, workspace_id, source_type, source_id, False)
-
- def inner(**kwargs):
- from application.flow.tool_workflow_manage import ToolWorkflowManage
-
- work_flow_manage = ToolWorkflowManage(
- Workflow.new_instance(qv.work_flow, WorkflowMode.TOOL),
- {
- "chat_record_id": tool_record_id,
- "tool_id": tool_id,
- "stream": True,
- "workspace_id": workspace_id,
- **kwargs,
- },
- ToolWorkflowPostHandler(took_execute, tool_id),
- is_the_task_interrupted=lambda: False,
- child_node=None,
- start_node_id=None,
- start_node_data=None,
- chat_record=None,
- )
- res = work_flow_manage.run()
- for r in res:
- pass
- return work_flow_manage.out_context
-
- return inner
-
-
-def get_tools(source_type, source_id, tool_workflow_ids, workspace_id):
- tools = QuerySet(Tool).filter(
- id__in=tool_workflow_ids, is_active=True, tool_type=ToolType.WORKFLOW, workspace_id=workspace_id
- )
- latest_subquery = ToolWorkflowVersion.objects.filter(tool_id=OuterRef("tool_id")).order_by("-create_time")
-
- qs = ToolWorkflowVersion.objects.filter(
- tool_id__in=[t.id for t in tools], id=Subquery(latest_subquery.values("id")[:1])
- )
- qd = {q.tool_id: q for q in qs}
- results = []
- for tool in tools:
- qv = qd.get(tool.id)
- func = get_workflow_func(source_type, source_id, tool, qv, workspace_id)
- args = get_workflow_args(tool, qv)
- tool = StructuredTool.from_function(
- func=func,
- name=tool.name,
- description=tool.desc,
- args_schema=args,
- )
- results.append(tool)
-
- return results
diff --git a/apps/application/flow/workflow_manage.py b/apps/application/flow/workflow_manage.py
deleted file mode 100644
index f1323c6d4b7..00000000000
--- a/apps/application/flow/workflow_manage.py
+++ /dev/null
@@ -1,833 +0,0 @@
-# coding=utf-8
-"""
- @project: maxkb
- @Author:虎
- @file: workflow_manage.py
- @date:2024/1/9 17:40
- @desc:
-"""
-import concurrent
-import json
-import threading
-from concurrent.futures import ThreadPoolExecutor
-from functools import reduce
-from typing import List, Dict
-
-from django.db import close_old_connections, connection
-from django.utils import translation
-from django.utils.translation import get_language
-from langchain_core.prompts import PromptTemplate
-from rest_framework import status
-
-from application.flow import tools
-from application.flow.common import Workflow
-from application.flow.i_step_node import INode, WorkFlowPostHandler, NodeResult, FlowParamsSerializer
-from application.flow.step_node import get_node
-from common.handle.base_to_response import BaseToResponse
-from common.handle.impl.response.system_to_response import SystemToResponse
-from common.utils.logger import maxkb_logger
-
-executor = ThreadPoolExecutor(max_workers=200)
-
-
-class NodeResultFuture:
- def __init__(self, r, e, status=200):
- self.r = r
- self.e = e
- self.status = status
-
- def result(self):
- if self.status == 200:
- return self.r
- else:
- raise self.e
-
-
-def await_result(result, timeout=1):
- try:
- result.result(timeout)
- return False
- except Exception as e:
- return True
-
-
-class NodeChunkManage:
-
- def __init__(self, work_flow):
- self.node_chunk_list = []
- self.current_node_chunk = None
- self.work_flow = work_flow
-
- def add_node_chunk(self, node_chunk):
- self.node_chunk_list.append(node_chunk)
-
- def contains(self, node_chunk):
- return self.node_chunk_list.__contains__(node_chunk)
-
- def pop(self):
- if self.current_node_chunk is None:
- try:
- current_node_chunk = self.node_chunk_list.pop(0)
- self.current_node_chunk = current_node_chunk
- except IndexError as e:
- pass
- if self.current_node_chunk is not None:
- try:
- chunk = self.current_node_chunk.chunk_list.pop(0)
- return chunk
- except IndexError as e:
- if self.current_node_chunk.is_end():
- self.current_node_chunk = None
- if self.work_flow.answer_is_not_empty():
- chunk = self.work_flow.base_to_response.to_stream_chunk_response(
- self.work_flow.params['chat_id'],
- self.work_flow.params['chat_record_id'],
- '\n\n', False, 0, 0)
- self.work_flow.append_answer('\n\n')
- return chunk
- return self.pop()
- return None
-
-
-class WorkflowManage:
- def __init__(self, flow: Workflow, params, work_flow_post_handler: WorkFlowPostHandler,
- base_to_response: BaseToResponse = SystemToResponse(), form_data=None, image_list=None,
- document_list=None,
- audio_list=None,
- video_list=None,
- other_list=None,
- start_node_id=None,
- start_node_data=None, chat_record=None, child_node=None, is_the_task_interrupted=lambda: False):
- if form_data is None:
- form_data = {}
- if image_list is None:
- image_list = []
- if document_list is None:
- document_list = []
- if audio_list is None:
- audio_list = []
- if video_list is None:
- video_list = []
- if other_list is None:
- other_list = []
- self.start_node_id = start_node_id
- self.start_node = None
- self.form_data = form_data
- self.image_list = image_list
- self.video_list = video_list
- self.document_list = document_list
- self.audio_list = audio_list
- self.other_list = other_list
- self.params = params
- self.flow = flow
- self.context = {}
- self.chat_context = {}
- self.node_chunk_manage = NodeChunkManage(self)
- self.work_flow_post_handler = work_flow_post_handler
- self.current_node = None
- self.current_result = None
- self.answer = ""
- self.answer_list = ['']
- self.status = 200
- self.base_to_response = base_to_response
- self.chat_record = chat_record
- self.child_node = child_node
- self.future_list = []
- self.lock = threading.Lock()
- self.field_list = []
- self.global_field_list = []
- self.chat_field_list = []
- self.init_fields()
- self.is_the_task_interrupted = is_the_task_interrupted
- if start_node_id is not None:
- self.load_node(chat_record, start_node_id, start_node_data)
- else:
- self.node_context = []
-
- def init_fields(self):
- field_list = []
- global_field_list = []
- chat_field_list = []
- for node in self.flow.nodes:
- properties = node.properties
- node_name = properties.get('stepName')
- node_id = node.id
- node_config = properties.get('config')
- field_list.append(
- {'label': '异常信息', 'value': 'exception_message', 'node_id': node_id, 'node_name': node_name})
- if node_config is not None:
- fields = node_config.get('fields')
- if fields is not None:
- for field in fields:
- field_list.append({**field, 'node_id': node_id, 'node_name': node_name})
- global_fields = node_config.get('globalFields')
- if global_fields is not None:
- for global_field in global_fields:
- global_field_list.append({**global_field, 'node_id': node_id, 'node_name': node_name})
- chat_fields = node_config.get('chatFields')
- if chat_fields is not None:
- for chat_field in chat_fields:
- chat_field_list.append({**chat_field, 'node_id': node_id, 'node_name': node_name})
- field_list.sort(key=lambda f: len(f.get('node_name') + f.get('value')), reverse=True)
- global_field_list.sort(key=lambda f: len(f.get('node_name') + f.get('value')), reverse=True)
- chat_field_list.sort(key=lambda f: len(f.get('node_name') + f.get('value')), reverse=True)
- self.field_list = field_list
- self.global_field_list = global_field_list
- self.chat_field_list = chat_field_list
-
- def append_answer(self, content):
- self.answer += content
- self.answer_list[-1] += content
-
- def answer_is_not_empty(self):
- return len(self.answer_list[-1]) > 0
-
- def load_node(self, chat_record, start_node_id, start_node_data):
- self.node_context = []
- self.answer = chat_record.answer_text
- self.answer_list = chat_record.answer_text_list
- self.answer_list.append('')
- for node_details in sorted(chat_record.details.values(), key=lambda d: d.get('index')):
- node_id = node_details.get('node_id')
- if node_details.get('runtime_node_id') == start_node_id:
- def get_node_params(n):
- is_result = False
- if ['application-node', 'loop-node', 'tool-workflow-lib-node'].__contains__(n.type):
- is_result = True
- return {**n.properties.get('node_data'), 'form_data': start_node_data, 'node_data': start_node_data,
- 'child_node': self.child_node, 'is_result': is_result}
-
- self.start_node = self.get_node_cls_by_id(node_id, node_details.get('up_node_id_list'),
- get_node_params=get_node_params)
- self.start_node.valid_args(
- {**self.start_node.node_params, 'form_data': start_node_data}, self.start_node.workflow_params)
- if self.start_node.type == 'loop-node':
- loop_node_data = node_details.get('loop_node_data', {})
- for k, v in node_details.get('loop_context_data').items():
- if v is not None:
- self.start_node.context[k] = v
- self.start_node.context['loop_node_data'] = loop_node_data
- self.start_node.context['current_index'] = node_details.get('current_index')
- self.start_node.context['current_item'] = node_details.get('current_item')
- self.start_node.context['loop_answer_data'] = node_details.get('loop_answer_data', {})
- if self.start_node.type == 'application-node':
- application_node_dict = node_details.get('application_node_dict', {})
- self.start_node.context['application_node_dict'] = application_node_dict
- self.node_context.append(self.start_node)
- continue
-
- node_id = node_details.get('node_id')
- node = self.get_node_cls_by_id(node_id, node_details.get('up_node_id_list'))
- node.valid_args(node.node_params, node.workflow_params)
- node.save_context(node_details, self)
- node.node_chunk.end()
- self.node_context.append(node)
-
- def run(self):
- close_old_connections()
- language = get_language()
- if self.params.get('stream'):
- return self.run_stream(self.start_node, None, language)
- return self.run_block(language)
-
- def run_block(self, language='zh'):
- """
- 非流式响应
- @return: 结果
- """
- try:
- self.params['stream'] = True
- self.run_chain_async(None, None, language)
- while self.is_run():
- pass
- details = self.get_runtime_details()
- message_tokens = sum([row.get('message_tokens') for row in details.values() if
- 'message_tokens' in row and row.get('message_tokens') is not None])
- answer_tokens = sum([row.get('answer_tokens') for row in details.values() if
- 'answer_tokens' in row and row.get('answer_tokens') is not None])
- answer_text_list = self.get_answer_text_list()
- answer_text = '\n\n'.join(
- '\n\n'.join([a.get('content') for a in answer]) for answer in
- answer_text_list)
- answer_list = reduce(lambda pre, _n: [*pre, *_n], answer_text_list, [])
- self.work_flow_post_handler.handler(self)
-
- res = self.base_to_response.to_block_response(self.params['chat_id'],
- self.params['chat_record_id'], answer_text, True
- , message_tokens, answer_tokens,
- _status=status.HTTP_200_OK if self.status == 200 else status.HTTP_500_INTERNAL_SERVER_ERROR,
- other_params={'answer_list': answer_list})
- finally:
- self._cleanup()
- return res
-
- def _cleanup(self):
- """清理所有对象引用"""
- # 清理列表
- self.future_list.clear()
- self.field_list.clear()
- self.global_field_list.clear()
- self.chat_field_list.clear()
- self.image_list.clear()
- self.video_list.clear()
- self.document_list.clear()
- self.audio_list.clear()
- self.other_list.clear()
- if hasattr(self, 'node_context'):
- self.node_context.clear()
-
- # 清理字典
- self.context.clear()
- self.chat_context.clear()
- self.form_data.clear()
-
- # 清理对象引用
- self.node_chunk_manage = None
- self.work_flow_post_handler = None
- self.flow = None
- self.start_node = None
- self.current_node = None
- self.current_result = None
- self.chat_record = None
- self.base_to_response = None
- self.params = None
- self.lock = None
-
- def run_stream(self, current_node, node_result_future, language='zh'):
- """
- 流式响应
- @return:
- """
- self.run_chain_async(current_node, node_result_future, language)
- return tools.to_stream_response_simple(self.await_result())
-
- def get_body(self):
- return self.params
-
- def is_run(self, timeout=0.5):
- future_list_len = len(self.future_list)
- try:
- r = concurrent.futures.wait(self.future_list, timeout)
- if len(r.not_done) > 0:
- return True
- else:
- if future_list_len == len(self.future_list):
- return False
- else:
- return True
- except Exception as e:
- return True
-
- def await_result(self, is_cleanup=True):
- try:
- while self.is_run():
- while True:
- chunk = self.node_chunk_manage.pop()
- if chunk is not None:
- yield chunk
- else:
- break
- while True:
- chunk = self.node_chunk_manage.pop()
- if chunk is None:
- break
- yield chunk
- finally:
- while self.is_run():
- pass
- details = self.get_runtime_details()
- message_tokens = sum([row.get('message_tokens') for row in details.values() if
- 'message_tokens' in row and row.get('message_tokens') is not None])
- answer_tokens = sum([row.get('answer_tokens') for row in details.values() if
- 'answer_tokens' in row and row.get('answer_tokens') is not None])
- self.work_flow_post_handler.handler(self)
- yield self.base_to_response.to_stream_chunk_response(self.params.get('chat_id'),
- self.params.get('chat_record_id'),
- '',
- [],
- '', True, message_tokens, answer_tokens, {})
- if is_cleanup:
- self._cleanup()
-
- def run_chain_async(self, current_node, node_result_future, language='zh'):
- future = executor.submit(self.run_chain_manage, current_node, node_result_future, language)
- self.future_list.append(future)
-
- def run_chain_manage(self, current_node, node_result_future, language='zh'):
- translation.activate(language)
- if current_node is None:
- start_node = self.get_start_node()
- current_node = get_node(start_node.type, self.flow.workflow_mode)(start_node, self.params, self)
- self.node_chunk_manage.add_node_chunk(current_node.node_chunk)
- # 添加节点
- self.append_node(current_node)
- result = self.run_chain(current_node, node_result_future)
- if result is None:
- return
- node_list = self.get_next_node_list(current_node, result)
- if len(node_list) == 1:
- self.run_chain_manage(node_list[0], None, language)
- elif len(node_list) > 1:
- sorted_node_run_list = sorted(node_list, key=lambda n: n.node.y)
- # 获取到可执行的子节点
- result_list = [{'node': node, 'future': executor.submit(self.run_chain_manage, node, None, language)} for
- node in
- sorted_node_run_list]
- for r in result_list:
- self.future_list.append(r.get('future'))
-
- def run_chain(self, current_node, node_result_future=None):
- if node_result_future is None:
- node_result_future = self.run_node_future(current_node)
- try:
- is_stream = self.params.get('stream', True)
- result = self.hand_event_node_result(current_node,
- node_result_future) if is_stream else self.hand_node_result(
- current_node, node_result_future)
- return result
- except Exception as e:
- maxkb_logger.error(f'Exception: {e}', exc_info=True)
- return None
-
- def hand_node_result(self, current_node, node_result_future):
- try:
- current_result = node_result_future.result()
- result = current_result.write_context(current_node, self)
- if result is not None:
- # 阻塞获取结果
- list(result)
- return current_result
- except Exception as e:
- maxkb_logger.error(f'Exception: {e}', exc_info=True)
- self.status = 500
- current_node.get_write_error_context(e)
- self.answer += str(e)
- finally:
- current_node.node_chunk.end()
-
- def append_node(self, current_node):
- for index in range(len(self.node_context)):
- n = self.node_context[index]
- if current_node.id == n.node.id and current_node.runtime_node_id == n.runtime_node_id:
- self.node_context[index] = current_node
- return
- self.node_context.append(current_node)
-
- def hand_event_node_result(self, current_node, node_result_future):
- runtime_node_id = current_node.runtime_node_id
- real_node_id = current_node.runtime_node_id
- child_node = {}
- view_type = current_node.view_type
- try:
- self.send_progress(current_node)
- current_result = node_result_future.result()
- result = current_result.write_context(current_node, self)
- if result is not None:
- if self.is_result(current_node, current_result):
- for r in result:
- reasoning_content = ''
- content = r
- child_node = {}
- node_is_end = False
- view_type = current_node.view_type
- node_type = current_node.type
- node_name = current_node.node.properties.get('stepName')
- if isinstance(r, dict):
- content = r.get('content')
- child_node = {'runtime_node_id': r.get('runtime_node_id'),
- 'chat_record_id': r.get('chat_record_id')
- , 'child_node': r.get('child_node')}
- if r.__contains__('real_node_id'):
- real_node_id = r.get('real_node_id')
- if r.__contains__('node_is_end'):
- node_is_end = r.get('node_is_end')
- if r.__contains__('node_type'):
- node_type = r.get("node_type")
- if r.__contains__('node_name'):
- node_name = r.get('node_name')
- view_type = r.get('view_type')
- reasoning_content = r.get('reasoning_content')
- chunk = self.base_to_response.to_stream_chunk_response(self.params.get('chat_id'),
- self.params.get('chat_record_id'),
- current_node.id,
- current_node.up_node_id_list,
- content, False, 0, 0,
- {'node_type': node_type,
- 'runtime_node_id': runtime_node_id,
- 'node_name': node_name,
- 'view_type': view_type,
- 'child_node': child_node,
- 'node_is_end': node_is_end,
- 'real_node_id': real_node_id,
- 'reasoning_content': reasoning_content,
- 'node_status': "SUCCESS"})
- current_node.node_chunk.add_chunk(chunk)
- chunk = (self.base_to_response
- .to_stream_chunk_response(self.params.get('chat_id'),
- self.params.get('chat_record_id'),
- current_node.id,
- current_node.up_node_id_list,
- '', False, 0, 0, {'node_is_end': True,
- 'runtime_node_id': runtime_node_id,
- 'node_type': current_node.type,
- 'view_type': view_type,
- 'child_node': child_node,
- 'real_node_id': real_node_id,
- 'reasoning_content': '',
- 'node_status': "SUCCESS"}))
- current_node.node_chunk.add_chunk(chunk)
- else:
- list(result)
- if current_node.status == 500:
- enableException = current_node.node.properties.get('enableException')
- if not enableException:
- return None
- current_node.context['exception_message'] = current_node.err_message
- current_node.context['branch_id'] = 'exception'
- r = NodeResult({'branch_id': 'exception', 'exception': current_node.err_message}, {},
- _is_interrupt=lambda node, step_variable, global_variable: False)
- r.write_context(current_node, self)
- return r
- if self.is_the_task_interrupted():
- current_node.status = 201
- return None
- return current_result
- except Exception as e:
- # 添加节点
- maxkb_logger.error(f'Exception: {e}', exc_info=True)
- enableException = current_node.node.properties.get('enableException')
- current_node.get_write_error_context(e)
- self.status = 500
- if self.is_the_task_interrupted():
- current_node.status = 201
- return None
- if not enableException:
- chunk = self.base_to_response.to_stream_chunk_response(self.params.get('chat_id'),
- self.params.get('chat_record_id'),
- current_node.id,
- current_node.up_node_id_list,
- 'Exception:' + str(e), False, 0, 0,
- {'node_is_end': True,
- 'runtime_node_id': current_node.runtime_node_id,
- 'node_type': current_node.type,
- 'view_type': current_node.view_type,
- 'child_node': {},
- 'real_node_id': real_node_id,
- 'node_status': 'ERROR'})
- current_node.node_chunk.add_chunk(chunk)
- return None
- else:
- current_node.context['exception_message'] = current_node.err_message
- current_node.context['branch_id'] = 'exception'
- return NodeResult({'branch_id': 'exception', 'exception': current_node.err_message}, {},
- _is_interrupt=lambda node, step_variable, global_variable: False)
- finally:
- current_node.node_chunk.end()
- # 归还链接到连接池
- connection.close()
-
- def send_progress(self, current_node):
- runtime_node_id = current_node.runtime_node_id
- real_node_id = current_node.runtime_node_id
- child_node = {}
- view_type = current_node.view_type
- if 'form-node' != current_node.type:
- chunk = self.base_to_response.to_stream_chunk_response(self.params.get('chat_id'),
- self.params.get('chat_record_id'),
- current_node.id,
- current_node.up_node_id_list,
- '', False, 0, 0,
- {'node_type': current_node.type,
- 'runtime_node_id': runtime_node_id,
- 'node_name': current_node.node.properties.get(
- 'stepName'),
- 'view_type': view_type,
- 'child_node': child_node,
- 'node_is_end': True,
- 'real_node_id': real_node_id,
- 'reasoning_content': '',
- 'node_status': "SUCCESS"})
- current_node.node_chunk.add_chunk(chunk)
-
- def run_node_async(self, node):
- future = executor.submit(self.run_node, node)
- return future
-
- def run_node_future(self, node):
- try:
- node.valid_args(node.node_params, node.workflow_params)
- self.send_progress(node)
- result = self.run_node(node)
- return NodeResultFuture(result, None, 200)
- except Exception as e:
- return NodeResultFuture(None, e, 500)
-
- def run_node(self, node):
- result = node.run()
- return result
-
- def is_result(self, current_node, current_node_result):
- return current_node.node_params.get('is_result', not self._has_next_node(
- current_node, current_node_result)) if current_node.node_params is not None else False
-
- def get_chat_info(self):
- return self.work_flow_post_handler.chat_info
-
- def get_chunk_content(self, chunk, is_end=False):
- return 'data: ' + json.dumps(
- {'chat_id': self.params['chat_id'], 'id': self.params['chat_record_id'], 'operate': True,
- 'content': chunk, 'is_end': is_end}, ensure_ascii=False) + "\n\n"
-
- def _has_next_node(self, current_node, node_result: NodeResult | None):
- """
- 是否有下一个可运行的节点
- """
- next_edge_node_list = self.flow.get_next_edge_nodes(current_node.id) or []
- for next_edge_node in next_edge_node_list:
- if node_result is not None and node_result.is_assertion_result():
- edge = next_edge_node.edge
- if (edge.sourceNodeId == current_node.id and
- f"{edge.sourceNodeId}_{node_result.node_variable.get('branch_id')}_right" == edge.sourceAnchorId):
- return True
- return len(next_edge_node_list) > 0
-
- def has_next_node(self, node_result: NodeResult | None):
- """
- 是否有下一个可运行的节点
- """
- return self._has_next_node(self.get_start_node() if self.current_node is None else self.current_node,
- node_result)
-
- def get_runtime_details(self, get_details=lambda n, index: n.get_details(index)):
- details_result = {}
- for index in range(len(self.node_context)):
- node = self.node_context[index]
- if self.chat_record is not None and self.chat_record.details is not None and self.start_node:
- details = self.chat_record.details.get(node.runtime_node_id)
- if details is not None and self.start_node.runtime_node_id != node.runtime_node_id:
- details_result[node.runtime_node_id] = details
- continue
- details = get_details(node, index)
- details['node_id'] = node.id
- details['up_node_id_list'] = node.up_node_id_list
- details['runtime_node_id'] = node.runtime_node_id
- details_result[node.runtime_node_id] = details
- return details_result
-
- def get_record_answer_list(self):
- answer_text_list = self.get_answer_text_list()
- return reduce(lambda pre, _n: [*pre, *_n], answer_text_list, [])
-
- def get_answer_text_list(self):
- result = []
- answer_list = reduce(lambda x, y: [*x, *y],
- [n.get_answer_list() for n in self.node_context if n.get_answer_list() is not None],
- [])
- up_node = None
- for index in range(len(answer_list)):
- current_answer = answer_list[index]
- if len(current_answer.content) > 0:
- if up_node is None or current_answer.view_type == 'single_view' or (
- current_answer.view_type == 'many_view' and up_node.view_type == 'single_view'):
- result.append([current_answer])
- else:
- if len(result) > 0:
- exec_index = len(result) - 1
- if isinstance(result[exec_index], list):
- result[exec_index].append(current_answer)
- else:
- result.insert(0, [current_answer])
- up_node = current_answer
- if len(result) == 0:
- # 如果没有响应 就响应一个空数据
- return [[]]
- return [[item.to_dict() for item in r] for r in result]
-
- @staticmethod
- def dependent_node(edge, node):
- up_node_id = edge.sourceNodeId
- if not node.node_chunk.is_end():
- return False
- if node.id == up_node_id:
- if node.context.get('branch_id', None):
- if edge.sourceAnchorId == f"{node.id}_{node.context.get('branch_id', None)}_right":
- return True
- else:
- return False
- if node.type == 'form-node':
- if node.context.get('form_data', None) is not None:
- return True
- return False
- return True
-
- def dependent_node_been_executed(self, node_id):
- """
- 判断依赖节点是否都已执行
- @param node_id: 需要判断的节点id
- @return:
- """
- up_edge_list = [edge for edge in self.flow.edges if edge.targetNodeId == node_id]
- return all(
- [any([self.dependent_node(edge, node) for node in self.node_context if node.id == edge.sourceNodeId]) for
- edge in
- up_edge_list])
-
- def get_next_node_list(self, current_node, current_node_result):
- """
- 获取下一个可执行节点列表
- @param current_node: 当前可执行节点
- @param current_node_result: 当前可执行节点结果
- @return: 可执行节点列表
- """
- # 判断是否中断执行
- if current_node_result.is_interrupt_exec(current_node):
- return []
- node_list = []
- next_edge_node_list = self.flow.get_next_edge_nodes(current_node.id) or []
- if current_node_result is not None and current_node_result.is_assertion_result():
- for edge_node in next_edge_node_list:
- edge = edge_node.edge
- next_node = edge_node.node
- if (
- f"{edge.sourceNodeId}_{current_node_result.node_variable.get('branch_id')}_right" == edge.sourceAnchorId):
- if next_node.properties.get('condition', "AND") == 'AND':
- if self.dependent_node_been_executed(edge.targetNodeId):
- up_nodes = self.flow.get_up_nodes(edge.targetNodeId)
- up_node_id_list = [*current_node.up_node_id_list, current_node.node.id]
- if up_nodes and len(up_nodes) > 1:
- up_nodes.sort(key=lambda node: node.id)
- first = up_nodes[0]
- up_node_id_list = [n_c for n_c in self.node_context if n_c.node.id == first.id][
- 0].up_node_id_list
- up_node_id_list = [*up_node_id_list, first.id]
- node_list.append(
- self.get_node_cls_by_id(edge.targetNodeId,
- up_node_id_list))
- else:
- node_list.append(
- self.get_node_cls_by_id(edge.targetNodeId,
- [*current_node.up_node_id_list, current_node.node.id]))
- else:
- for edge_node in next_edge_node_list:
- edge = edge_node.edge
- if edge.sourceNodeId + '_right' == edge.sourceAnchorId:
- next_node = edge_node.node
- if next_node.properties.get('condition', "AND") == 'AND':
- if self.dependent_node_been_executed(edge.targetNodeId):
- up_nodes = self.flow.get_up_nodes(edge.targetNodeId)
- up_node_id_list = [*current_node.up_node_id_list, current_node.node.id]
- if up_nodes and len(up_nodes) > 1:
- up_nodes.sort(key=lambda node: node.id)
- first = up_nodes[0]
- up_node_id_list = [n_c for n_c in self.node_context if n_c.node.id == first.id][
- 0].up_node_id_list
- up_node_id_list = [*up_node_id_list, first.id]
- node_list.append(
- self.get_node_cls_by_id(edge.targetNodeId,
- up_node_id_list))
- else:
- node_list.append(
- self.get_node_cls_by_id(edge.targetNodeId,
- [*current_node.up_node_id_list, current_node.node.id]))
- return [node for node in node_list if not node.node.properties.get('disabled')]
-
- def get_reference_field(self, node_id: str, fields: List[str]):
- """
- @param node_id: 节点id
- @param fields: 字段
- @return:
- """
- if node_id == 'global':
- return INode.get_field(self.context, fields)
- elif node_id == 'chat':
- return INode.get_field(self.chat_context, fields)
- else:
- node = self.get_node_by_id(node_id)
- if node:
- return node.get_reference_field(fields)
- return None
-
- def get_workflow_content(self):
- context = {
- 'global': self.context,
- 'chat': self.chat_context
- }
-
- for node in self.node_context:
- context[node.id] = node.context
- return context
-
- def reset_prompt(self, prompt: str):
- placeholder = "{}"
- for field in self.field_list:
- globeLabel = f"{field.get('node_name')}.{field.get('value')}"
- globeValue = f"context.get('{field.get('node_id')}',{placeholder}).get('{field.get('value', '')}','')"
- prompt = prompt.replace(globeLabel, globeValue)
- for field in self.global_field_list:
- globeLabel = f"全局变量.{field.get('value')}"
- globeLabelNew = f"global.{field.get('value')}"
- globeValue = f"context.get('global').get('{field.get('value', '')}','')"
- prompt = prompt.replace(globeLabel, globeValue).replace(globeLabelNew, globeValue)
- for field in self.chat_field_list:
- chatLabel = f"chat.{field.get('value')}"
- chatValue = f"context.get('chat').get('{field.get('value', '')}','')"
- prompt = prompt.replace(chatLabel, chatValue)
-
- return prompt
-
- def generate_prompt(self, prompt: str):
- """
- 格式化生成提示词
- @param prompt: 提示词信息
- @return: 格式化后的提示词
- """
- context = self.get_workflow_content()
- prompt = self.reset_prompt(prompt)
- prompt_template = PromptTemplate.from_template(prompt, template_format='jinja2')
- value = prompt_template.format(context=context)
- return value
-
- def get_start_node(self):
- """
- 获取启动节点
- @return:
- """
- start_node_list = [node for node in self.flow.nodes if node.type == 'start-node']
- return start_node_list[0]
-
- def get_base_node(self):
- """
- 获取基础节点
- @return:
- """
- base_node_list = [node for node in self.flow.nodes if node.type == 'base-node']
- return base_node_list[0]
-
- def get_node_cls_by_id(self, node_id, up_node_id_list=None,
- get_node_params=lambda node: node.properties.get('node_data')):
- for node in self.flow.nodes:
- if node.id == node_id:
- node_instance = get_node(node.type, self.flow.workflow_mode)(node,
- self.params, self, up_node_id_list,
- get_node_params)
- return node_instance
- return None
-
- def get_node_by_id(self, node_id):
- for node in self.node_context:
- if node.id == node_id:
- return node
- return None
-
- def get_node_reference(self, reference_address: Dict):
- node = self.get_node_by_id(reference_address.get('node_id'))
- return node.context[reference_address.get('node_field')]
-
- def get_params_serializer_class(self):
- return FlowParamsSerializer
-
- def get_source_type(self):
- return "APPLICATION"
-
- def get_source_id(self):
- return self.params.get('application_id')
diff --git a/apps/application/long_term_memory/__init__.py b/apps/application/long_term_memory/__init__.py
index ea03cf58bcc..ba2419d6bce 100644
--- a/apps/application/long_term_memory/__init__.py
+++ b/apps/application/long_term_memory/__init__.py
@@ -270,7 +270,7 @@ def _run_extract(workspace_id, application_id, chat_user_id, config, history_lim
]):
content += chunk.content
- content = re.sub(r'.*?<\/think>', '', content, flags=re.DOTALL).strip()
+ content = re.sub(r'.*?', '', content, flags=re.DOTALL).strip()
if long_term_memory:
long_term_memory.memory = content
diff --git a/apps/application/mcp_tools.py b/apps/application/mcp_tools.py
new file mode 100644
index 00000000000..d48d6480b26
--- /dev/null
+++ b/apps/application/mcp_tools.py
@@ -0,0 +1,8 @@
+"""Shared MCP tool loading helpers."""
+
+from langchain_mcp_adapters.client import MultiServerMCPClient
+
+
+async def get_mcp_tools(servers):
+ client = MultiServerMCPClient(servers)
+ return await client.get_tools()
diff --git a/apps/application/migrations/0014_applicationversion_knowledge_ids.py b/apps/application/migrations/0014_applicationversion_knowledge_ids.py
new file mode 100644
index 00000000000..9457355f724
--- /dev/null
+++ b/apps/application/migrations/0014_applicationversion_knowledge_ids.py
@@ -0,0 +1,61 @@
+# Generated by Django 5.2.15 on 2026-07-29 02:42
+
+from django.db import migrations, models
+
+
+def forwards(apps, schema_editor):
+ Application = apps.get_model("application", "Application")
+ ResourceMapping = apps.get_model("system_manage", "ResourceMapping")
+ ApplicationVersion = apps.get_model("application", "ApplicationVersion")
+
+ APPLICATION = "APPLICATION"
+ KNOWLEDGE = "KNOWLEDGE"
+ SIMPLE = "SIMPLE"
+ db_alias = schema_editor.connection.alias
+ simple_application_ids = {
+ str(app_id)
+ for app_id in Application.objects.using(db_alias)
+ .filter(type=SIMPLE)
+ .values_list("id", flat=True)
+ }
+ mapping = {}
+ qs = (
+ ResourceMapping.objects.using(db_alias)
+ .filter(source_type=APPLICATION, target_type=KNOWLEDGE)
+ .values_list("source_id", "target_id")
+ )
+ for source_id, target_id in qs.iterator():
+ if source_id in simple_application_ids:
+ mapping.setdefault(source_id, []).append(target_id)
+ mapping = {k: list(dict.fromkeys(v)) for k, v in mapping.items()}
+
+ updates = []
+ for obj in ApplicationVersion.objects.using(db_alias).iterator():
+ app_id = str(obj.application_id)
+ if app_id not in simple_application_ids:
+ continue
+ knowledge_ids = mapping.get(app_id)
+ if knowledge_ids:
+ obj.knowledge_ids = knowledge_ids
+ updates.append(obj)
+ if updates:
+ ApplicationVersion.objects.using(db_alias).bulk_update(
+ updates, ["knowledge_ids"], batch_size=500
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('application', '0013_application_long_term_enable_and_more'),
+ ('system_manage', '0005_resourcemapping'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="applicationversion",
+ name="knowledge_ids",
+ field=models.JSONField(default=list, verbose_name="数据集id列表"),
+ ),
+ migrations.RunPython(forwards, migrations.RunPython.noop),
+ ]
diff --git a/apps/application/migrations/0015_chat_execute_type.py b/apps/application/migrations/0015_chat_execute_type.py
new file mode 100644
index 00000000000..9f0e446d4f9
--- /dev/null
+++ b/apps/application/migrations/0015_chat_execute_type.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.2.14 on 2026-07-21 08:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('application', '0014_applicationversion_knowledge_ids'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='chat',
+ name='execute_type',
+ field=models.CharField(choices=[('ANONYMOUS_USER', '匿名用户'), ('CHAT_USER', '对话用户'), ('SYSTEM_API_KEY', '系统API_KEY'), ('APPLICATION_API_KEY', '应用API_KEY'), ('PLATFORM_USER', '平台用户')], default='CHAT', max_length=64, verbose_name='执行类型'),
+ ),
+ migrations.AddField(
+ model_name="chatrecord",
+ name="workflow_context",
+ field=models.JSONField(blank=True, default=dict, null=True, verbose_name="工作流上下文"),
+ ),
+ ]
diff --git a/apps/application/migrations/0016_chatrecord_messages_chatrecord_question_and_more.py b/apps/application/migrations/0016_chatrecord_messages_chatrecord_question_and_more.py
new file mode 100644
index 00000000000..bf835deb315
--- /dev/null
+++ b/apps/application/migrations/0016_chatrecord_messages_chatrecord_question_and_more.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.2.14 on 2026-07-22 08:33
+
+import common.encoder.encoder
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('application', '0015_chat_execute_type'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='chatrecord',
+ name='messages',
+ field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(), default=list, size=None, verbose_name='响应message'),
+ ),
+ migrations.AddField(
+ model_name='chatrecord',
+ name='question',
+ field=models.JSONField(default=dict, encoder=common.encoder.encoder.SystemEncoder, verbose_name='用户的消息'),
+ ),
+ migrations.AddField(
+ model_name='chatrecord',
+ name='version',
+ field=models.IntegerField(default=1, verbose_name='版本号'),
+ ),
+ ]
diff --git a/apps/application/migrations/0017_application_is_portal_and_more.py b/apps/application/migrations/0017_application_is_portal_and_more.py
new file mode 100644
index 00000000000..8b4dd159ddc
--- /dev/null
+++ b/apps/application/migrations/0017_application_is_portal_and_more.py
@@ -0,0 +1,17 @@
+# Generated by Django 6.1 on 2026-08-24 08:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("application", "0016_chatrecord_messages_chatrecord_question_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="application",
+ name="is_portal",
+ field=models.BooleanField(default=False, verbose_name="是否在门户上架"),
+ )
+ ]
diff --git a/apps/application/migrations/0018_application_default_model_setting_and_more.py b/apps/application/migrations/0018_application_default_model_setting_and_more.py
new file mode 100644
index 00000000000..ff25649f8ae
--- /dev/null
+++ b/apps/application/migrations/0018_application_default_model_setting_and_more.py
@@ -0,0 +1,73 @@
+# Generated by Django 6.1 on 2026-09-09 09:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("application", "0017_application_is_portal_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="application",
+ name="default_model_setting",
+ field=models.JSONField(default=dict, verbose_name="默认模型"),
+ ),
+ migrations.AddField(
+ model_name="applicationversion",
+ name="default_model_setting",
+ field=models.JSONField(default=dict, verbose_name="默认模型"),
+ ),
+ migrations.AlterField(
+ model_name="applicationchatuserstats",
+ name="chat_user_type",
+ field=models.CharField(
+ choices=[
+ ("ANONYMOUS_USER", "匿名用户"),
+ ("CHAT_USER", "对话用户"),
+ ("SYSTEM_API_KEY", "系统API_KEY"),
+ ("APPLICATION_API_KEY", "应用API_KEY"),
+ ("PLATFORM_USER", "平台用户"),
+ ("SYSTEM_USER", "系统用户"),
+ ],
+ default="ANONYMOUS_USER",
+ max_length=64,
+ verbose_name="对话用户类型",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="chat",
+ name="chat_user_type",
+ field=models.CharField(
+ choices=[
+ ("ANONYMOUS_USER", "匿名用户"),
+ ("CHAT_USER", "对话用户"),
+ ("SYSTEM_API_KEY", "系统API_KEY"),
+ ("APPLICATION_API_KEY", "应用API_KEY"),
+ ("PLATFORM_USER", "平台用户"),
+ ("SYSTEM_USER", "系统用户"),
+ ],
+ default="ANONYMOUS_USER",
+ max_length=64,
+ verbose_name="客户端类型",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="chat",
+ name="execute_type",
+ field=models.CharField(
+ choices=[
+ ("ANONYMOUS_USER", "匿名用户"),
+ ("CHAT_USER", "对话用户"),
+ ("SYSTEM_API_KEY", "系统API_KEY"),
+ ("APPLICATION_API_KEY", "应用API_KEY"),
+ ("PLATFORM_USER", "平台用户"),
+ ("SYSTEM_USER", "系统用户"),
+ ],
+ default="CHAT",
+ max_length=64,
+ verbose_name="执行类型",
+ ),
+ ),
+ ]
diff --git a/apps/application/migrations/0019_applicationversion_publish_desc.py b/apps/application/migrations/0019_applicationversion_publish_desc.py
new file mode 100644
index 00000000000..f9c071ff985
--- /dev/null
+++ b/apps/application/migrations/0019_applicationversion_publish_desc.py
@@ -0,0 +1,17 @@
+# Generated by Django 6.1 on 2026-09-16 08:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("application", "0018_application_default_model_setting_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="applicationversion",
+ name="publish_desc",
+ field=models.CharField(default="", max_length=1024, verbose_name="更新说明"),
+ ),
+ ]
diff --git a/apps/application/models/application.py b/apps/application/models/application.py
index 34824b29eee..f81e48cad33 100644
--- a/apps/application/models/application.py
+++ b/apps/application/models/application.py
@@ -1,11 +1,12 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: application.py
- @date:2025/5/7 15:29
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: application.py
+@date:2025/5/7 15:29
+@desc:
"""
+
import uuid_utils.compat as uuid
from django.db import models
from mptt.fields import TreeForeignKey
@@ -23,45 +24,50 @@ class ApplicationFolder(MPTTModel, AppModelMixin):
desc = models.CharField(max_length=200, null=True, blank=True, verbose_name="描述")
user = models.ForeignKey(User, on_delete=models.SET_NULL, db_constraint=False, blank=True, null=True)
workspace_id = models.CharField(max_length=64, verbose_name="工作空间id", default="default", db_index=True)
- parent = TreeForeignKey('self', on_delete=models.DO_NOTHING, null=True, blank=True, related_name='children')
+ parent = TreeForeignKey("self", on_delete=models.DO_NOTHING, null=True, blank=True, related_name="children")
class Meta:
db_table = "application_folder"
class MPTTMeta:
- order_insertion_by = ['name']
+ order_insertion_by = ["name"]
class ApplicationTypeChoices(models.TextChoices):
"""订单类型"""
- SIMPLE = 'SIMPLE', '简易'
- WORK_FLOW = 'WORK_FLOW', '工作流'
+
+ SIMPLE = "SIMPLE", "简易"
+ WORK_FLOW = "WORK_FLOW", "工作流"
def get_dataset_setting_dict():
- return {'top_n': 3, 'similarity': 0.6, 'max_paragraph_char_number': 5000, 'search_mode': 'embedding',
- 'no_references_setting': {
- 'status': 'ai_questioning',
- 'value': '{question}'
- }}
+ return {
+ "top_n": 3,
+ "similarity": 0.6,
+ "max_paragraph_char_number": 5000,
+ "search_mode": "embedding",
+ "no_references_setting": {"status": "ai_questioning", "value": "{question}"},
+ }
def get_model_setting_dict():
return {
- 'prompt': Application.get_default_model_prompt(),
- 'no_references_prompt': '{question}',
- 'reasoning_content_start': '',
- 'reasoning_content_end': '',
- 'reasoning_content_enable': False,
+ "prompt": Application.get_default_model_prompt(),
+ "no_references_prompt": "{question}",
+ "reasoning_content_start": "",
+ "reasoning_content_end": "",
+ "reasoning_content_enable": False,
}
class Application(AppModelMixin):
id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id")
workspace_id = models.CharField(max_length=64, verbose_name="工作空间id", default="default", db_index=True)
- folder = models.ForeignKey(ApplicationFolder, on_delete=models.DO_NOTHING, verbose_name="文件夹id",
- default='default')
+ folder = models.ForeignKey(
+ ApplicationFolder, on_delete=models.DO_NOTHING, verbose_name="文件夹id", default="default"
+ )
is_publish = models.BooleanField(verbose_name="是否发布", default=False)
+ is_portal = models.BooleanField(verbose_name="是否在门户上架", default=False)
name = models.CharField(max_length=128, verbose_name="应用名称", db_index=True)
desc = models.CharField(max_length=512, verbose_name="引用描述", default="")
prologue = models.CharField(max_length=40960, verbose_name="开场白", default="")
@@ -76,15 +82,25 @@ class Application(AppModelMixin):
problem_optimization = models.BooleanField(verbose_name="问题优化", default=False)
icon = models.CharField(max_length=256, verbose_name="应用icon", default="./favicon.ico")
work_flow = models.JSONField(verbose_name="工作流数据", default=dict)
- type = models.CharField(verbose_name="应用类型", choices=ApplicationTypeChoices.choices,
- default=ApplicationTypeChoices.SIMPLE, max_length=256)
- problem_optimization_prompt = models.CharField(verbose_name="问题优化提示词", max_length=102400, blank=True,
- null=True,
- default="()里面是用户问题,根据上下文回答揣测用户问题({question}) 要求: 输出一个补全问题,并且放在标签中")
- tts_model = models.ForeignKey(Model, related_name='tts_model_id', on_delete=models.SET_NULL, db_constraint=False,
- blank=True, null=True)
- stt_model = models.ForeignKey(Model, related_name='stt_model_id', on_delete=models.SET_NULL, db_constraint=False,
- blank=True, null=True)
+ type = models.CharField(
+ verbose_name="应用类型",
+ choices=ApplicationTypeChoices.choices,
+ default=ApplicationTypeChoices.SIMPLE,
+ max_length=256,
+ )
+ problem_optimization_prompt = models.CharField(
+ verbose_name="问题优化提示词",
+ max_length=102400,
+ blank=True,
+ null=True,
+ default="()里面是用户问题,根据上下文回答揣测用户问题({question}) 要求: 输出一个补全问题,并且放在标签中",
+ )
+ tts_model = models.ForeignKey(
+ Model, related_name="tts_model_id", on_delete=models.SET_NULL, db_constraint=False, blank=True, null=True
+ )
+ stt_model = models.ForeignKey(
+ Model, related_name="stt_model_id", on_delete=models.SET_NULL, db_constraint=False, blank=True, null=True
+ )
tts_model_enable = models.BooleanField(verbose_name="语音合成模型是否启用", default=False)
stt_model_enable = models.BooleanField(verbose_name="语音识别模型是否启用", default=False)
tts_type = models.CharField(verbose_name="语音播放类型", max_length=20, default="BROWSER")
@@ -105,26 +121,30 @@ class Application(AppModelMixin):
skill_tool_ids = models.JSONField(verbose_name="技能ID列表", default=list)
mcp_output_enable = models.BooleanField(verbose_name="MCP输出是否启用", default=True)
file_clean_time = models.IntegerField(verbose_name="文件清理时间", default=180)
- long_term_enable = models.BooleanField(verbose_name='长期记忆是否开启', default=False)
- long_term_model = models.ForeignKey(Model, related_name='long_term_model_id', on_delete=models.SET_NULL,
- db_constraint=False, blank=True, null=True)
+ long_term_enable = models.BooleanField(verbose_name="长期记忆是否开启", default=False)
+ long_term_model = models.ForeignKey(
+ Model, related_name="long_term_model_id", on_delete=models.SET_NULL, db_constraint=False, blank=True, null=True
+ )
long_term_model_params_setting = models.JSONField(verbose_name="长期记忆模型参数相关设置", default=dict)
- long_term_trigger_type = models.CharField(verbose_name='长期记忆触发类型', default='ROUND')
- long_term_trigger_setting = models.JSONField(verbose_name='长期记忆触发配置', default=dict)
+ long_term_trigger_type = models.CharField(verbose_name="长期记忆触发类型", default="ROUND")
+ long_term_trigger_setting = models.JSONField(verbose_name="长期记忆触发配置", default=dict)
+ default_model_setting = models.JSONField(verbose_name="默认模型", default=dict)
@staticmethod
def get_default_model_prompt():
- return ('已知信息:'
- '\n{data}'
- '\n回答要求:'
- '\n- 如果你不知道答案或者没有从获取答案,请回答“没有在知识库中查找到相关信息,建议咨询相关技术支持或参考官方文档进行操作”。'
- '\n- 避免提及你是从中获得的知识。'
- '\n- 请保持答案与中描述的一致。'
- '\n- 请使用markdown 语法优化答案的格式。'
- '\n- 中的图片链接、链接地址和脚本语言请完整返回。'
- '\n- 请使用与问题相同的语言来回答。'
- '\n问题:'
- '\n{question}')
+ return (
+ "已知信息:"
+ "\n{data}"
+ "\n回答要求:"
+ "\n- 如果你不知道答案或者没有从获取答案,请回答“没有在知识库中查找到相关信息,建议咨询相关技术支持或参考官方文档进行操作”。"
+ "\n- 避免提及你是从中获得的知识。"
+ "\n- 请保持答案与中描述的一致。"
+ "\n- 请使用markdown 语法优化答案的格式。"
+ "\n- 中的图片链接、链接地址和脚本语言请完整返回。"
+ "\n- 请使用与问题相同的语言来回答。"
+ "\n问题:"
+ "\n{question}"
+ )
class Meta:
db_table = "application"
@@ -145,6 +165,7 @@ class ApplicationVersion(AppModelMixin):
name = models.CharField(verbose_name="版本名称", max_length=128, default="")
publish_user_id = models.UUIDField(verbose_name="发布者id", max_length=128, default=None, null=True)
publish_user_name = models.CharField(verbose_name="发布者名称", max_length=128, default="")
+ publish_desc = models.CharField(verbose_name="更新说明", max_length=1024, default="")
workspace_id = models.CharField(max_length=64, verbose_name="工作空间id", default="default", db_index=True)
application_name = models.CharField(max_length=128, verbose_name="应用名称")
desc = models.CharField(max_length=512, verbose_name="引用描述", default="")
@@ -160,15 +181,21 @@ class ApplicationVersion(AppModelMixin):
problem_optimization = models.BooleanField(verbose_name="问题优化", default=False)
icon = models.CharField(max_length=256, verbose_name="应用icon", default="./favicon.ico")
work_flow = models.JSONField(verbose_name="工作流数据", default=dict)
- type = models.CharField(verbose_name="应用类型", choices=ApplicationTypeChoices.choices,
- default=ApplicationTypeChoices.SIMPLE, max_length=256)
- problem_optimization_prompt = models.CharField(verbose_name="问题优化提示词", max_length=102400, blank=True,
- null=True,
- default="()里面是用户问题,根据上下文回答揣测用户问题({question}) 要求: 输出一个补全问题,并且放在标签中")
- tts_model_id = models.UUIDField(verbose_name="文本转语音模型id",
- blank=True, null=True)
- stt_model_id = models.UUIDField(verbose_name="语音转文本模型id",
- blank=True, null=True)
+ type = models.CharField(
+ verbose_name="应用类型",
+ choices=ApplicationTypeChoices.choices,
+ default=ApplicationTypeChoices.SIMPLE,
+ max_length=256,
+ )
+ problem_optimization_prompt = models.CharField(
+ verbose_name="问题优化提示词",
+ max_length=102400,
+ blank=True,
+ null=True,
+ default="()里面是用户问题,根据上下文回答揣测用户问题({question}) 要求: 输出一个补全问题,并且放在标签中",
+ )
+ tts_model_id = models.UUIDField(verbose_name="文本转语音模型id", blank=True, null=True)
+ stt_model_id = models.UUIDField(verbose_name="语音转文本模型id", blank=True, null=True)
tts_model_enable = models.BooleanField(verbose_name="语音合成模型是否启用", default=False)
stt_model_enable = models.BooleanField(verbose_name="语音识别模型是否启用", default=False)
tts_type = models.CharField(verbose_name="语音播放类型", max_length=20, default="BROWSER")
@@ -187,11 +214,13 @@ class ApplicationVersion(AppModelMixin):
application_ids = models.JSONField(verbose_name="应用ID列表", default=list)
skill_tool_ids = models.JSONField(verbose_name="技能ID列表", default=list)
mcp_output_enable = models.BooleanField(verbose_name="MCP输出是否启用", default=True)
- long_term_enable = models.BooleanField(verbose_name='长期记忆是否开启', default=False)
+ long_term_enable = models.BooleanField(verbose_name="长期记忆是否开启", default=False)
long_term_model_id = models.UUIDField(verbose_name="长期记忆模型id", blank=True, null=True)
long_term_model_params_setting = models.JSONField(verbose_name="长期记忆模型参数相关设置", default=dict)
- long_term_trigger_type = models.CharField(verbose_name='长期记忆触发类型', default='ROUND')
- long_term_trigger_setting = models.JSONField(verbose_name='长期记忆触发配置', default=dict)
+ long_term_trigger_type = models.CharField(verbose_name="长期记忆触发类型", default="ROUND")
+ long_term_trigger_setting = models.JSONField(verbose_name="长期记忆触发配置", default=dict)
+ knowledge_ids = models.JSONField(verbose_name="数据集id列表", default=list)
+ default_model_setting = models.JSONField(verbose_name="默认模型", default=dict)
class Meta:
db_table = "application_version"
diff --git a/apps/application/models/application_chat.py b/apps/application/models/application_chat.py
index e9a3efbc697..fea35d311f7 100644
--- a/apps/application/models/application_chat.py
+++ b/apps/application/models/application_chat.py
@@ -1,33 +1,39 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: application_chat_log.py
- @date:2025/5/29 17:12
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: application_chat_log.py
+@date:2025/5/29 17:12
+@desc:
"""
+
import uuid_utils.compat as uuid
from django.contrib.postgres.fields import ArrayField
from django.db import models
-from django.utils.translation import gettext as _
-from langchain_core.messages import HumanMessage, AIMessage
from application.models import Application
from common.encoder.encoder import SystemEncoder
from common.mixins.app_model_mixin import AppModelMixin
+from common.utils.messages_util import to_ai_message_list, to_human_message_list
from users.models import User
class ChatUserType(models.TextChoices):
- ANONYMOUS_USER = "ANONYMOUS_USER", '匿名用户'
+ ANONYMOUS_USER = "ANONYMOUS_USER", "匿名用户"
CHAT_USER = "CHAT_USER", "对话用户"
SYSTEM_API_KEY = "SYSTEM_API_KEY", "系统API_KEY"
APPLICATION_API_KEY = "APPLICATION_API_KEY", "应用API_KEY"
PLATFORM_USER = "PLATFORM_USER", "平台用户"
+ SYSTEM_USER = "SYSTEM_USER", "系统用户"
+
+
+class ExecuteType(models.TextChoices):
+ DEBUG = "DEBUG"
+ CHAT = "CHAT"
def default_asker():
- return {'username': '游客'}
+ return {"username": "游客"}
class Chat(AppModelMixin):
@@ -35,8 +41,12 @@ class Chat(AppModelMixin):
application = models.ForeignKey(Application, on_delete=models.CASCADE)
abstract = models.CharField(max_length=1024, verbose_name="摘要")
chat_user_id = models.CharField(verbose_name="对话用户id", default=None, null=True)
- chat_user_type = models.CharField(max_length=64, verbose_name="客户端类型", choices=ChatUserType.choices,
- default=ChatUserType.ANONYMOUS_USER)
+ chat_user_type = models.CharField(
+ max_length=64, verbose_name="客户端类型", choices=ChatUserType.choices, default=ChatUserType.ANONYMOUS_USER
+ )
+ execute_type = models.CharField(
+ max_length=64, verbose_name="执行类型", choices=ChatUserType.choices, default=ExecuteType.CHAT
+ )
is_deleted = models.BooleanField(verbose_name="逻辑删除", default=False)
asker = models.JSONField(verbose_name="访问者", default=default_asker, encoder=SystemEncoder)
meta = models.JSONField(verbose_name="元数据", default=dict)
@@ -45,7 +55,7 @@ class Chat(AppModelMixin):
chat_record_count = models.IntegerField(verbose_name="对话次数", default=0)
mark_sum = models.IntegerField(verbose_name="标记数量", default=0)
source = models.JSONField(verbose_name="来源", default=dict)
- ip_address = models.CharField(max_length=128, verbose_name="ip地址", default='')
+ ip_address = models.CharField(max_length=128, verbose_name="ip地址", default="")
class Meta:
db_table = "application_chat"
@@ -53,21 +63,24 @@ class Meta:
class VoteChoices(models.TextChoices):
"""订单类型"""
- UN_VOTE = "-1", '未投票'
- STAR = "0", '赞同'
- TRAMPLE = "1", '反对'
+
+ UN_VOTE = "-1", "未投票"
+ STAR = "0", "赞同"
+ TRAMPLE = "1", "反对"
class VoteReasonChoices(models.TextChoices):
- ACCURATE = 'accurate', '内容准确'
- COMPLETE = 'complete', '内容完善'
- INACCURATE = 'inaccurate', '内容不准确'
- INCOMPLETE = 'incomplete', '内容不完善'
- OTHER = 'other', '其他'
+ ACCURATE = "accurate", "内容准确"
+ COMPLETE = "complete", "内容完善"
+ INACCURATE = "inaccurate", "内容不准确"
+ INCOMPLETE = "incomplete", "内容不完善"
+ OTHER = "other", "其他"
+
class ShareLinkType(models.TextChoices):
- PUBLIC = "PUBLIC", 'public'
- PRIVATE = "PRIVATE", 'private'
+ PUBLIC = "PUBLIC", "public"
+ PRIVATE = "PRIVATE", "private"
+
class ChatSourceChoices(models.TextChoices):
ONLINE = "ONLINE", "线上使用"
@@ -85,44 +98,47 @@ class ChatRecord(AppModelMixin):
"""
对话日志 详情
"""
+
id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id")
chat = models.ForeignKey(Chat, on_delete=models.CASCADE)
- vote_status = models.CharField(verbose_name='投票', max_length=10, choices=VoteChoices.choices,
- default=VoteChoices.UN_VOTE)
- vote_reason = models.CharField(verbose_name='投票原因', max_length=50, choices=VoteReasonChoices.choices, null=True,
- blank=True)
- vote_other_content = models.CharField(verbose_name='其他原因', max_length=1024, default='')
+ vote_status = models.CharField(
+ verbose_name="投票", max_length=10, choices=VoteChoices.choices, default=VoteChoices.UN_VOTE
+ )
+ vote_reason = models.CharField(
+ verbose_name="投票原因", max_length=50, choices=VoteReasonChoices.choices, null=True, blank=True
+ )
+ vote_other_content = models.CharField(verbose_name="其他原因", max_length=1024, default="")
problem_text = models.CharField(max_length=10240, verbose_name="问题")
answer_text = models.CharField(max_length=40960, verbose_name="答案")
- answer_text_list = ArrayField(verbose_name="改进标注列表",
- base_field=models.JSONField()
- , default=list)
+ answer_text_list = ArrayField(verbose_name="改进标注列表", base_field=models.JSONField(), default=list)
message_tokens = models.IntegerField(verbose_name="请求token数量", default=0)
answer_tokens = models.IntegerField(verbose_name="响应token数量", default=0)
const = models.IntegerField(verbose_name="总费用", default=0)
details = models.JSONField(verbose_name="对话详情", default=dict, encoder=SystemEncoder)
- improve_paragraph_id_list = ArrayField(verbose_name="改进标注列表",
- base_field=models.UUIDField(max_length=128, blank=True)
- , default=list)
+ improve_paragraph_id_list = ArrayField(
+ verbose_name="改进标注列表", base_field=models.UUIDField(max_length=128, blank=True), default=list
+ )
run_time = models.FloatField(verbose_name="运行时长", default=0)
index = models.IntegerField(verbose_name="对话下标")
source = models.JSONField(verbose_name="来源", default=dict)
- ip_address = models.CharField(max_length=128, verbose_name="ip地址", default='')
+ ip_address = models.CharField(max_length=128, verbose_name="ip地址", default="")
+ version = models.IntegerField(verbose_name="版本号", default=1)
+ question = models.JSONField(verbose_name="用户的消息", default=dict, encoder=SystemEncoder)
+ messages = ArrayField(verbose_name="响应message", base_field=models.JSONField(), default=list)
+
+ workflow_context = models.JSONField(verbose_name="工作流上下文", default=dict, null=True, blank=True)
def get_human_message(self):
- if 'problem_padding' in self.details:
- return HumanMessage(content=self.details.get('problem_padding').get('padding_problem_text'))
- return HumanMessage(content=self.problem_text)
+ return to_human_message_list(self.question)
def get_ai_message(self):
- answer_text = self.answer_text
- if answer_text is None or len(str(answer_text).strip()) == 0:
- answer_text = _(
- 'Sorry, no relevant content was found. Please re-describe your problem or provide more information. ')
- return AIMessage(content=answer_text)
+ return to_ai_message_list(self.messages)
def get_node_details_runtime_node_id(self, runtime_node_id):
- return self.details.get(runtime_node_id, None)
+ for node_details in self.details:
+ if node_details.get("node_id") == runtime_node_id:
+ return node_details
+ return None
class Meta:
db_table = "application_chat_record"
@@ -131,8 +147,9 @@ class Meta:
class ApplicationChatUserStats(AppModelMixin):
id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id")
chat_user_id = models.UUIDField(max_length=128, default=uuid.uuid7, verbose_name="对话用户id")
- chat_user_type = models.CharField(max_length=64, verbose_name="对话用户类型", choices=ChatUserType.choices,
- default=ChatUserType.ANONYMOUS_USER)
+ chat_user_type = models.CharField(
+ max_length=64, verbose_name="对话用户类型", choices=ChatUserType.choices, default=ChatUserType.ANONYMOUS_USER
+ )
application = models.ForeignKey(Application, on_delete=models.CASCADE, verbose_name="应用id")
access_num = models.IntegerField(default=0, verbose_name="访问总次数次数")
intraday_access_num = models.IntegerField(default=0, verbose_name="当日访问次数")
@@ -140,13 +157,14 @@ class ApplicationChatUserStats(AppModelMixin):
class Meta:
db_table = "application_chat_user_stats"
indexes = [
- models.Index(fields=['application_id', 'chat_user_id']),
+ models.Index(fields=["application_id", "chat_user_id"]),
]
+
class ChatShareLink(AppModelMixin):
id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id")
chat = models.ForeignKey(Chat, on_delete=models.CASCADE)
- application = models.ForeignKey(Application,on_delete=models.CASCADE)
+ application = models.ForeignKey(Application, on_delete=models.CASCADE)
share_type = models.CharField(max_length=20, choices=ShareLinkType.choices, default=ShareLinkType.PUBLIC)
user = models.ForeignKey(User, on_delete=models.SET_NULL, db_constraint=False, blank=True, null=True)
chat_record_ids = ArrayField(base_field=models.UUIDField(max_length=128))
@@ -155,16 +173,15 @@ class Meta:
db_table = "application_chat_share_link"
-
class ApplicationLongTermMemory(AppModelMixin):
id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id")
application = models.ForeignKey(Application, on_delete=models.CASCADE, db_constraint=False, verbose_name="所属应用")
- chat_user_id = models.CharField( max_length=128, verbose_name="对话用户id", db_index=True)
+ chat_user_id = models.CharField(max_length=128, verbose_name="对话用户id", db_index=True)
memory = models.TextField(verbose_name="长期记忆内容", default="")
class Meta:
db_table = "application_long_term_memory"
- unique_together = [('application', 'chat_user_id')]
+ unique_together = [("application", "chat_user_id")]
indexes = [
- models.Index(fields=['application_id', 'chat_user_id']),
+ models.Index(fields=["application_id", "chat_user_id"]),
]
diff --git a/apps/application/serializers/application.py b/apps/application/serializers/application.py
index 5c74570911b..e0edf3e8a70 100644
--- a/apps/application/serializers/application.py
+++ b/apps/application/serializers/application.py
@@ -21,6 +21,11 @@
import requests
import uuid_utils.compat as uuid
+from application.workflow.common import new_instance, WorkflowType
+from application.long_term_memory import schedule_extract_long_term_memory
+from application.models.application import Application, ApplicationFolder, ApplicationTypeChoices, ApplicationVersion
+from application.models.application_access_token import ApplicationAccessToken
+from application.serializers.common import update_resource_mapping_by_application
from common import result
from common.cache_data.application_access_token_cache import del_application_access_token
from common.database_model_manage.database_model_manage import DatabaseModelManage
@@ -36,6 +41,7 @@
)
from common.utils.logger import maxkb_logger
from common.utils.tool_code import ToolExecutor
+from common.utils.url_validator import ALLOWED_CALLBACK_HOSTS, ALLOWED_DOWNLOAD_HOSTS, validate_trusted_url
from django.core import validators
from django.db import models, transaction
from django.db.models import Q, QuerySet
@@ -45,14 +51,14 @@
from knowledge.models import File, FileSourceType, Knowledge, KnowledgeScope
from knowledge.serializers.common import BatchMoveSerializer, BatchSerializer
from knowledge.serializers.knowledge import KnowledgeModelSerializer, KnowledgeSerializer
-from langchain_mcp_adapters.client import MultiServerMCPClient
+from application.workflow.backend.sandbox_mcp import SandboxMCPBackend
from maxkb.conf import PROJECT_DIR
from maxkb.const import CONFIG
from models_provider.models import Model
from models_provider.tools import get_model_instance_by_model_workspace_id
from rest_framework import serializers, status
from rest_framework.utils.formatting import lazy_format
-from system_manage.models import AuthTargetType, WorkspaceUserResourcePermission
+from system_manage.models import AuthTargetType, WorkspaceUserResourcePermission, WorkspaceUserGroupResourcePermission
from system_manage.models.resource_mapping import ResourceMapping
from system_manage.serializers.resource_mapping_serializers import ResourceMappingSerializer
from system_manage.serializers.user_resource_permission import UserResourcePermissionSerializer
@@ -62,11 +68,133 @@
from users.models import User
from users.serializers.user import is_workspace_manage, is_workspace_manage_permission_read
-from application.flow.common import Workflow
-from application.long_term_memory import schedule_extract_long_term_memory
-from application.models.application import Application, ApplicationFolder, ApplicationTypeChoices, ApplicationVersion
-from application.models.application_access_token import ApplicationAccessToken
-from application.serializers.common import update_resource_mapping_by_application
+
+def _walk_workflow_nodes(work_flow, collector):
+ """遍历工作流节点(含 loop-node 嵌套),对每个节点的 node_data 调用 collector。"""
+ if not work_flow:
+ return
+ for node in work_flow.get("nodes", []) or []:
+ node_data = (node.get("properties") or {}).get("node_data") or {}
+ collector(node_data)
+ if node.get("type") == "loop-node":
+ _walk_workflow_nodes(node_data.get("loop_body"), collector)
+
+
+def get_bound_tool_ids(instance: Dict) -> List[str]:
+ """
+ 收集应用配置(含工作流节点)中引用的所有工具id,用于绑定前的权限校验
+ """
+ tool_ids = set()
+ for key in ("tool_ids", "skill_tool_ids", "mcp_tool_ids"):
+ for tool_id in instance.get(key) or []:
+ tool_ids.add(str(tool_id))
+ if instance.get("mcp_tool_id"):
+ tool_ids.add(str(instance.get("mcp_tool_id")))
+
+ def collect(node_data):
+ for key in ("tool_lib_id", "mcp_tool_id"):
+ if node_data.get(key):
+ tool_ids.add(str(node_data.get(key)))
+ for key in ("mcp_tool_ids", "tool_ids", "skill_tool_ids"):
+ for tool_id in node_data.get(key) or []:
+ tool_ids.add(str(tool_id))
+
+ _walk_workflow_nodes(instance.get("work_flow"), collect)
+ return list(tool_ids)
+
+
+def get_bound_application_ids(instance: Dict) -> List[str]:
+ """
+ 收集应用配置(含工作流节点)中引用的所有 application_id,用于绑定前的权限校验。
+ ai-chat-node 的 node_data 包含 application_ids 列表。
+ """
+ application_ids = set()
+ for app_id in instance.get("application_ids") or []:
+ application_ids.add(str(app_id))
+
+ def collect(node_data):
+ for app_id in node_data.get("application_ids") or []:
+ application_ids.add(str(app_id))
+
+ _walk_workflow_nodes(instance.get("work_flow"), collect)
+ return list(application_ids)
+
+
+def get_authorized_tool_ids(user_id: str, workspace_id: str, tool_ids: List[str]) -> List[str]:
+ """
+ 返回 tool_ids 中当前用户被授权绑定/使用的工具id。
+ 工作空间管理员默认拥有全部工具权限;其他用户必须在 workspace_user_resource_permission
+ 中存在针对该工具的显式授权记录(默认拒绝)。
+ """
+ if not tool_ids:
+ return []
+ tool_ids = list({str(t) for t in tool_ids})
+ if is_workspace_manage(user_id, workspace_id):
+ return tool_ids
+ granted_tool_ids = {
+ str(permission.target)
+ for permission in QuerySet(WorkspaceUserResourcePermission).filter(
+ workspace_id=workspace_id,
+ user_id=user_id,
+ auth_target_type=AuthTargetType.TOOL.value,
+ target__in=tool_ids,
+ )
+ if "VIEW" in permission.permission_list or "ROLE" in permission.permission_list
+ }
+ return [tool_id for tool_id in tool_ids if tool_id in granted_tool_ids]
+
+
+def get_authorized_application_ids(user_id: str, workspace_id: str, application_ids: List[str]) -> List[str]:
+ """
+ 返回 application_ids 中当前用户被授权绑定/使用的应用id。
+ 工作空间管理员默认拥有全部应用权限;其他用户必须在 workspace_user_resource_permission
+ 中存在针对该应用的显式授权记录(默认拒绝)。
+ """
+ if not application_ids:
+ return []
+ application_ids = list({str(a) for a in application_ids})
+ if is_workspace_manage(user_id, workspace_id):
+ return application_ids
+ granted_application_ids = {
+ str(permission.target)
+ for permission in QuerySet(WorkspaceUserResourcePermission).filter(
+ workspace_id=workspace_id,
+ user_id=user_id,
+ auth_target_type=AuthTargetType.APPLICATION.value,
+ target__in=application_ids,
+ )
+ if "VIEW" in permission.permission_list or "ROLE" in permission.permission_list
+ }
+ return [app_id for app_id in application_ids if app_id in granted_application_ids]
+
+
+def validate_bound_tool_permissions(user_id: str, workspace_id: str, instance: Dict):
+ """
+ 校验应用/工作流中绑定的工具和子应用,当前用户是否都有权限使用,防止低权限成员
+ 绑定自己被禁止访问的资源,并通过应用/工作流执行绕过单独授权控制。
+ """
+ tool_ids = get_bound_tool_ids(instance)
+ if tool_ids:
+ authorized_tool_ids = set(get_authorized_tool_ids(user_id, workspace_id, tool_ids))
+ unauthorized_tool_ids = [tool_id for tool_id in tool_ids if tool_id not in authorized_tool_ids]
+ if unauthorized_tool_ids:
+ message = lazy_format(
+ _("No permission to use tool(s): {tool_ids}"), tool_ids=", ".join(unauthorized_tool_ids)
+ )
+ raise AppApiException(403, str(message))
+
+ application_ids = get_bound_application_ids(instance)
+ if application_ids:
+ authorized_application_ids = set(get_authorized_application_ids(user_id, workspace_id, application_ids))
+ unauthorized_application_ids = [
+ app_id for app_id in application_ids if app_id not in authorized_application_ids
+ ]
+ if unauthorized_application_ids:
+ message = lazy_format(
+ _("No permission to use application(s): {application_ids}"),
+ application_ids=", ".join(unauthorized_application_ids),
+ )
+ raise AppApiException(403, str(message))
def get_base_node_work_flow(work_flow):
@@ -103,6 +231,42 @@ def hand_node(node, update_tool_map):
node.get("properties", {}).get("node_data", {})["tool_lib_id"] = update_tool_map.get(tool_lib_id, tool_lib_id)
+def update_form_knowledge_fields(workflow):
+ if workflow is None:
+ return
+ for node in workflow.get("nodes", []):
+ if node.get("type") == "form-node":
+ form_field_list = node.get("properties", {}).get("node_data", {}).get("form_field_list", [])
+ for field in form_field_list:
+ if field.get("input_type") != "Knowledge":
+ continue
+ knowledge_list = field.get("attrs", {}).get("knowledge_list", [])
+ knowledge_id_list = [
+ str(knowledge.get("id")) for knowledge in knowledge_list if knowledge.get("id") is not None
+ ]
+ current_knowledge_dict = {
+ str(knowledge.id): knowledge
+ for knowledge in QuerySet(Knowledge).filter(id__in=list(set(knowledge_id_list)))
+ }
+ refreshed_knowledge_list = []
+ for knowledge in knowledge_list:
+ current_knowledge = current_knowledge_dict.get(str(knowledge.get("id")))
+ if current_knowledge is None:
+ refreshed_knowledge_list.append(knowledge)
+ else:
+ refreshed_knowledge_list.append(
+ {
+ **knowledge,
+ "name": current_knowledge.name,
+ "type": current_knowledge.type,
+ "embedding_model_id": current_knowledge.embedding_model_id,
+ }
+ )
+ field.setdefault("attrs", {})["knowledge_list"] = refreshed_knowledge_list
+ if node.get("type") == "loop-node":
+ update_form_knowledge_fields(node.get("properties", {}).get("node_data", {}).get("loop_body") or {})
+
+
class MKInstance:
def __init__(self, application: dict, function_lib_list: List[dict], version: str, tool_list: List[dict]):
self.application = application
@@ -262,6 +426,7 @@ def to_application_model(user_id: str, workspace_id: str, application: Dict):
file_upload_enable=application.get("file_upload_enable", False),
file_upload_setting=application.get("file_upload_setting", {}),
work_flow=default_workflow,
+ default_model_setting=application.get("default_model_setting", {}),
)
class SimplateRequest(serializers.Serializer):
@@ -393,6 +558,9 @@ class ApplicationListResponse(serializers.Serializer):
required=True, label=_("Application Description"), help_text=_("Application Description")
)
is_publish = serializers.BooleanField(required=True, label=_("Model id"), help_text=_("Model id"))
+ is_portal = serializers.BooleanField(
+ required=True, label=_("Whether to publish on portal"), help_text=_("Whether to publish on portal")
+ )
type = serializers.CharField(required=True, label=_("Application type"), help_text=_("Application type"))
resource_type = serializers.CharField(required=True, label=_("Resource type"), help_text=_("Resource type"))
user_id = serializers.CharField(required=True, label=_("Affiliation user"), help_text=_("Affiliation user"))
@@ -437,11 +605,15 @@ def get_query_set(self, instance: Dict, workspace_manage: bool, is_x_pack_ee: bo
resource_and_folder_query_set = QuerySet(WorkspaceUserResourcePermission).filter(
auth_target_type="APPLICATION", workspace_id=workspace_id, user_id=user_id
)
+ resource_and_group_query_set = self.get_workspace_user_group_resource_permission_query_set(
+ workspace_id, user_id
+ )
return (
{
"application_query_set": application_query_set,
"workspace_user_resource_permission_query_set": resource_and_folder_query_set,
+ "workspace_user_group_resource_permission_query_set": resource_and_group_query_set,
}
if (not workspace_manage)
else {
@@ -456,6 +628,14 @@ def is_x_pack_ee():
role_permission_mapping_model = DatabaseModelManage.get_model("role_permission_mapping_model")
return workspace_user_role_mapping_model is not None and role_permission_mapping_model is not None
+ @staticmethod
+ def get_workspace_user_group_resource_permission_query_set(workspace_id, user_id):
+ return QuerySet(WorkspaceUserGroupResourcePermission).filter(
+ auth_target_type="APPLICATION",
+ workspace_id=workspace_id,
+ user_group__user_relations__user_id=user_id,
+ )
+
def list(self, instance: Dict):
self.is_valid(raise_exception=True)
workspace_id = self.data.get("workspace_id")
@@ -514,6 +694,7 @@ class ApplicationImportRequest(serializers.Serializer):
class ApplicationEditSerializer(serializers.Serializer):
name = serializers.CharField(required=False, max_length=64, min_length=1, label=_("Application Name"))
+ is_portal = serializers.BooleanField(required=False, label=_("Whether to publish on portal"))
desc = serializers.CharField(
required=False,
max_length=256,
@@ -534,6 +715,9 @@ class ApplicationEditSerializer(serializers.Serializer):
)
# 数据集相关设置
knowledge_setting = KnowledgeSettingSerializer(required=False, allow_null=True, label=_("Dataset settings"))
+
+ default_model_setting = serializers.DictField(required=False, label=_("Default model setting"))
+
# 模型相关设置
model_setting = ModelSettingSerializer(required=False, allow_null=True, label=_("Model setup"))
# 问题补全
@@ -586,10 +770,10 @@ def insert_template_workflow(self, instance: Dict):
self.is_valid(raise_exception=True)
work_flow_template = instance.get("work_flow_template")
download_url = work_flow_template.get("downloadUrl")
- if not download_url.startswith("https://apps-assets.fit2cloud.com/"):
+ if not validate_trusted_url(download_url, ALLOWED_DOWNLOAD_HOSTS):
raise AppApiException(500, _("Illegal download url"))
# 查找匹配的版本名称
- res = requests.get(download_url, timeout=5)
+ res = requests.get(download_url, timeout=5, allow_redirects=False)
app = ApplicationSerializer(
data={"user_id": self.data.get("user_id"), "workspace_id": self.data.get("workspace_id")}
).import_(
@@ -610,9 +794,9 @@ def insert_template_workflow(self, instance: Dict):
)
try:
download_callback_url = work_flow_template.get("downloadCallbackUrl", "")
- if not download_callback_url.startswith("https://apps.fit2cloud.com"):
+ if not validate_trusted_url(download_callback_url, ALLOWED_CALLBACK_HOSTS):
raise AppApiException(500, _("Illegal download callback url"))
- requests.get(download_callback_url, timeout=5)
+ requests.get(download_callback_url, timeout=5, allow_redirects=False)
except Exception as e:
maxkb_logger.error(f"callback appstore tool download error: {e}")
return app
@@ -623,6 +807,7 @@ def insert_workflow(self, instance: Dict):
workspace_id = self.data.get("workspace_id")
wq = ApplicationCreateSerializer.WorkflowRequest(data=instance)
wq.is_valid(raise_exception=True)
+ validate_bound_tool_permissions(user_id, workspace_id, instance)
application_model = wq.to_application_model(user_id, workspace_id, instance)
application_model.save()
# 插入认证信息
@@ -675,7 +860,7 @@ def import_(self, instance: dict, is_import_tool, with_valid=True):
mk_instance_bytes = instance.get("file").read()
try:
mk_instance = restricted_loads(mk_instance_bytes)
- except Exception as e:
+ except Exception:
raise AppApiException(1001, _("Unsupported file format"))
application = mk_instance.application
tool_list = mk_instance.get_tool_list()
@@ -703,6 +888,20 @@ def import_(self, instance: dict, is_import_tool, with_valid=True):
if not exits_tool_id_list.__contains__(tool.get("id"))
and not exits_tool_id_list.__contains__(generate_uuid((tool.get("id") + workspace_id or "")))
]
+ # 导入包内新建的工具由导入者本人持有,无需校验;仅需校验绑定到已存在工具的引用
+ existing_bound_tool_ids = [
+ tool_id for tool_id in get_bound_tool_ids(application) if tool_id not in update_tool_map
+ ]
+ if existing_bound_tool_ids:
+ authorized_tool_ids = set(get_authorized_tool_ids(user_id, workspace_id, existing_bound_tool_ids))
+ unauthorized_tool_ids = [
+ tool_id for tool_id in existing_bound_tool_ids if tool_id not in authorized_tool_ids
+ ]
+ if unauthorized_tool_ids:
+ message = lazy_format(
+ _("No permission to use tool(s): {tool_ids}"), tool_ids=", ".join(unauthorized_tool_ids)
+ )
+ raise AppApiException(403, str(message))
application_model = self.to_application(application, workspace_id, user_id, update_tool_map, folder_id)
tool_model_list = [self.to_tool(f, workspace_id, user_id) for f in tool_list]
application_model.save()
@@ -896,8 +1095,8 @@ class PlayDemoTextRequest(serializers.Serializer):
async def get_mcp_tools(servers):
- client = MultiServerMCPClient(servers)
- return await client.get_tools()
+ backend = SandboxMCPBackend(servers)
+ return await backend.get_tools()
class McpServersSerializer(serializers.Serializer):
@@ -908,6 +1107,12 @@ class ApplicationOperateSerializer(serializers.Serializer):
application_id = serializers.UUIDField(required=True, label=_("Application ID"))
user_id = serializers.UUIDField(required=True, label=_("User ID"))
workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
+ publish_name = serializers.CharField(
+ required=False, max_length=128, allow_null=True, allow_blank=True, label=_("Publish Name")
+ )
+ publish_desc = serializers.CharField(
+ required=False, max_length=1024, allow_null=True, allow_blank=True, label=_("Publish Desc")
+ )
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
@@ -923,9 +1128,7 @@ def get_mcp_servers(self, instance, with_valid=True):
self.is_valid(raise_exception=True)
McpServersSerializer(data=instance).is_valid(raise_exception=True)
servers = json.loads(instance.get("mcp_servers"))
- for server, config in servers.items():
- if config.get("transport") not in ["sse", "streamable_http"]:
- raise AppApiException(500, _("Only support transport=sse or transport=streamable_http"))
+ ToolExecutor().validate_mcp_transport(json.dumps(servers))
tools = []
for server in servers:
tools += [
@@ -971,7 +1174,7 @@ def export(self, with_valid=True):
self.is_valid()
application_id = self.data.get("application_id")
application = QuerySet(Application).filter(id=application_id).first()
- from application.flow.tools import get_tool_id_list
+ from system_manage.services.resource_mapping import get_tool_id_list
tool_id_list = get_tool_id_list(application.work_flow, True)
if len(tool_id_list) > 0:
@@ -1050,6 +1253,7 @@ def reset_application_version(application_version, application):
"skill_tool_ids": "skill_tool_ids",
"mcp_output_enable": "mcp_output_enable",
"type": "type",
+ "default_model_setting": "default_model_setting",
}
for version_field, app_field in update_field_dict.items():
@@ -1062,6 +1266,10 @@ def publish(self, instance, with_valid=True):
self.is_valid()
user_id = self.data.get("user_id")
workspace_id = self.data.get("workspace_id")
+ name = (instance or {}).get("publish_name")
+ if not name or not str(name).strip():
+ raise AppApiException(500, _("publish_name is required"))
+ publish_desc = (instance or {}).get("publish_desc") or ""
user = QuerySet(User).filter(id=user_id).first()
application = (
QuerySet(Application).filter(id=self.data.get("application_id"), workspace_id=workspace_id).first()
@@ -1070,7 +1278,7 @@ def publish(self, instance, with_valid=True):
work_flow = application.work_flow
if work_flow is None:
raise AppApiException(500, _("work_flow is a required field"))
- Workflow.new_instance(work_flow).is_valid()
+ new_instance(work_flow).is_valid(workflow_type=WorkflowType.APPLICATION)
base_node = get_base_node_work_flow(work_flow)
if base_node is not None:
node_data = base_node.get("properties").get("node_data")
@@ -1085,12 +1293,21 @@ def publish(self, instance, with_valid=True):
work_flow_version = ApplicationVersion(
work_flow=application.work_flow,
application=application,
- name=timezone.localtime(timezone.now()).strftime("%Y-%m-%d %H:%M:%S"),
+ name=name,
publish_user_id=user_id,
publish_user_name=user.username,
+ publish_desc=publish_desc,
workspace_id=workspace_id,
)
self.reset_application_version(work_flow_version, application)
+ # 如果是简易应用 需要存入 knowledge_ids
+ if application.type == ApplicationTypeChoices.SIMPLE:
+ work_flow_version.knowledge_ids = [
+ str(row.target_id)
+ for row in QuerySet(ResourceMapping).filter(
+ source_id=str(application.id), source_type="APPLICATION", target_type="KNOWLEDGE"
+ )
+ ]
work_flow_version.save()
access_token = hashlib.md5(str(uuid.uuid7()).encode()).hexdigest()[8:24]
application_access_token = QuerySet(ApplicationAccessToken).filter(application_id=application.id).first()
@@ -1190,6 +1407,8 @@ def edit(self, instance: Dict, with_valid=True):
if "work_flow_template" in instance:
return self.update_template_workflow(instance, application)
+ validate_bound_tool_permissions(self.data.get("user_id"), self.data.get("workspace_id"), instance)
+
if instance.get("model_id") is None or len(instance.get("model_id")) == 0:
application.model_id = None
else:
@@ -1221,6 +1440,7 @@ def edit(self, instance: Dict, with_valid=True):
ToolExecutor().validate_mcp_transport(json.dumps(instance.get("mcp_servers")))
update_keys = [
"name",
+ "is_portal",
"desc",
"model_id",
"multiple_rounds_dialogue",
@@ -1264,6 +1484,7 @@ def edit(self, instance: Dict, with_valid=True):
"clean_time",
"file_clean_time",
"folder_id",
+ "default_model_setting",
]
for update_key in update_keys:
if update_key in instance and instance.get(update_key) is not None:
@@ -1304,13 +1525,13 @@ def update_template_workflow(self, instance: Dict, app: Application):
self.is_valid(raise_exception=True)
work_flow_template = instance.get("work_flow_template")
download_url = work_flow_template.get("downloadUrl")
- if not download_url.startswith("https://apps-assets.fit2cloud.com/"):
+ if not validate_trusted_url(download_url, ALLOWED_DOWNLOAD_HOSTS):
raise AppApiException(500, _("Illegal download url"))
# 查找匹配的版本名称
- res = requests.get(download_url, timeout=5)
+ res = requests.get(download_url, timeout=5, allow_redirects=False)
try:
mk_instance = restricted_loads(res.content)
- except Exception as e:
+ except Exception:
raise AppApiException(1001, _("Unsupported file format"))
application = mk_instance.application
tool_list = mk_instance.get_tool_list()
@@ -1363,9 +1584,9 @@ def update_template_workflow(self, instance: Dict, app: Application):
).auth_resource_batch([t.id for t in tool_model_list])
try:
download_callback_url = work_flow_template.get("downloadCallbackUrl", "")
- if not download_callback_url.startswith("https://apps.fit2cloud.com"):
+ if not validate_trusted_url(download_callback_url, ALLOWED_CALLBACK_HOSTS):
raise AppApiException(500, _("Illegal download callback url"))
- requests.get(download_callback_url, timeout=5)
+ requests.get(download_callback_url, timeout=5, allow_redirects=False)
except Exception as e:
maxkb_logger.error(f"callback appstore tool download error: {e}")
@@ -1408,6 +1629,7 @@ def one(self, with_valid=True):
knowledge_id_list = [k.get("id") for k in knowledge_list]
else:
self.update_knowledge_node(application.work_flow, available_knowledge_dict)
+ update_form_knowledge_fields(application.work_flow)
return {
**ApplicationSerializerModel(application).data,
@@ -1600,6 +1822,9 @@ def batch_delete(self, instance: Dict, with_valid=True):
self.is_valid(raise_exception=True)
id_list = instance.get("id_list")
workspace_id = self.data.get("workspace_id")
+ id_list = list(
+ QuerySet(Application).filter(id__in=id_list, workspace_id=workspace_id).values_list("id", flat=True)
+ )
QuerySet(ApplicationVersion).filter(application_id__in=id_list).delete()
QuerySet(ResourceMapping).filter(Q(target_id__in=id_list) | Q(source_id__in=id_list)).delete()
@@ -1657,9 +1882,7 @@ def batch_clean_time(self, instance: Dict, with_valid=True):
class BatchCleanTimeSerializer(BatchSerializer):
clean_time = serializers.IntegerField(required=True, min_value=1, max_value=100000, label=_("Clean time"))
- file_clean_time = serializers.IntegerField(
- required=True, min_value=1, max_value=100000, label=_("File clean time")
- )
+ file_clean_time = serializers.IntegerField(required=True, min_value=1, max_value=100000, label=_("File clean time"))
def is_valid(self, *, model=None, raise_exception=False):
super().is_valid(model=model, raise_exception=True)
diff --git a/apps/application/serializers/application_chat.py b/apps/application/serializers/application_chat.py
index dd9464a9ba5..0236b178eb5 100644
--- a/apps/application/serializers/application_chat.py
+++ b/apps/application/serializers/application_chat.py
@@ -1,11 +1,12 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: application_chat.py
- @date:2025/6/10 11:06
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: application_chat.py
+@date:2025/6/10 11:06
+@desc:
"""
+
import datetime
import os
import re
@@ -45,80 +46,90 @@ class ApplicationChatResponseSerializers(serializers.Serializer):
class ApplicationChatRecordExportRequest(serializers.Serializer):
- select_ids = serializers.ListField(required=True, label=_("Chat ID List"),
- child=serializers.UUIDField(required=True, label=_("Chat ID")))
+ select_ids = serializers.ListField(
+ required=True, label=_("Chat ID List"), child=serializers.UUIDField(required=True, label=_("Chat ID"))
+ )
class ApplicationChatQuerySerializers(serializers.Serializer):
workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
abstract = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("summary"))
username = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("username"))
- start_time = serializers.DateField(format='%Y-%m-%d', label=_("Start time"))
- end_time = serializers.DateField(format='%Y-%m-%d', label=_("End time"))
+ start_time = serializers.DateField(format="%Y-%m-%d", label=_("Start time"))
+ end_time = serializers.DateField(format="%Y-%m-%d", label=_("End time"))
application_id = serializers.UUIDField(required=True, label=_("Application ID"))
- min_star = serializers.IntegerField(required=False, min_value=0,
- label=_("Minimum number of likes"))
- min_trample = serializers.IntegerField(required=False, min_value=0,
- label=_("Minimum number of clicks"))
- comparer = serializers.CharField(required=False, label=_("Comparator"), validators=[
- validators.RegexValidator(regex=re.compile("^and|or$"),
- message=_("Only supports and|or"), code=500)
- ])
+ min_star = serializers.IntegerField(required=False, min_value=0, label=_("Minimum number of likes"))
+ min_trample = serializers.IntegerField(required=False, min_value=0, label=_("Minimum number of clicks"))
+ comparer = serializers.CharField(
+ required=False,
+ label=_("Comparator"),
+ validators=[
+ validators.RegexValidator(regex=re.compile("^and|or$"), message=_("Only supports and|or"), code=500)
+ ],
+ )
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
- workspace_id = self.data.get('workspace_id')
- query_set = QuerySet(Application).filter(id=self.data.get('application_id'))
+ workspace_id = self.data.get("workspace_id")
+ query_set = QuerySet(Application).filter(id=self.data.get("application_id"))
if workspace_id:
query_set = query_set.filter(workspace_id=workspace_id)
if not query_set.exists():
- raise AppApiException(500, _('Application id does not exist'))
+ raise AppApiException(500, _("Application id does not exist"))
def get_end_time(self):
- d = datetime.datetime.strptime(self.data.get('end_time'), '%Y-%m-%d').date()
+ d = datetime.datetime.strptime(self.data.get("end_time"), "%Y-%m-%d").date()
naive = datetime.datetime.combine(d, datetime.time.max)
return timezone.make_aware(naive, timezone.get_default_timezone())
def get_start_time(self):
- d = datetime.datetime.strptime(self.data.get('start_time'), '%Y-%m-%d').date()
+ d = datetime.datetime.strptime(self.data.get("start_time"), "%Y-%m-%d").date()
naive = datetime.datetime.combine(d, datetime.time.min)
return timezone.make_aware(naive, timezone.get_default_timezone())
def get_query_set(self, select_ids=None):
end_time = self.get_end_time()
start_time = self.get_start_time()
- query_set = QuerySet(model=get_dynamics_model(
- {'application_chat.application_id': models.CharField(),
- 'application_chat.abstract': models.CharField(),
- 'application_chat.asker': models.JSONField(),
- "star_num": models.IntegerField(),
- 'trample_num': models.IntegerField(),
- 'comparer': models.CharField(),
- 'application_chat.update_time': models.DateTimeField(),
- 'application_chat.id': models.UUIDField(),
- 'application_chat_record_temp.id': models.UUIDField()}))
-
- base_query_dict = {'application_chat.application_id': self.data.get("application_id"),
- 'application_chat.update_time__gte': start_time,
- 'application_chat.update_time__lte': end_time,
- }
- if 'abstract' in self.data and self.data.get('abstract') is not None:
- base_query_dict['application_chat.abstract__icontains'] = self.data.get('abstract')
- if 'username' in self.data and self.data.get('username') is not None:
- base_query_dict['application_chat.asker__username__icontains'] = self.data.get('username')
-
+ query_set = QuerySet(
+ model=get_dynamics_model(
+ {
+ "application_chat.application_id": models.CharField(),
+ "application_chat.abstract": models.CharField(),
+ "application_chat.asker": models.JSONField(),
+ "star_num": models.IntegerField(),
+ "trample_num": models.IntegerField(),
+ "comparer": models.CharField(),
+ "application_chat.update_time": models.DateTimeField(),
+ "application_chat.id": models.UUIDField(),
+ "application_chat_record_temp.id": models.UUIDField(),
+ }
+ )
+ )
+
+ base_query_dict = {
+ "application_chat.application_id": self.data.get("application_id"),
+ "application_chat.update_time__gte": start_time,
+ "application_chat.update_time__lte": end_time,
+ }
+ if "abstract" in self.data and self.data.get("abstract") is not None:
+ base_query_dict["application_chat.abstract__icontains"] = self.data.get("abstract")
if select_ids is not None and len(select_ids) > 0:
- base_query_dict['application_chat.id__in'] = select_ids
+ base_query_dict["application_chat.id__in"] = select_ids
base_condition = Q(**base_query_dict)
+ if "username" in self.data and self.data.get("username") is not None:
+ username = self.data.get("username")
+ base_condition = base_condition & (
+ Q(**{"application_chat.asker__username__icontains": username})
+ | Q(**{"application_chat.asker__nick_name__icontains": username})
+ )
min_star_query = None
min_trample_query = None
- if 'min_star' in self.data and self.data.get('min_star') is not None:
- min_star_query = Q(star_num__gte=self.data.get('min_star'))
- if 'min_trample' in self.data and self.data.get('min_trample') is not None:
- min_trample_query = Q(trample_num__gte=self.data.get('min_trample'))
+ if "min_star" in self.data and self.data.get("min_star") is not None:
+ min_star_query = Q(star_num__gte=self.data.get("min_star"))
+ if "min_trample" in self.data and self.data.get("min_trample") is not None:
+ min_trample_query = Q(trample_num__gte=self.data.get("min_trample"))
if min_star_query is not None and min_trample_query is not None:
- if self.data.get(
- 'comparer') is not None and self.data.get('comparer') == 'or':
+ if self.data.get("comparer") is not None and self.data.get("comparer") == "or":
condition = base_condition & (min_star_query | min_trample_query)
else:
condition = base_condition & (min_star_query & min_trample_query)
@@ -129,71 +140,113 @@ def get_query_set(self, select_ids=None):
else:
condition = base_condition
- return {
- 'default_queryset': query_set.filter(condition).order_by("-application_chat.update_time")
- }
+ return {"default_queryset": query_set.filter(condition).order_by("-application_chat.update_time")}
def list(self, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
- return native_search(self.get_query_set(), select_string=get_file_content(
- os.path.join(PROJECT_DIR, "apps", "application", 'sql',
- ('list_application_chat_ee.sql' if ['PE', 'EE'].__contains__(
- edition) else 'list_application_chat.sql'))),
- with_table_name=False)
+ return native_search(
+ self.get_query_set(),
+ select_string=get_file_content(
+ os.path.join(
+ PROJECT_DIR,
+ "apps",
+ "application",
+ "sql",
+ (
+ "list_application_chat_ee.sql"
+ if ["PE", "EE"].__contains__(edition)
+ else "list_application_chat.sql"
+ ),
+ )
+ ),
+ with_table_name=False,
+ )
@staticmethod
def paragraph_list_to_string(paragraph_list):
return "\n**********\n".join(
- [f"{paragraph.get('title')}:\n{paragraph.get('content')}" for paragraph in
- paragraph_list] if paragraph_list is not None else '')
+ [f"{paragraph.get('title')}:\n{paragraph.get('content')}" for paragraph in paragraph_list]
+ if paragraph_list is not None
+ else ""
+ )
@staticmethod
def to_row(row: Dict):
- details = row.get('details') or {}
- padding_problem_text = ' '.join((node.get("answer", "") or "") for key, node in details.items() if
- node.get("type") == 'question-node')
- search_dataset_node_list = [(key, node) for key, node in details.items() if
- node.get("type") == 'search-dataset-node' or node.get(
- "step_type") == 'search_step' or node.get("type") == 'search-knowledge-node']
- reference_paragraph_len = '\n'.join([str(len(node.get('paragraph_list',
- []))) if key == 'search_step' else node.get(
- 'name') + ':' + str(
- len(node.get('paragraph_list', [])) if node.get('paragraph_list', []) is not None else '0') for
- key, node in search_dataset_node_list])
- reference_paragraph = '\n----------\n'.join(
- [ApplicationChatQuerySerializers.paragraph_list_to_string(node.get('paragraph_list',
- [])) if key == 'search_step' else node.get(
- 'name') + ':\n' + ApplicationChatQuerySerializers.paragraph_list_to_string(node.get('paragraph_list',
- [])) for
- key, node in search_dataset_node_list])
- improve_paragraph_list = row.get('improve_paragraph_list') or []
- vote_status_map = {'-1': '未投票', '0': '赞同', '1': '反对'}
- vote_reason_map = {'accurate': gettext('accurate'), 'complete': gettext('complete'),
- 'inaccurate': gettext('inaccurate'), 'incomplete': gettext('incomplete'),
- 'other': gettext('Other'), }
- return [str(row.get('chat_id')), row.get('abstract'), row.get('problem_text'), padding_problem_text,
- row.get('answer_text'), vote_status_map.get(row.get('vote_status')),
- vote_reason_map.get(row.get('vote_reason')),
- row.get('vote_other_content'),
- reference_paragraph_len,
- reference_paragraph,
- "\n".join([
+ details = row.get("details") or {}
+ padding_problem_text = " ".join(
+ (node.get("answer", "") or "") for key, node in details.items() if node.get("type") == "question-node"
+ )
+ search_dataset_node_list = [
+ (key, node)
+ for key, node in details.items()
+ if node.get("type") == "search-dataset-node"
+ or node.get("step_type") == "search_step"
+ or node.get("type") == "search-knowledge-node"
+ ]
+ reference_paragraph_len = "\n".join(
+ [
+ str(len(node.get("paragraph_list", [])))
+ if key == "search_step"
+ else node.get("name")
+ + ":"
+ + str(len(node.get("paragraph_list", [])) if node.get("paragraph_list", []) is not None else "0")
+ for key, node in search_dataset_node_list
+ ]
+ )
+ reference_paragraph = "\n----------\n".join(
+ [
+ ApplicationChatQuerySerializers.paragraph_list_to_string(node.get("paragraph_list", []))
+ if key == "search_step"
+ else node.get("name")
+ + ":\n"
+ + ApplicationChatQuerySerializers.paragraph_list_to_string(node.get("paragraph_list", []))
+ for key, node in search_dataset_node_list
+ ]
+ )
+ improve_paragraph_list = row.get("improve_paragraph_list") or []
+ vote_status_map = {"-1": "未投票", "0": "赞同", "1": "反对"}
+ vote_reason_map = {
+ "accurate": gettext("accurate"),
+ "complete": gettext("complete"),
+ "inaccurate": gettext("inaccurate"),
+ "incomplete": gettext("incomplete"),
+ "other": gettext("Other"),
+ }
+ return [
+ str(row.get("chat_id")),
+ row.get("abstract"),
+ row.get("problem_text"),
+ padding_problem_text,
+ row.get("answer_text"),
+ vote_status_map.get(row.get("vote_status")),
+ vote_reason_map.get(row.get("vote_reason")),
+ row.get("vote_other_content"),
+ reference_paragraph_len,
+ reference_paragraph,
+ "\n".join(
+ [
f"{improve_paragraph_list[index].get('title')}\n{improve_paragraph_list[index].get('content')}"
- for index in range(len(improve_paragraph_list))]),
- row.get('asker').get('username'),
- (row.get('message_tokens') or 0) + (row.get('answer_tokens') or 0),
- row.get('ip_address') or '-',
- get_source_display(row.get('source')),
- row.get('run_time'),
- str(row.get('create_time').astimezone(pytz.timezone(TIME_ZONE)).strftime('%Y-%m-%d %H:%M:%S')
- if row.get('create_time') is not None else None)]
+ for index in range(len(improve_paragraph_list))
+ ]
+ ),
+ row.get("asker").get("username"),
+ (row.get("message_tokens") or 0) + (row.get("answer_tokens") or 0),
+ row.get("ip_address") or "-",
+ get_source_display(row.get("source")),
+ row.get("run_time"),
+ str(
+ row.get("create_time").astimezone(pytz.timezone(TIME_ZONE)).strftime("%Y-%m-%d %H:%M:%S")
+ if row.get("create_time") is not None
+ else None
+ ),
+ ]
@staticmethod
def reset_value(value):
if isinstance(value, str):
- value = re.sub(ILLEGAL_CHARACTERS_RE, '', value)
- if value.startswith(('=', '+', '-', '@')):
+ value = re.sub(ILLEGAL_CHARACTERS_RE, "", value)
+ if value.startswith(("=", "+", "-", "@")):
value = "'" + value
if isinstance(value, datetime.datetime):
eastern = pytz.timezone(TIME_ZONE)
@@ -208,31 +261,50 @@ def export(self, data, with_valid=True):
def stream_response():
workbook = openpyxl.Workbook(write_only=True)
- worksheet = workbook.create_sheet(title='Sheet1')
+ worksheet = workbook.create_sheet(title="Sheet1")
current_page = 1
page_size = 500
- headers = [gettext('Conversation ID'), gettext('summary'), gettext('User Questions'),
- gettext('Problem after optimization'),
- gettext('answer'), gettext('User feedback'), gettext('Feedback reason'),
- gettext('Other reason content'),
- gettext('Reference segment number'),
- gettext('Section title + content'),
- gettext('Annotation'), gettext('User'), gettext('Consuming tokens'),
- gettext('Ip Address'), gettext('source'),
- gettext('Time consumed (s)'),
- gettext('Question Time')]
+ headers = [
+ gettext("Conversation ID"),
+ gettext("summary"),
+ gettext("User Questions"),
+ gettext("Problem after optimization"),
+ gettext("answer"),
+ gettext("User feedback"),
+ gettext("Feedback reason"),
+ gettext("Other reason content"),
+ gettext("Reference segment number"),
+ gettext("Section title + content"),
+ gettext("Annotation"),
+ gettext("User"),
+ gettext("Consuming tokens"),
+ gettext("Ip Address"),
+ gettext("source"),
+ gettext("Time consumed (s)"),
+ gettext("Question Time"),
+ ]
worksheet.append(headers)
- for data_list in native_page_handler(page_size, self.get_query_set(data.get('select_ids')),
- primary_key='application_chat_record_temp.id',
- primary_queryset='default_queryset',
- get_primary_value=lambda item: item.get('id'),
- select_string=get_file_content(
- os.path.join(PROJECT_DIR, "apps", "application", 'sql',
- ('export_application_chat_ee.sql' if ['PE',
- 'EE'].__contains__(
- edition) else 'export_application_chat.sql'))),
- with_table_name=False):
-
+ for data_list in native_page_handler(
+ page_size,
+ self.get_query_set(data.get("select_ids")),
+ primary_key="application_chat_record_temp.id",
+ primary_queryset="default_queryset",
+ get_primary_value=lambda item: item.get("id"),
+ select_string=get_file_content(
+ os.path.join(
+ PROJECT_DIR,
+ "apps",
+ "application",
+ "sql",
+ (
+ "export_application_chat_ee.sql"
+ if ["PE", "EE"].__contains__(edition)
+ else "export_application_chat.sql"
+ ),
+ )
+ ),
+ with_table_name=False,
+ ):
for item in data_list:
row = [self.reset_value(v) for v in self.to_row(item)]
worksheet.append(row)
@@ -244,56 +316,74 @@ def stream_response():
output.close()
workbook.close()
- response = StreamingHttpResponse(stream_response(),
- content_type='application/vnd.open.xmlformats-officedocument.spreadsheetml.sheet')
- response['Content-Disposition'] = 'attachment; filename="data.xlsx"'
+ response = StreamingHttpResponse(
+ stream_response(), content_type="application/vnd.open.xmlformats-officedocument.spreadsheetml.sheet"
+ )
+ response["Content-Disposition"] = 'attachment; filename="data.xlsx"'
return response
def page(self, current_page: int, page_size: int, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
- return native_page_search(current_page, page_size, self.get_query_set(), select_string=get_file_content(
- os.path.join(PROJECT_DIR, "apps", "application", 'sql',
- ('list_application_chat_ee.sql' if ['PE', 'EE'].__contains__(
- edition) else 'list_application_chat.sql'))),
- with_table_name=False)
+ return native_page_search(
+ current_page,
+ page_size,
+ self.get_query_set(),
+ select_string=get_file_content(
+ os.path.join(
+ PROJECT_DIR,
+ "apps",
+ "application",
+ "sql",
+ (
+ "list_application_chat_ee.sql"
+ if ["PE", "EE"].__contains__(edition)
+ else "list_application_chat.sql"
+ ),
+ )
+ ),
+ with_table_name=False,
+ )
class ChatCountSerializer(serializers.Serializer):
chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
def get_query_set(self):
- return QuerySet(ChatRecord).filter(chat_id=self.data.get('chat_id'))
+ return QuerySet(ChatRecord).filter(chat_id=self.data.get("chat_id"))
def update_chat(self):
self.is_valid(raise_exception=True)
- count_chat_record = native_search(self.get_query_set(), get_file_content(
- os.path.join(PROJECT_DIR, "apps", "application", 'sql', 'count_chat_record.sql')), with_search_one=True)
- QuerySet(Chat).filter(id=self.data.get('chat_id')).update(star_num=count_chat_record.get('star_num', 0) or 0,
- trample_num=count_chat_record.get('trample_num',
- 0) or 0,
- chat_record_count=count_chat_record.get(
- 'chat_record_count', 0) or 0,
- mark_sum=count_chat_record.get('mark_sum', 0) or 0)
+ count_chat_record = native_search(
+ self.get_query_set(),
+ get_file_content(os.path.join(PROJECT_DIR, "apps", "application", "sql", "count_chat_record.sql")),
+ with_search_one=True,
+ )
+ QuerySet(Chat).filter(id=self.data.get("chat_id")).update(
+ star_num=count_chat_record.get("star_num", 0) or 0,
+ trample_num=count_chat_record.get("trample_num", 0) or 0,
+ chat_record_count=count_chat_record.get("chat_record_count", 0) or 0,
+ mark_sum=count_chat_record.get("mark_sum", 0) or 0,
+ )
return True
def get_source_display(source):
- if not source or not isinstance(source, dict) or 'type' not in source:
- return '-'
- source_type = source.get('type')
+ if not source or not isinstance(source, dict) or "type" not in source:
+ return "-"
+ source_type = source.get("type")
# 定义映射关系
source_mapping = {
- ChatSourceChoices.ONLINE.value: gettext('Online Usage'),
- ChatSourceChoices.API_CALL.value: gettext('API Call'),
- ChatSourceChoices.ENTERPRISE_WECHAT.value: gettext('Enterprise WeChat'),
- ChatSourceChoices.WECHAT_PUBLIC_ACCOUNT.value: gettext('WeChat Public Account'),
- ChatSourceChoices.LARK.value: gettext('Lark'),
- ChatSourceChoices.DINGTALK.value: gettext('DingTalk'),
- ChatSourceChoices.ENTERPRISE_WECHAT_ROBOT.value: gettext('Enterprise WeChat Robot'),
- ChatSourceChoices.TRIGGER.value: gettext('Trigger'),
- ChatSourceChoices.SLACK.value: gettext('Slack'),
+ ChatSourceChoices.ONLINE.value: gettext("Online Usage"),
+ ChatSourceChoices.API_CALL.value: gettext("API Call"),
+ ChatSourceChoices.ENTERPRISE_WECHAT.value: gettext("Enterprise WeChat"),
+ ChatSourceChoices.WECHAT_PUBLIC_ACCOUNT.value: gettext("WeChat Public Account"),
+ ChatSourceChoices.LARK.value: gettext("Lark"),
+ ChatSourceChoices.DINGTALK.value: gettext("DingTalk"),
+ ChatSourceChoices.ENTERPRISE_WECHAT_ROBOT.value: gettext("Enterprise WeChat Robot"),
+ ChatSourceChoices.TRIGGER.value: gettext("Trigger"),
+ ChatSourceChoices.SLACK.value: gettext("Slack"),
}
return source_mapping.get(source_type, str(source_type))
diff --git a/apps/application/serializers/application_chat_link.py b/apps/application/serializers/application_chat_link.py
index a283d264f9b..f295d823406 100644
--- a/apps/application/serializers/application_chat_link.py
+++ b/apps/application/serializers/application_chat_link.py
@@ -5,18 +5,21 @@
@date: 2026/2/9 10:50
@desc:
"""
+import re
+
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from application.models import Chat, ChatShareLink, ShareLinkType, ChatRecord
from common.exception.app_exception import AppApiException
from common.utils.chat_link_code import UUIDEncoder
+from knowledge.models import PublicFileAccess
import uuid_utils.compat as uuid
class ShareChatRecordModelSerializer(serializers.ModelSerializer):
-
execution_details = serializers.SerializerMethodField()
+
class Meta:
model = ChatRecord
fields = ['id', 'problem_text', 'answer_text', 'answer_text_list',
@@ -38,6 +41,7 @@ def get_execution_details(chat_record):
for v in details.values() if v.get('type') == 'start-node'
]
+
class ChatRecordShareLinkRequestSerializer(serializers.Serializer):
chat_record_ids = serializers.ListSerializer(
child=serializers.UUIDField(),
@@ -52,6 +56,53 @@ def validate(self, attrs):
raise serializers.ValidationError(_('Chat record ids can not be empty'))
return attrs
+
+def extract_oss_file_urls(answer_text_list):
+ """从 answer_text_list 中提取所有 ./oss/file/ 开头的链接"""
+ file_urls = []
+ for answer_group in answer_text_list:
+ if not isinstance(answer_group, list):
+ answer_group = [answer_group]
+ for item in answer_group:
+ content = item.get('content', '')
+ urls = re.findall(r'\./oss/file/[\w-]+', content)
+ file_urls.extend(urls)
+ return file_urls
+
+
+def save_public_file_access(chat_record_list):
+ """提取聊天记录中的所有文件ID并入库 PublicFileAccess"""
+ file_ids = set()
+ for chat_record in chat_record_list:
+ urls = extract_oss_file_urls(chat_record.answer_text_list)
+ for url in urls:
+ file_id = url.replace('./oss/file/', '')
+ if file_id:
+ file_ids.add(file_id)
+
+ if not file_ids:
+ return
+
+ existing = set(
+ PublicFileAccess.objects.filter(
+ source_type='FILE',
+ source_id__in=list(file_ids)
+ ).values_list('source_id', flat=True)
+ )
+
+ new_records = [
+ PublicFileAccess(
+ id=uuid.uuid7(),
+ source_type='FILE',
+ source_id=file_id
+ )
+ for file_id in file_ids if file_id not in existing
+ ]
+
+ if new_records:
+ PublicFileAccess.objects.bulk_create(new_records)
+
+
class ChatRecordShareLinkSerializer(serializers.Serializer):
chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
application_id = serializers.UUIDField(required=True, label=_("Application ID"))
@@ -61,8 +112,11 @@ def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
chat_id = self.data.get('chat_id')
application_id = self.data.get('application_id')
+ user_id = self.data.get('user_id')
- chat_query_set = Chat.objects.filter(id=chat_id, application_id=application_id, is_deleted=False)
+ chat_query_set = Chat.objects.filter(
+ id=chat_id, application_id=application_id, chat_user_id=user_id, is_deleted=False
+ )
if not chat_query_set.exists():
raise AppApiException(500, _('Chat id does not exist'))
@@ -74,7 +128,8 @@ def generate_link(self, instance, with_valid=True):
if not instance.get('is_current_all', False):
chat_record_ids: list[str] = instance.get('chat_record_ids')
- record_count = ChatRecord.objects.filter(id__in=chat_record_ids, chat_id=self.data.get('chat_id')).count()
+ record_count = ChatRecord.objects.filter(id__in=chat_record_ids,
+ chat_id=self.data.get('chat_id')).count()
if record_count != len(chat_record_ids):
raise AppApiException(500, _('Invalid chat record ids'))
chat_id = self.data.get('chat_id')
@@ -99,7 +154,8 @@ def generate_link(self, instance, with_valid=True):
if existing:
return {'link': UUIDEncoder.encode(existing.id)}
-
+ chat_record_list = ChatRecord.objects.filter(id__in=sorted_ids)
+ save_public_file_access(chat_record_list)
chat_share_link_model = ChatShareLink(
id=uuid.uuid7(),
chat_id=chat_id,
diff --git a/apps/application/serializers/application_chat_record.py b/apps/application/serializers/application_chat_record.py
index b2f21f72008..08a5fcfdb6e 100644
--- a/apps/application/serializers/application_chat_record.py
+++ b/apps/application/serializers/application_chat_record.py
@@ -1,44 +1,65 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: application_chat_record.py
- @date:2025/6/10 15:10
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: application_chat_record.py
+@date:2025/6/10 15:10
+@desc:
"""
+
from functools import reduce
from typing import Dict
import uuid_utils.compat as uuid
-from django.db import transaction
-from django.db.models import QuerySet
-from django.db.models.aggregates import Max, Min
-from django.utils.translation import gettext_lazy as _, gettext
-from rest_framework import serializers
-from rest_framework.utils.formatting import lazy_format
-
-from application.models import ChatRecord, ApplicationAccessToken, Application
+from application.models import Application, ApplicationAccessToken, ChatRecord, Chat
from application.serializers.application_chat import ChatCountSerializer
from application.serializers.common import ChatInfo
from common.auth.authentication import get_is_permissions
from common.chunk import text_to_chunk
-from common.constants.permission_constants import PermissionConstants, RoleConstants, ViewPermission, CompareConstants
+from common.auth.constants.compare_constants import CompareConstants
+from common.auth.constants.permission_constants import PermissionConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.aggregate_permission import ViewPermission
from common.db.search import page_search
from common.exception.app_exception import AppApiException, AppUnauthorizedFailed
from common.utils.common import post
-from knowledge.models import Paragraph, Document, Problem, ProblemParagraphMapping, Knowledge
+from django.db import transaction
+from django.db.models import QuerySet
+from django.db.models.aggregates import Max, Min
+from django.utils.translation import gettext
+from django.utils.translation import gettext_lazy as _
+from knowledge.models import Document, Knowledge, Paragraph, Problem, ProblemParagraphMapping
from knowledge.serializers.common import get_embedding_model_id_by_knowledge_id, update_document_char_length
from knowledge.serializers.paragraph import ParagraphSerializers
from knowledge.task.embedding import embedding_by_paragraph, embedding_by_paragraph_list
+from rest_framework import serializers
+from rest_framework.utils.formatting import lazy_format
class ChatRecordSerializerModel(serializers.ModelSerializer):
class Meta:
model = ChatRecord
- fields = ['id', 'chat_id', 'vote_status','vote_reason','vote_other_content', 'problem_text', 'answer_text',
- 'message_tokens', 'answer_tokens', 'const', 'improve_paragraph_id_list', 'run_time', 'index',
- 'answer_text_list',
- 'create_time', 'update_time']
+ fields = [
+ "id",
+ "chat_id",
+ "vote_status",
+ "vote_reason",
+ "vote_other_content",
+ "problem_text",
+ "answer_text",
+ "message_tokens",
+ "answer_tokens",
+ "const",
+ "improve_paragraph_id_list",
+ "run_time",
+ "index",
+ "answer_text_list",
+ "create_time",
+ "update_time",
+ "version",
+ "question",
+ "messages",
+ ]
class ChatRecordOperateSerializer(serializers.Serializer):
@@ -49,42 +70,57 @@ class ChatRecordOperateSerializer(serializers.Serializer):
def is_valid(self, *, debug=False, raise_exception=False):
super().is_valid(raise_exception=True)
- workspace_id = self.data.get('workspace_id')
- query_set = QuerySet(Application).filter(id=self.data.get('application_id'))
+ workspace_id = self.data.get("workspace_id")
+ query_set = QuerySet(Application).filter(id=self.data.get("application_id"))
if workspace_id:
query_set = query_set.filter(workspace_id=workspace_id)
if not query_set.exists():
- raise AppApiException(500, _('Application id does not exist'))
- application_access_token = QuerySet(ApplicationAccessToken).filter(
- application_id=self.data.get('application_id')).first()
+ raise AppApiException(500, _("Application id does not exist"))
+ if (
+ not ChatRecord.objects.filter(
+ chat_id=self.data.get("chat_id"), chat__application_id=self.data.get("application_id")
+ ).exists()
+ and not debug
+ ):
+ raise AppApiException(500, _("Chat records for the application do not exist"))
+ application_access_token = (
+ QuerySet(ApplicationAccessToken).filter(application_id=self.data.get("application_id")).first()
+ )
if application_access_token is None:
- raise AppApiException(500, gettext('Application authentication information does not exist'))
+ raise AppApiException(500, gettext("Application authentication information does not exist"))
def get_chat_record(self):
- chat_record_id = self.data.get('chat_record_id')
- chat_id = self.data.get('chat_id')
+ chat_record_id = self.data.get("chat_record_id")
+ chat_id = self.data.get("chat_id")
chat_info: ChatInfo = ChatInfo.get_cache(chat_id)
if chat_info is not None:
- chat_record_list = [chat_record for chat_record in chat_info.chat_record_list if
- str(chat_record.id) == str(chat_record_id)]
+ chat_record_list = [
+ chat_record for chat_record in chat_info.chat_record_list if str(chat_record.id) == str(chat_record_id)
+ ]
if chat_record_list is not None and len(chat_record_list):
return chat_record_list[-1]
- return QuerySet(ChatRecord).filter(id=chat_record_id, chat_id=chat_id).first()
+ return (
+ QuerySet(ChatRecord)
+ .filter(id=chat_record_id, chat_id=chat_id, chat__application_id=self.data.get("application_id"))
+ .first()
+ )
def one(self, debug):
self.is_valid(debug=debug, raise_exception=True)
chat_record = self.get_chat_record()
if chat_record is None:
raise AppApiException(500, gettext("Conversation does not exist"))
- application_access_token = QuerySet(ApplicationAccessToken).filter(
- application_id=self.data.get('application_id')).first()
+ application_access_token = (
+ QuerySet(ApplicationAccessToken).filter(application_id=self.data.get("application_id")).first()
+ )
show_source = False
show_exec = False
if application_access_token is not None:
show_exec = application_access_token.show_exec
show_source = application_access_token.show_source
return ApplicationChatRecordQuerySerializers.reset_chat_record(
- chat_record, True if debug else show_source, True if debug else show_exec)
+ chat_record, True if debug else show_source, True if debug else show_exec
+ )
class ApplicationChatRecordQuerySerializers(serializers.Serializer):
@@ -95,28 +131,35 @@ class ApplicationChatRecordQuerySerializers(serializers.Serializer):
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
- workspace_id = self.data.get('workspace_id')
- query_set = QuerySet(Application).filter(id=self.data.get('application_id'))
+ workspace_id = self.data.get("workspace_id")
+ query_set = QuerySet(Application).filter(id=self.data.get("application_id"))
if workspace_id:
query_set = query_set.filter(workspace_id=workspace_id)
if not query_set.exists():
- raise AppApiException(500, _('Application id does not exist'))
+ raise AppApiException(500, _("Application id does not exist"))
+ if not Chat.objects.filter(
+ id=self.data.get("chat_id"), application_id=self.data.get("application_id")
+ ).exists():
+ raise AppApiException(500, _("Chat records for the application do not exist"))
def list(self, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
- QuerySet(ChatRecord).filter(chat_id=self.data.get('chat_id'))
- order_by = 'create_time' if self.data.get('order_asc') is None or self.data.get(
- 'order_asc') else '-create_time'
- return [ChatRecordSerializerModel(chat_record).data for chat_record in
- QuerySet(ChatRecord).filter(chat_id=self.data.get('chat_id')).order_by(order_by)]
+ order_by = "create_time" if self.data.get("order_asc") is None or self.data.get("order_asc") else "-create_time"
+ return [
+ ChatRecordSerializerModel(chat_record).data
+ for chat_record in QuerySet(ChatRecord)
+ .filter(chat_id=self.data.get("chat_id"), chat__application_id=self.data.get("application_id"))
+ .order_by(order_by)
+ ]
@staticmethod
def get_loop_workflow_node(details):
result = []
- for item in details.values():
- if item.get('type') == 'loop-node':
- for loop_item in item.get('loop_node_data') or []:
+
+ for item in details.values() if isinstance(details, dict) else details:
+ if item.get("type") == "loop-node":
+ for loop_item in item.get("loop_node_data") or []:
for inner_item in loop_item.values():
result.append(inner_item)
return result
@@ -125,67 +168,97 @@ def get_loop_workflow_node(details):
def reset_chat_record(chat_record, show_source, show_exec):
knowledge_list = []
paragraph_list = []
- if 'search_step' in chat_record.details and chat_record.details.get('search_step').get(
- 'paragraph_list') is not None:
- paragraph_list = chat_record.details.get('search_step').get(
- 'paragraph_list')
-
- for item in [*chat_record.details.values(),
- *ApplicationChatRecordQuerySerializers.get_loop_workflow_node(chat_record.details)]:
- if item.get('type') == 'search-knowledge-node' and item.get('show_knowledge', False):
- paragraph_list = paragraph_list + (item.get(
- 'paragraph_list') or [])
-
- if item.get('type') == 'reranker-node' and item.get('show_knowledge', False):
- paragraph_list = paragraph_list + [rl.get('metadata') for rl in (item.get('result_list') or []) if
- 'document_id' in (rl.get('metadata') or {}) and 'knowledge_id' in (
- rl.get(
- 'metadata') or {})]
- paragraph_list = list({p.get('id'): p for p in paragraph_list}.values())
- knowledge_list = knowledge_list + [{'id': knowledge_id, **knowledge} for knowledge_id, knowledge in
- reduce(lambda x, y: {**x, **y},
- [{row.get(
- 'knowledge_id'): {'knowledge_name': row.get(
- "knowledge_name"),
- 'knowledge_type': row.get('knowledge_type')}} for
- row in
- paragraph_list],
- {}).items()]
+ if (
+ "search_step" in chat_record.details
+ and chat_record.details.get("search_step").get("paragraph_list") is not None
+ ):
+ paragraph_list = chat_record.details.get("search_step").get("paragraph_list")
+
+ for item in [
+ *(chat_record.details.values() if isinstance(chat_record.details, dict) else chat_record.details),
+ *ApplicationChatRecordQuerySerializers.get_loop_workflow_node(chat_record.details),
+ ]:
+ if item.get("type") == "search-knowledge-node" and item.get("show_knowledge", False):
+ paragraph_list = paragraph_list + (item.get("paragraph_list") or [])
+
+ if item.get("type") == "reranker-node" and item.get("show_knowledge", False):
+ paragraph_list = paragraph_list + [
+ rl.get("metadata")
+ for rl in (item.get("result_list") or [])
+ if "document_id" in (rl.get("metadata") or {}) and "knowledge_id" in (rl.get("metadata") or {})
+ ]
+ paragraph_list = list({p.get("id"): p for p in paragraph_list}.values())
+ knowledge_list = knowledge_list + [
+ {"id": knowledge_id, **knowledge}
+ for knowledge_id, knowledge in reduce(
+ lambda x, y: {**x, **y},
+ [
+ {
+ row.get("knowledge_id"): {
+ "knowledge_name": row.get("knowledge_name"),
+ "knowledge_type": row.get("knowledge_type"),
+ }
+ }
+ for row in paragraph_list
+ ],
+ {},
+ ).items()
+ ]
if len(chat_record.improve_paragraph_id_list) > 0:
paragraph_model_list = QuerySet(Paragraph).filter(id__in=chat_record.improve_paragraph_id_list)
if len(paragraph_model_list) < len(chat_record.improve_paragraph_id_list):
paragraph_model_id_list = [str(p.id) for p in paragraph_model_list]
chat_record.improve_paragraph_id_list = list(
- filter(lambda p_id: paragraph_model_id_list.__contains__(p_id),
- chat_record.improve_paragraph_id_list))
+ filter(
+ lambda p_id: paragraph_model_id_list.__contains__(p_id), chat_record.improve_paragraph_id_list
+ )
+ )
chat_record.save()
- show_source_dict = {'knowledge_list': knowledge_list,
- 'paragraph_list': paragraph_list, }
- show_exec_dict = {'execution_details': [chat_record.details[key] for key in chat_record.details if
- (True if show_exec else chat_record.details[key].get(
- 'type') == 'start-node')]}
+ show_source_dict = {
+ "knowledge_list": knowledge_list,
+ "paragraph_list": paragraph_list,
+ }
+ if isinstance(chat_record.details, dict):
+ show_exec_dict = {
+ "execution_details": [
+ chat_record.details[key]
+ for key in chat_record.details
+ if (True if show_exec else chat_record.details[key].get("type") == "start-node")
+ ]
+ }
+ else:
+ show_exec_dict = {
+ "execution_details": [
+ item for item in chat_record.details if (True if show_exec else item.get("type") == "start-node")
+ ]
+ }
+
return {
**ChatRecordSerializerModel(chat_record).data,
- 'padding_problem_text': chat_record.details.get('problem_padding').get(
- 'padding_problem_text') if 'problem_padding' in chat_record.details else None,
+ "padding_problem_text": chat_record.details.get("problem_padding").get("padding_problem_text")
+ if "problem_padding" in chat_record.details
+ else None,
**(show_source_dict if show_source else {}),
- **(show_exec_dict if show_exec else show_exec_dict)
+ **(show_exec_dict if show_exec else show_exec_dict),
}
def page(self, current_page: int, page_size: int, with_valid=True, show_source=None, show_exec=None):
if with_valid:
self.is_valid(raise_exception=True)
- order_by = '-create_time' if self.data.get('order_asc') is None or self.data.get(
- 'order_asc') else 'create_time'
+ order_by = "-create_time" if self.data.get("order_asc") is None or self.data.get("order_asc") else "create_time"
if show_source is None:
show_source = True
if show_exec is None:
show_exec = True
- page = page_search(current_page, page_size,
- QuerySet(ChatRecord).filter(chat_id=self.data.get('chat_id')).order_by(order_by),
- post_records_handler=lambda chat_record: self.reset_chat_record(chat_record, show_source,
- show_exec))
+ page = page_search(
+ current_page,
+ page_size,
+ QuerySet(ChatRecord)
+ .filter(chat_id=self.data.get("chat_id"), chat__application_id=self.data.get("application_id"))
+ .order_by(order_by),
+ post_records_handler=lambda chat_record: self.reset_chat_record(chat_record, show_source, show_exec),
+ )
return page
@@ -202,26 +275,25 @@ class ChatRecordImproveSerializer(serializers.Serializer):
chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
- chat_record_id = serializers.UUIDField(required=True,
- label=_("Conversation record id"))
+ chat_record_id = serializers.UUIDField(required=True, label=_("Conversation record id"))
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
- workspace_id = self.data.get('workspace_id')
- query_set = QuerySet(Application).filter(id=self.data.get('application_id'))
+ workspace_id = self.data.get("workspace_id")
+ query_set = QuerySet(Application).filter(id=self.data.get("application_id"))
if workspace_id:
query_set = query_set.filter(workspace_id=workspace_id)
if not query_set.exists():
- raise AppApiException(500, _('Application id does not exist'))
+ raise AppApiException(500, _("Application id does not exist"))
def get(self, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
- chat_record_id = self.data.get('chat_record_id')
- chat_id = self.data.get('chat_id')
+ chat_record_id = self.data.get("chat_record_id")
+ chat_id = self.data.get("chat_id")
chat_record = QuerySet(ChatRecord).filter(id=chat_record_id, chat_id=chat_id).first()
if chat_record is None:
- raise AppApiException(500, gettext('Conversation record does not exist'))
+ raise AppApiException(500, gettext("Conversation record does not exist"))
if chat_record.improve_paragraph_id_list is None or len(chat_record.improve_paragraph_id_list) == 0:
return []
@@ -229,19 +301,21 @@ def get(self, with_valid=True):
if len(paragraph_model_list) < len(chat_record.improve_paragraph_id_list):
paragraph_model_id_list = [str(p.id) for p in paragraph_model_list]
chat_record.improve_paragraph_id_list = list(
- filter(lambda p_id: paragraph_model_id_list.__contains__(p_id),
- chat_record.improve_paragraph_id_list))
+ filter(lambda p_id: paragraph_model_id_list.__contains__(p_id), chat_record.improve_paragraph_id_list)
+ )
chat_record.save()
return [ParagraphModel(p).data for p in paragraph_model_list]
class ApplicationChatRecordImproveInstanceSerializer(serializers.Serializer):
- title = serializers.CharField(required=False, max_length=256, allow_null=True, allow_blank=True,
- label=_("Section title"))
+ title = serializers.CharField(
+ required=False, max_length=256, allow_null=True, allow_blank=True, label=_("Section title")
+ )
content = serializers.CharField(required=True, label=_("Paragraph content"))
- problem_text = serializers.CharField(required=False, max_length=256, allow_null=True, allow_blank=True,
- label=_("question"))
+ problem_text = serializers.CharField(
+ required=False, max_length=256, allow_null=True, allow_blank=True, label=_("question")
+ )
class ApplicationChatRecordAddKnowledgeSerializer(serializers.Serializer):
@@ -249,19 +323,22 @@ class ApplicationChatRecordAddKnowledgeSerializer(serializers.Serializer):
application_id = serializers.UUIDField(required=True, label=_("Application ID"))
knowledge_id = serializers.UUIDField(required=True, label=_("Knowledge base id"))
document_id = serializers.UUIDField(required=True, label=_("Document id"))
- chat_ids = serializers.ListSerializer(child=serializers.UUIDField(), required=True,
- label=_("Conversation ID"))
+ chat_ids = serializers.ListSerializer(child=serializers.UUIDField(), required=True, label=_("Conversation ID"))
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
- workspace_id = self.data.get('workspace_id')
- query_set = QuerySet(Application).filter(id=self.data.get('application_id'))
+ workspace_id = self.data.get("workspace_id")
+ query_set = QuerySet(Application).filter(id=self.data.get("application_id"))
if workspace_id:
query_set = query_set.filter(workspace_id=workspace_id)
if not query_set.exists():
- raise AppApiException(500, _('Application id does not exist'))
- if not Document.objects.filter(id=self.data['document_id'], knowledge_id=self.data['knowledge_id']).exists():
+ raise AppApiException(500, _("Application id does not exist"))
+ if not Document.objects.filter(id=self.data["document_id"], knowledge_id=self.data["knowledge_id"]).exists():
raise AppApiException(500, gettext("The document id is incorrect"))
+ if not ChatRecord.objects.filter(
+ chat_id__in=self.data["chat_ids"], chat__application_id=self.data["application_id"]
+ ).exists():
+ raise AppApiException(500, gettext("The chat id is incorrect"))
@staticmethod
def post_embedding_paragraph(paragraph_ids, knowledge_id):
@@ -270,31 +347,33 @@ def post_embedding_paragraph(paragraph_ids, knowledge_id):
@post(post_function=post_embedding_paragraph)
@transaction.atomic
- def post_improve(self, instance: Dict, request=None, scope='WORKSPACE', with_valid=True):
+ def post_improve(self, instance: Dict, request=None, scope="WORKSPACE", with_valid=True):
if with_valid:
ApplicationChatRecordAddKnowledgeSerializer(data=instance).is_valid(raise_exception=True)
self.is_valid(raise_exception=True)
- if scope == 'WORKSPACE':
- is_permission = get_is_permissions(request=request, workspace_id=self.data.get('workspace_id'),
- knowledge_id=self.data.get("knowledge_id"))(
+ if scope == "WORKSPACE":
+ is_permission = get_is_permissions(
+ request=request, workspace_id=self.data.get("workspace_id"), knowledge_id=self.data.get("knowledge_id")
+ )(
PermissionConstants.KNOWLEDGE_DOCUMENT_EDIT.get_workspace_knowledge_permission(),
PermissionConstants.KNOWLEDGE_DOCUMENT_EDIT.get_workspace_permission_workspace_manage_role(),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.KNOWLEDGE.get_workspace_knowledge_permission()],
- CompareConstants.AND),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.KNOWLEDGE.get_workspace_knowledge_permission()],
+ compare=CompareConstants.AND,
+ ),
)
else:
- is_permission = get_is_permissions(request=request, workspace_id=self.data.get('workspace_id'),
- knowledge_id=self.data.get("knowledge_id"))(
- PermissionConstants.RESOURCE_KNOWLEDGE_DOCUMENT_EDIT, RoleConstants.ADMIN
- )
+ is_permission = get_is_permissions(
+ request=request, workspace_id=self.data.get("workspace_id"), knowledge_id=self.data.get("knowledge_id")
+ )(PermissionConstants.RESOURCE_KNOWLEDGE_DOCUMENT_EDIT, RoleConstants.ADMIN)
if not is_permission:
- raise AppUnauthorizedFailed(403, gettext('No permission to access'))
+ raise AppUnauthorizedFailed(403, gettext("No permission to access"))
- chat_ids = instance['chat_ids']
- document_id = instance['document_id']
- knowledge_id = instance['knowledge_id']
+ chat_ids = instance["chat_ids"]
+ document_id = instance["document_id"]
+ knowledge_id = instance["knowledge_id"]
# 获取所有聊天记录
chat_record_list = list(ChatRecord.objects.filter(chat_id__in=chat_ids))
@@ -312,7 +391,7 @@ def post_improve(self, instance: Dict, request=None, scope='WORKSPACE', with_val
content=chat_record.answer_text,
knowledge_id=knowledge_id,
title=chat_record.problem_text,
- chunks=text_to_chunk(chat_record.answer_text)
+ chunks=text_to_chunk(chat_record.answer_text),
)
problem, _ = Problem.objects.get_or_create(content=chat_record.problem_text, knowledge_id=knowledge_id)
problem_paragraph_mapping = ProblemParagraphMapping(
@@ -320,7 +399,7 @@ def post_improve(self, instance: Dict, request=None, scope='WORKSPACE', with_val
knowledge_id=knowledge_id,
document_id=document_id,
problem_id=problem.id,
- paragraph_id=paragraph.id
+ paragraph_id=paragraph.id,
)
paragraphs.append(paragraph)
paragraph_ids.append(paragraph.id)
@@ -335,19 +414,17 @@ def post_improve(self, instance: Dict, request=None, scope='WORKSPACE', with_val
ProblemParagraphMapping.objects.bulk_create(problem_paragraph_mappings)
# 批量保存聊天记录
- ChatRecord.objects.bulk_update(chat_record_list, ['improve_paragraph_id_list'])
+ ChatRecord.objects.bulk_update(chat_record_list, ["improve_paragraph_id_list"])
update_document_char_length(document_id)
for chat_id in chat_ids:
- ChatCountSerializer(data={'chat_id': chat_id}).update_chat()
+ ChatCountSerializer(data={"chat_id": chat_id}).update_chat()
return paragraph_ids, knowledge_id
@staticmethod
def prepend_paragraphs(document_id, paragraphs):
# 获取所有现有段落
- existing_paragraphs = list(Paragraph.objects.filter(
- document_id=document_id
- ).order_by('position'))
+ existing_paragraphs = list(Paragraph.objects.filter(document_id=document_id).order_by("position"))
# 计算新段落数量
new_count = len(paragraphs)
@@ -360,7 +437,7 @@ def prepend_paragraphs(document_id, paragraphs):
# 批量更新现有段落位置
if existing_paragraphs:
- Paragraph.objects.bulk_update(existing_paragraphs, ['position'])
+ Paragraph.objects.bulk_update(existing_paragraphs, ["position"])
# 为新段落分配位置,从1开始
for i, paragraph in enumerate(paragraphs):
@@ -370,8 +447,7 @@ def prepend_paragraphs(document_id, paragraphs):
class ApplicationChatRecordImproveSerializer(serializers.Serializer):
chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
- chat_record_id = serializers.UUIDField(required=True,
- label=_("Conversation record id"))
+ chat_record_id = serializers.UUIDField(required=True, label=_("Conversation record id"))
knowledge_id = serializers.UUIDField(required=True, label=_("Knowledge base id"))
@@ -382,21 +458,24 @@ class ApplicationChatRecordImproveSerializer(serializers.Serializer):
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
- workspace_id = self.data.get('workspace_id')
- query_set = QuerySet(Application).filter(id=self.data.get('application_id'))
+ workspace_id = self.data.get("workspace_id")
+ query_set = QuerySet(Application).filter(id=self.data.get("application_id"))
if workspace_id:
query_set = query_set.filter(workspace_id=workspace_id)
if not query_set.exists():
- raise AppApiException(500, _('Application id does not exist'))
+ raise AppApiException(500, _("Application id does not exist"))
- query_set = QuerySet(Knowledge).filter(id=self.data.get('knowledge_id'))
+ query_set = QuerySet(Knowledge).filter(id=self.data.get("knowledge_id"))
if workspace_id:
query_set = query_set.filter(workspace_id=workspace_id)
if not query_set.exists():
- raise AppApiException(500, _('Knowledge id does not exist'))
+ raise AppApiException(500, _("Knowledge id does not exist"))
- if not QuerySet(Document).filter(id=self.data.get('document_id'),
- knowledge_id=self.data.get('knowledge_id')).exists():
+ if (
+ not QuerySet(Document)
+ .filter(id=self.data.get("document_id"), knowledge_id=self.data.get("knowledge_id"))
+ .exists()
+ ):
raise AppApiException(500, gettext("The document id is incorrect"))
@staticmethod
@@ -408,38 +487,41 @@ def post_embedding_paragraph(chat_record, paragraph_id, knowledge_id):
@post(post_function=post_embedding_paragraph)
@transaction.atomic
- def improve(self, instance: Dict, request=None, scope='WORKSPACE', with_valid=True):
+ def improve(self, instance: Dict, request=None, scope="WORKSPACE", with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
- if scope == 'WORKSPACE':
- is_permission = get_is_permissions(request, workspace_id=self.data.get('workspace_id'),
- knowledge_id=self.data.get("knowledge_id"))(
+ if scope == "WORKSPACE":
+ is_permission = get_is_permissions(
+ request, workspace_id=self.data.get("workspace_id"), knowledge_id=self.data.get("knowledge_id")
+ )(
PermissionConstants.KNOWLEDGE_DOCUMENT_EDIT.get_workspace_knowledge_permission(),
PermissionConstants.KNOWLEDGE_DOCUMENT_EDIT.get_workspace_permission_workspace_manage_role(),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.KNOWLEDGE.get_workspace_knowledge_permission()],
- CompareConstants.AND),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.KNOWLEDGE.get_workspace_knowledge_permission()],
+ compare=CompareConstants.AND,
+ ),
)
else:
- is_permission = get_is_permissions(request, workspace_id=self.data.get('workspace_id'),
- knowledge_id=self.data.get("knowledge_id"))(
- PermissionConstants.RESOURCE_KNOWLEDGE_DOCUMENT_EDIT, RoleConstants.ADMIN
- )
+ is_permission = get_is_permissions(
+ request, workspace_id=self.data.get("workspace_id"), knowledge_id=self.data.get("knowledge_id")
+ )(PermissionConstants.RESOURCE_KNOWLEDGE_DOCUMENT_EDIT, RoleConstants.ADMIN)
if not is_permission:
- raise AppUnauthorizedFailed(403, gettext('No permission to access'))
+ raise AppUnauthorizedFailed(403, gettext("No permission to access"))
ApplicationChatRecordImproveInstanceSerializer(data=instance).is_valid(raise_exception=True)
- chat_record_id = self.data.get('chat_record_id')
- chat_id = self.data.get('chat_id')
+ chat_record_id = self.data.get("chat_record_id")
+ chat_id = self.data.get("chat_id")
chat_record = QuerySet(ChatRecord).filter(id=chat_record_id, chat_id=chat_id).first()
if chat_record is None:
- raise AppApiException(500, gettext('Conversation record does not exist'))
+ raise AppApiException(500, gettext("Conversation record does not exist"))
document_id = self.data.get("document_id")
knowledge_id = self.data.get("knowledge_id")
- max_position = Paragraph.objects.filter(document_id=document_id).aggregate(
- max_position=Max('position')
- )['max_position'] or 0
+ max_position = (
+ Paragraph.objects.filter(document_id=document_id).aggregate(max_position=Max("position"))["max_position"]
+ or 0
+ )
paragraph = Paragraph(
id=uuid.uuid7(),
document_id=document_id,
@@ -449,13 +531,17 @@ def improve(self, instance: Dict, request=None, scope='WORKSPACE', with_valid=Tr
position=max_position + 1,
chunks=text_to_chunk(instance.get("content", "")),
)
- problem_text = instance.get('problem_text') if instance.get(
- 'problem_text') is not None else chat_record.problem_text
+ problem_text = (
+ instance.get("problem_text") if instance.get("problem_text") is not None else chat_record.problem_text
+ )
problem, _ = QuerySet(Problem).get_or_create(content=problem_text, knowledge_id=knowledge_id)
- problem_paragraph_mapping = ProblemParagraphMapping(id=uuid.uuid7(), knowledge_id=knowledge_id,
- document_id=document_id,
- problem_id=problem.id,
- paragraph_id=paragraph.id)
+ problem_paragraph_mapping = ProblemParagraphMapping(
+ id=uuid.uuid7(),
+ knowledge_id=knowledge_id,
+ document_id=document_id,
+ problem_id=problem.id,
+ paragraph_id=paragraph.id,
+ )
# 插入段落
paragraph.save()
# 插入关联问题
@@ -464,14 +550,13 @@ def improve(self, instance: Dict, request=None, scope='WORKSPACE', with_valid=Tr
update_document_char_length(document_id)
# 添加标注
chat_record.save()
- ChatCountSerializer(data={'chat_id': chat_id}).update_chat()
+ ChatCountSerializer(data={"chat_id": chat_id}).update_chat()
return ChatRecordSerializerModel(chat_record).data, paragraph.id, knowledge_id
class Operate(serializers.Serializer):
chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
- chat_record_id = serializers.UUIDField(required=True,
- label=_("Conversation record id"))
+ chat_record_id = serializers.UUIDField(required=True, label=_("Conversation record id"))
knowledge_id = serializers.UUIDField(required=True, label=_("Knowledge base id"))
@@ -481,49 +566,63 @@ class Operate(serializers.Serializer):
workspace_id = serializers.CharField(required=True, label=_("Workspace ID"))
- def delete(self, request=None, scope='WORKSPACE', with_valid=True):
+ def delete(self, request=None, scope="WORKSPACE", with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
- if scope == 'WORKSPACE':
- is_permission = get_is_permissions(request=request, workspace_id=self.data.get('workspace_id'),
- knowledge_id=self.data.get("knowledge_id"))(
+ if scope == "WORKSPACE":
+ is_permission = get_is_permissions(
+ request=request,
+ workspace_id=self.data.get("workspace_id"),
+ knowledge_id=self.data.get("knowledge_id"),
+ )(
PermissionConstants.KNOWLEDGE_DOCUMENT_EDIT.get_workspace_knowledge_permission(),
PermissionConstants.KNOWLEDGE_DOCUMENT_EDIT.get_workspace_permission_workspace_manage_role(),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.KNOWLEDGE.get_workspace_knowledge_permission()],
- CompareConstants.AND),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.KNOWLEDGE.get_workspace_knowledge_permission()],
+ compare=CompareConstants.AND,
+ ),
)
else:
- is_permission = get_is_permissions(request=request, workspace_id=self.data.get('workspace_id'),
- knowledge_id=self.data.get("knowledge_id"))(
- PermissionConstants.RESOURCE_KNOWLEDGE_DOCUMENT_EDIT, RoleConstants.ADMIN
- )
+ is_permission = get_is_permissions(
+ request=request,
+ workspace_id=self.data.get("workspace_id"),
+ knowledge_id=self.data.get("knowledge_id"),
+ )(PermissionConstants.RESOURCE_KNOWLEDGE_DOCUMENT_EDIT, RoleConstants.ADMIN)
if not is_permission:
- raise AppUnauthorizedFailed(403, gettext('No permission to access'))
-
- workspace_id = self.data.get('workspace_id')
- chat_record_id = self.data.get('chat_record_id')
- chat_id = self.data.get('chat_id')
- knowledge_id = self.data.get('knowledge_id')
- document_id = self.data.get('document_id')
- paragraph_id = self.data.get('paragraph_id')
+ raise AppUnauthorizedFailed(403, gettext("No permission to access"))
+
+ workspace_id = self.data.get("workspace_id")
+ chat_record_id = self.data.get("chat_record_id")
+ chat_id = self.data.get("chat_id")
+ knowledge_id = self.data.get("knowledge_id")
+ document_id = self.data.get("document_id")
+ paragraph_id = self.data.get("paragraph_id")
chat_record = QuerySet(ChatRecord).filter(id=chat_record_id, chat_id=chat_id).first()
if chat_record is None:
- raise AppApiException(500, gettext('Conversation record does not exist'))
+ raise AppApiException(500, gettext("Conversation record does not exist"))
if not chat_record.improve_paragraph_id_list.__contains__(uuid.UUID(paragraph_id)):
message = lazy_format(
gettext(
- 'The paragraph id is wrong. The current conversation record does not exist. [{paragraph_id}] paragraph id'),
- paragraph_id=paragraph_id)
+ "The paragraph id is wrong. The current conversation record does not exist. [{paragraph_id}] paragraph id"
+ ),
+ paragraph_id=paragraph_id,
+ )
raise AppApiException(500, message.__str__())
- chat_record.improve_paragraph_id_list = [row for row in chat_record.improve_paragraph_id_list if
- str(row) != paragraph_id]
+ chat_record.improve_paragraph_id_list = [
+ row for row in chat_record.improve_paragraph_id_list if str(row) != paragraph_id
+ ]
chat_record.save()
o = ParagraphSerializers.Operate(
- data={"workspace_id": workspace_id, "knowledge_id": knowledge_id, 'document_id': document_id,
- "paragraph_id": paragraph_id})
+ data={
+ "workspace_id": workspace_id,
+ "knowledge_id": knowledge_id,
+ "document_id": document_id,
+ "paragraph_id": paragraph_id,
+ }
+ )
o.is_valid(raise_exception=True)
o.delete()
return True
diff --git a/apps/application/serializers/application_version.py b/apps/application/serializers/application_version.py
index 5856220336f..ac0fcf49de8 100644
--- a/apps/application/serializers/application_version.py
+++ b/apps/application/serializers/application_version.py
@@ -1,11 +1,12 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: application_version.py
- @date:2025/6/3 16:25
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: application_version.py
+@date:2025/6/3 16:25
+@desc:
"""
+
from typing import Dict
from django.db.models import QuerySet
@@ -19,21 +20,33 @@
class ApplicationVersionQuerySerializer(serializers.Serializer):
application_id = serializers.UUIDField(required=True, label=_("Application ID"))
- name = serializers.CharField(required=False, allow_null=True, allow_blank=True,
- label=_("summary"))
+ name = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("summary"))
class ApplicationVersionModelSerializer(serializers.ModelSerializer):
class Meta:
model = ApplicationVersion
- fields = ['id', 'name', 'workspace_id', 'application_id', 'work_flow', 'publish_user_id', 'publish_user_name',
- 'create_time',
- 'update_time']
+ fields = [
+ "id",
+ "name",
+ "publish_desc",
+ "workspace_id",
+ "application_id",
+ "work_flow",
+ "publish_user_id",
+ "publish_user_name",
+ "create_time",
+ "update_time",
+ ]
class ApplicationVersionEditSerializer(serializers.Serializer):
- name = serializers.CharField(required=False, max_length=128, allow_null=True, allow_blank=True,
- label=_("Version Name"))
+ name = serializers.CharField(
+ required=False, max_length=128, allow_null=True, allow_blank=True, label=_("Version Name")
+ )
+ publish_desc = serializers.CharField(
+ required=False, max_length=1024, allow_null=True, allow_blank=True, label=_("Publish Description")
+ )
class ApplicationVersionSerializer(serializers.Serializer):
@@ -43,11 +56,11 @@ class Query(serializers.Serializer):
workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
def get_query_set(self, query):
- query_set = QuerySet(ApplicationVersion).filter(application_id=query.get('application_id'))
- if 'name' in query and query.get('name') is not None:
- query_set = query_set.filter(name__contains=query.get('name'))
- if 'workspace_id' in self.data and self.data.get('workspace_id') is not None:
- query_set = query_set.filter(workspace_id=self.data.get('workspace_id'))
+ query_set = QuerySet(ApplicationVersion).filter(application_id=query.get("application_id"))
+ if "name" in query and query.get("name") is not None:
+ query_set = query_set.filter(name__contains=query.get("name"))
+ if "workspace_id" in self.data and self.data.get("workspace_id") is not None:
+ query_set = query_set.filter(workspace_id=self.data.get("workspace_id"))
return query_set.order_by("-create_time")
def list(self, query, with_valid=True):
@@ -60,48 +73,57 @@ def list(self, query, with_valid=True):
def page(self, query, current_page, page_size, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
- return page_search(current_page, page_size,
- self.get_query_set(query),
- post_records_handler=lambda v: ApplicationVersionModelSerializer(v).data)
+ return page_search(
+ current_page,
+ page_size,
+ self.get_query_set(query),
+ post_records_handler=lambda v: ApplicationVersionModelSerializer(v).data,
+ )
class Operate(serializers.Serializer):
workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
application_id = serializers.UUIDField(required=True, label=_("Application ID"))
- application_version_id = serializers.UUIDField(required=True,
- label=_("Application version ID"))
+ application_version_id = serializers.UUIDField(required=True, label=_("Application version ID"))
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
- workspace_id = self.data.get('workspace_id')
- query_set = QuerySet(Application).filter(id=self.data.get('application_id'))
+ workspace_id = self.data.get("workspace_id")
+ query_set = QuerySet(Application).filter(id=self.data.get("application_id"))
if workspace_id:
query_set = query_set.filter(workspace_id=workspace_id)
if not query_set.exists():
- raise AppApiException(500, _('Application id does not exist'))
+ raise AppApiException(500, _("Application id does not exist"))
def one(self, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
- application_version = QuerySet(ApplicationVersion).filter(application_id=self.data.get('application_id'),
- id=self.data.get(
- 'application_version_id')).first()
+ application_version = (
+ QuerySet(ApplicationVersion)
+ .filter(application_id=self.data.get("application_id"), id=self.data.get("application_version_id"))
+ .first()
+ )
if application_version is not None:
return ApplicationVersionModelSerializer(application_version).data
else:
- raise AppApiException(500, _('Workflow version does not exist'))
+ raise AppApiException(500, _("Workflow version does not exist"))
def edit(self, instance: Dict, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
ApplicationVersionEditSerializer(data=instance).is_valid(raise_exception=True)
- application_version = QuerySet(ApplicationVersion).filter(application_id=self.data.get('application_id'),
- id=self.data.get(
- 'application_version_id')).first()
+ application_version = (
+ QuerySet(ApplicationVersion)
+ .filter(application_id=self.data.get("application_id"), id=self.data.get("application_version_id"))
+ .first()
+ )
if application_version is not None:
- name = instance.get('name', None)
+ name = instance.get("name", None)
+ publish_desc = instance.get("publish_desc", None)
if name is not None and len(name) > 0:
application_version.name = name
+ if publish_desc is not None and len(publish_desc) > 0:
+ application_version.publish_desc = publish_desc
application_version.save()
return ApplicationVersionModelSerializer(application_version).data
else:
- raise AppApiException(500, _('Workflow version does not exist'))
+ raise AppApiException(500, _("Workflow version does not exist"))
diff --git a/apps/application/serializers/common.py b/apps/application/serializers/common.py
index b41ce79e470..c1da528f3dc 100644
--- a/apps/application/serializers/common.py
+++ b/apps/application/serializers/common.py
@@ -1,19 +1,35 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: common.py
- @date:2025/6/9 13:42
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: common.py
+@date:2025/6/9 13:42
+@desc:
"""
+
from typing import List
+from application.models import Application, ApplicationTypeChoices, ApplicationVersion, Chat, ChatRecord, ChatUserType
+from application.serializers.application_chat import ChatCountSerializer
+from common.constants.cache_version import Cache_Version
+from common.database_model_manage.database_model_manage import DatabaseModelManage
+from common.exception.app_exception import ChatException
from django.core.cache import cache
from django.db.models import QuerySet
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
-from application.models import Application, ChatRecord, Chat, ApplicationVersion, ChatUserType, ApplicationTypeChoices
+from system_manage.models.chat_user_token_quota import ChatUserTokenQuota
+
+from application.models import (
+ Application,
+ ChatRecord,
+ Chat,
+ ApplicationVersion,
+ ChatUserType,
+ ApplicationTypeChoices,
+ ExecuteType,
+)
from application.serializers.application_chat import ChatCountSerializer
from common.constants.cache_version import Cache_Version
from common.database_model_manage.database_model_manage import DatabaseModelManage
@@ -26,12 +42,7 @@
class ToolExecute:
- def __init__(self, tool_id: str,
- tool_record_id: str,
- workspace_id: str,
- source_type,
- source_id,
- debug=False):
+ def __init__(self, tool_id: str, tool_record_id: str, workspace_id: str, source_type, source_id, debug=False):
self.tool_id = tool_id
self.workspace_id = workspace_id
self.source_type = source_type
@@ -42,8 +53,12 @@ def __init__(self, tool_id: str,
def get_record(self):
if self.tool_record_id:
if self.debug:
- return self.to_record(cache.get(Cache_Version.TOOL_WORKFLOW_EXECUTE.get_key(key=self.tool_record_id),
- version=Cache_Version.TOOL_WORKFLOW_EXECUTE.get_version()))
+ return self.to_record(
+ cache.get(
+ Cache_Version.TOOL_WORKFLOW_EXECUTE.get_key(key=self.tool_record_id),
+ version=Cache_Version.TOOL_WORKFLOW_EXECUTE.get_version(),
+ )
+ )
else:
return QuerySet(ToolRecord).filter(tool_id=self.tool_id, id=self.tool_record_id).first()
return None
@@ -51,61 +66,136 @@ def get_record(self):
def to_record(self, tool_record_dict):
if tool_record_dict is None:
return None
- return ToolRecord(id=tool_record_dict.get('id'),
- tool_id=tool_record_dict.get('tool_id'),
- workspace_id=tool_record_dict.get('workspace_id'),
- source_type=tool_record_dict.get('source_type'),
- source_id=tool_record_dict.get('source_id'),
- meta=tool_record_dict.get('meta'),
- state=tool_record_dict.get('state'),
- run_time=tool_record_dict.get('run_time'))
+ return ToolRecord(
+ id=tool_record_dict.get("id"),
+ tool_id=tool_record_dict.get("tool_id"),
+ workspace_id=tool_record_dict.get("workspace_id"),
+ source_type=tool_record_dict.get("source_type"),
+ source_id=tool_record_dict.get("source_id"),
+ meta=tool_record_dict.get("meta"),
+ state=tool_record_dict.get("state"),
+ run_time=tool_record_dict.get("run_time"),
+ )
def to_dict(self, tool_record):
- return {'id': tool_record.id,
- 'tool_id': tool_record.tool_id,
- 'workspace_id': tool_record.workspace_id,
- 'source_type': tool_record.source_type,
- 'source_id': tool_record.source_id,
- 'meta': tool_record.meta,
- 'state': tool_record.state,
- 'run_time': tool_record.run_time}
+ return {
+ "id": tool_record.id,
+ "tool_id": tool_record.tool_id,
+ "workspace_id": tool_record.workspace_id,
+ "source_type": tool_record.source_type,
+ "source_id": tool_record.source_id,
+ "meta": tool_record.meta,
+ "state": tool_record.state,
+ "run_time": tool_record.run_time,
+ }
def set_record(self, tool_record):
- cache.set(Cache_Version.TOOL_WORKFLOW_EXECUTE.get_key(key=self.tool_record_id), self.to_dict(tool_record),
- version=Cache_Version.TOOL_WORKFLOW_EXECUTE.get_version(),
- timeout=60 * 30)
+ cache.set(
+ Cache_Version.TOOL_WORKFLOW_EXECUTE.get_key(key=self.tool_record_id),
+ self.to_dict(tool_record),
+ version=Cache_Version.TOOL_WORKFLOW_EXECUTE.get_version(),
+ timeout=60 * 30,
+ )
if not self.debug:
- QuerySet(ToolRecord).update_or_create(id=tool_record.id,
- create_defaults={'id': tool_record.id,
- 'tool_id': tool_record.tool_id,
- 'state': tool_record.state,
- 'workspace_id': tool_record.workspace_id,
- "source_type": tool_record.source_type,
- 'source_id': tool_record.source_id,
- 'meta': tool_record.meta,
- 'run_time': tool_record.run_time},
- defaults={
- 'workspace_id': tool_record.workspace_id,
- 'tool_id': tool_record.tool_id,
- "source_type": tool_record.source_type,
- 'source_id': tool_record.source_id,
- 'state': tool_record.state,
- 'meta': tool_record.meta,
- 'run_time': tool_record.run_time
- })
+ QuerySet(ToolRecord).update_or_create(
+ id=tool_record.id,
+ create_defaults={
+ "id": tool_record.id,
+ "tool_id": tool_record.tool_id,
+ "state": tool_record.state,
+ "workspace_id": tool_record.workspace_id,
+ "source_type": tool_record.source_type,
+ "source_id": tool_record.source_id,
+ "meta": tool_record.meta,
+ "run_time": tool_record.run_time,
+ },
+ defaults={
+ "workspace_id": tool_record.workspace_id,
+ "tool_id": tool_record.tool_id,
+ "source_type": tool_record.source_type,
+ "source_id": tool_record.source_id,
+ "state": tool_record.state,
+ "meta": tool_record.meta,
+ "run_time": tool_record.run_time,
+ },
+ )
+
+
+def load_debug_workflow_context(chat_record_id):
+ """
+ 按记录 id 解析历史工作流 context:优先 Redis 调试缓存 DEBUG_WORKFLOW_CONTEXT,其次 DB ChatRecord.workflow_context。
+ 属业务层逻辑(依赖 ChatRecord),供基于 ChatRecord 的续跑场景(应用对话、子应用节点)复用,
+ 作为 WorkflowManage.from_context 的 get_context 回调传入;引擎本身不关心 context 来源。
+ """
+ try:
+ cache_key = Cache_Version.DEBUG_WORKFLOW_CONTEXT.get_key(chat_record_id=str(chat_record_id))
+ context_data = cache.get(cache_key)
+ if not context_data:
+ chat_record = ChatRecord.objects.filter(id=chat_record_id).first()
+ if not chat_record or not chat_record.workflow_context:
+ return None
+ context_data = chat_record.workflow_context
+ return context_data
+ except Exception:
+ import traceback
+
+ traceback.print_exc()
+ return None
+
+
+def resolve_chat_user(chat_user_id, chat_user_type, asker=None):
+ """
+ 根据对话用户 id / 类型解析出对话用户信息。
+ - 登录的对话用户(CHAT_USER):从 ChatUser 表取真实信息
+ - 匿名/其他:优先用 asker(dict 或用户名),否则回退为“游客”
+ """
+ from system_manage.models import ChatUser
+
+ if chat_user_type == ChatUserType.CHAT_USER.value:
+ chat_user = QuerySet(ChatUser).filter(id=chat_user_id).first()
+ return {
+ "id": str(chat_user.id),
+ "email": chat_user.email,
+ "phone": chat_user.phone,
+ "nick_name": chat_user.nick_name,
+ "username": chat_user.username,
+ "source": chat_user.source,
+ }
+ if asker:
+ if isinstance(asker, dict):
+ return asker
+ return {"username": asker}
+ return {"username": "游客"}
+
+
+def resolve_chat_user_group(chat_user):
+ chat_user_id = chat_user.get("id")
+ if not chat_user_id:
+ return []
+ user_group_relation_model = DatabaseModelManage.get_model("user_group_relation")
+ if user_group_relation_model:
+ return [
+ {"id": user_group_relation.group_id, "name": user_group_relation.group.name}
+ for user_group_relation in QuerySet(user_group_relation_model)
+ .select_related("group")
+ .filter(user_id=chat_user_id)
+ ]
+ return []
class ChatInfo:
- def __init__(self,
- chat_id: str,
- chat_user_id: str,
- chat_user_type: str,
- ip_address: str,
- source: {},
- knowledge_id_list: List[str],
- exclude_document_id_list: list[str],
- application_id: str,
- debug=False):
+ def __init__(
+ self,
+ chat_id: str,
+ chat_user_id: str,
+ chat_user_type: str,
+ ip_address: str,
+ source: {},
+ knowledge_id_list: List[str],
+ exclude_document_id_list: list[str],
+ application_id: str,
+ debug=False,
+ ):
"""
:param chat_id: 对话id
:param chat_user_id 对话用户id
@@ -133,36 +223,44 @@ def __init__(self,
@staticmethod
def get_no_references_setting(knowledge_setting, model_setting):
no_references_setting = knowledge_setting.get(
- 'no_references_setting', {
- 'status': 'ai_questioning',
- 'value': '{question}'})
- if no_references_setting.get('status') == 'ai_questioning':
- no_references_prompt = model_setting.get('no_references_prompt', '{question}')
- no_references_setting['value'] = no_references_prompt if len(no_references_prompt) > 0 else "{question}"
+ "no_references_setting", {"status": "ai_questioning", "value": "{question}"}
+ )
+ if no_references_setting.get("status") == "ai_questioning":
+ no_references_prompt = model_setting.get("no_references_prompt", "{question}")
+ no_references_setting["value"] = no_references_prompt if len(no_references_prompt) > 0 else "{question}"
return no_references_setting
def get_application(self):
if self.debug:
application = QuerySet(Application).filter(id=self.application_id).first()
if not application:
- raise ChatException(500, _('The application does not exist'))
+ raise ChatException(500, _("The application does not exist"))
else:
- application = QuerySet(ApplicationVersion).filter(application_id=self.application_id).order_by(
- '-create_time')[0:1].first()
+ application = (
+ QuerySet(ApplicationVersion)
+ .filter(application_id=self.application_id)
+ .order_by("-create_time")[0:1]
+ .first()
+ )
if not application:
raise ChatException(500, _("The application has not been published. Please use it after publishing."))
if application.type == ApplicationTypeChoices.SIMPLE.value:
- # 数据集id列表
- knowledge_id_list = [str(row.target_id) for row in
- QuerySet(ResourceMapping).filter(source_id=self.application_id,
- source_type='APPLICATION',
- target_type='KNOWLEDGE')]
+ # 数据集id列表 这里需要从application中获取知识库 不能从关联表获取
+ if self.debug:
+ knowledge_id_list = [
+ str(row.target_id)
+ for row in QuerySet(ResourceMapping).filter(
+ source_id=self.application_id, source_type="APPLICATION", target_type="KNOWLEDGE"
+ )
+ ]
+ else:
+ knowledge_id_list = application.knowledge_ids
# 需要排除的文档
- exclude_document_id_list = [str(document.id) for document in
- QuerySet(Document).filter(
- knowledge_id__in=knowledge_id_list,
- is_active=False)]
+ exclude_document_id_list = [
+ str(document.id)
+ for document in QuerySet(Document).filter(knowledge_id__in=knowledge_id_list, is_active=False)
+ ]
self.knowledge_id_list = knowledge_id_list
self.exclude_document_id_list = exclude_document_id_list
self.application = application
@@ -171,42 +269,14 @@ def get_application(self):
def get_chat_user(self, asker=None):
if self.chat_user:
return self.chat_user
- chat_user_model = DatabaseModelManage.get_model("chat_user")
- if self.chat_user_type == ChatUserType.CHAT_USER.value and chat_user_model:
- chat_user = QuerySet(chat_user_model).filter(id=self.chat_user_id).first()
- return {
- 'id': str(chat_user.id),
- 'email': chat_user.email,
- 'phone': chat_user.phone,
- 'nick_name': chat_user.nick_name,
- 'username': chat_user.username,
- 'source': chat_user.source
- }
- else:
- if asker:
- if isinstance(asker, dict):
- self.chat_user = asker
- else:
- self.chat_user = {'username': asker}
- else:
- self.chat_user = {'username': '游客'}
- return self.chat_user
+ chat_user = resolve_chat_user(self.chat_user_id, self.chat_user_type, asker=asker)
+ # 保持原有语义:仅非登录用户缓存到实例上
+ if self.chat_user_type != ChatUserType.CHAT_USER.value:
+ self.chat_user = chat_user
+ return chat_user
def get_chat_user_group(self, asker=None):
- chat_user = self.get_chat_user(asker=asker)
- chat_user_id = chat_user.get('id')
-
- if not chat_user_id:
- return []
-
- user_group_relation_model = DatabaseModelManage.get_model("user_group_relation")
- if user_group_relation_model:
- return [{
- 'id': user_group_relation.group_id,
- 'name': user_group_relation.group.name
- } for user_group_relation in
- QuerySet(user_group_relation_model).select_related('group').filter(user_id=chat_user_id)]
- return []
+ return resolve_chat_user_group(self.get_chat_user(asker=asker))
def to_base_pipeline_manage_params(self):
self.get_application()
@@ -217,66 +287,107 @@ def to_base_pipeline_manage_params(self):
model_params_setting = None
if model_id is not None:
model = QuerySet(Model).filter(id=model_id).first()
+ if model is None:
+ raise Exception(_("Model does not exist"))
credential = get_model_credential(model.provider, model.model_type, model.model_name)
model_params_setting = credential.get_model_params_setting_form(model.model_name).get_default_form_data()
return {
- 'knowledge_id_list': self.knowledge_id_list,
- 'exclude_document_id_list': self.exclude_document_id_list,
- 'exclude_paragraph_id_list': [],
- 'top_n': 3 if knowledge_setting.get('top_n') is None else knowledge_setting.get('top_n'),
- 'similarity': 0.6 if knowledge_setting.get('similarity') is None else knowledge_setting.get('similarity'),
- 'max_paragraph_char_number': knowledge_setting.get('max_paragraph_char_number') or 5000,
- 'history_chat_record': self.chat_record_list,
- 'chat_id': self.chat_id,
- 'dialogue_number': self.application.dialogue_number,
- 'problem_optimization_prompt': self.application.problem_optimization_prompt if self.application.problem_optimization_prompt is not None and len(
- self.application.problem_optimization_prompt) > 0 else _(
- "() contains the user's question. Answer the guessed user's question based on the context ({question}) Requirement: Output a complete question and put it in the tag"),
- 'prompt': model_setting.get(
- 'prompt') if 'prompt' in model_setting and len(model_setting.get(
- 'prompt')) > 0 else Application.get_default_model_prompt(),
- 'system': model_setting.get(
- 'system', None),
- 'model_id': model_id,
- 'problem_optimization': self.application.problem_optimization,
- 'stream': True,
- 'model_setting': model_setting,
- 'model_params_setting': model_params_setting if self.application.model_params_setting is None or len(
- self.application.model_params_setting.keys()) == 0 else self.application.model_params_setting,
- 'search_mode': self.application.knowledge_setting.get('search_mode') or 'embedding',
- 'no_references_setting': self.get_no_references_setting(self.application.knowledge_setting, model_setting),
- 'workspace_id': self.application.workspace_id,
- 'application_id': self.application_id,
- 'mcp_enable': self.application.mcp_enable,
- 'mcp_tool_ids': self.application.mcp_tool_ids,
- 'mcp_servers': self.application.mcp_servers,
- 'mcp_source': self.application.mcp_source,
- 'tool_enable': self.application.tool_enable,
- 'tool_ids': self.application.tool_ids,
- 'application_enable': self.application.application_enable,
- 'application_ids': self.application.application_ids,
- 'skill_tool_ids': self.application.skill_tool_ids,
- 'mcp_output_enable': self.application.mcp_output_enable,
+ "knowledge_id_list": self.knowledge_id_list,
+ "exclude_document_id_list": self.exclude_document_id_list,
+ "exclude_paragraph_id_list": [],
+ "top_n": 3 if knowledge_setting.get("top_n") is None else knowledge_setting.get("top_n"),
+ "similarity": 0.6 if knowledge_setting.get("similarity") is None else knowledge_setting.get("similarity"),
+ "max_paragraph_char_number": knowledge_setting.get("max_paragraph_char_number") or 5000,
+ "history_chat_record": self.chat_record_list,
+ "chat_id": self.chat_id,
+ "dialogue_number": self.application.dialogue_number,
+ "problem_optimization_prompt": self.application.problem_optimization_prompt
+ if self.application.problem_optimization_prompt is not None
+ and len(self.application.problem_optimization_prompt) > 0
+ else _(
+ "() contains the user's question. Answer the guessed user's question based on the context ({question}) Requirement: Output a complete question and put it in the tag"
+ ),
+ "prompt": model_setting.get("prompt")
+ if "prompt" in model_setting and len(model_setting.get("prompt")) > 0
+ else Application.get_default_model_prompt(),
+ "system": model_setting.get("system", None),
+ "model_id": model_id,
+ "problem_optimization": self.application.problem_optimization,
+ "stream": True,
+ "model_setting": model_setting,
+ "model_params_setting": model_params_setting
+ if self.application.model_params_setting is None or len(self.application.model_params_setting.keys()) == 0
+ else self.application.model_params_setting,
+ "search_mode": self.application.knowledge_setting.get("search_mode") or "embedding",
+ "no_references_setting": self.get_no_references_setting(self.application.knowledge_setting, model_setting),
+ "workspace_id": self.application.workspace_id,
+ "application_id": self.application_id,
+ "mcp_enable": self.application.mcp_enable,
+ "mcp_tool_ids": self.application.mcp_tool_ids,
+ "mcp_servers": self.application.mcp_servers,
+ "mcp_source": self.application.mcp_source,
+ "tool_enable": self.application.tool_enable,
+ "tool_ids": self.application.tool_ids,
+ "application_enable": self.application.application_enable,
+ "application_ids": self.application.application_ids,
+ "skill_tool_ids": self.application.skill_tool_ids,
+ "mcp_output_enable": self.application.mcp_output_enable,
}
- def to_pipeline_manage_params(self, problem_text: str, post_response_handler,
- exclude_paragraph_id_list, chat_user_id: str, chat_user_type, ip_address, source,
- stream=True,
- form_data=None):
+ def to_pipeline_manage_params(
+ self,
+ problem_text: str,
+ post_response_handler,
+ exclude_paragraph_id_list,
+ chat_user_id: str,
+ chat_user_type,
+ ip_address,
+ source,
+ stream=True,
+ form_data=None,
+ ):
if form_data is None:
form_data = {}
params = self.to_base_pipeline_manage_params()
- return {**params, 'problem_text': problem_text, 'post_response_handler': post_response_handler,
- 'exclude_paragraph_id_list': exclude_paragraph_id_list, 'stream': stream, 'chat_user_id': chat_user_id,
- 'chat_user_type': chat_user_type, 'ip_address': ip_address, 'source': source, 'form_data': form_data}
+ return {
+ **params,
+ "problem_text": problem_text,
+ "post_response_handler": post_response_handler,
+ "exclude_paragraph_id_list": exclude_paragraph_id_list,
+ "stream": stream,
+ "chat_user_id": chat_user_id,
+ "chat_user_type": chat_user_type,
+ "ip_address": ip_address,
+ "source": source,
+ "form_data": form_data,
+ }
def set_chat(self, question):
if not self.debug:
if not QuerySet(Chat).filter(id=self.chat_id).exists():
- Chat(id=self.chat_id, application_id=self.application_id, abstract=question[0:1024],
- chat_user_id=self.chat_user_id, chat_user_type=self.chat_user_type,
- ip_address=self.ip_address, source=self.source,
- asker=self.get_chat_user()).save()
+ Chat(
+ id=self.chat_id,
+ application_id=self.application_id,
+ abstract=question[0:1024],
+ chat_user_id=self.chat_user_id,
+ chat_user_type=self.chat_user_type,
+ ip_address=self.ip_address,
+ source=self.source,
+ asker=self.get_chat_user(),
+ ).save()
+
+ def save_chat(self):
+ Chat(
+ id=self.chat_id,
+ application_id=self.application_id,
+ abstract="新建对话",
+ execute_type=ExecuteType.DEBUG if self.debug else ExecuteType.CHAT,
+ chat_user_id=self.chat_user_id,
+ chat_user_type=self.chat_user_type,
+ ip_address=self.ip_address,
+ source=self.source,
+ asker=self.get_chat_user(),
+ ).save()
def set_chat_variable(self, chat_context):
if not self.debug:
@@ -285,9 +396,12 @@ def set_chat_variable(self, chat_context):
chat.meta = {**(chat.meta if isinstance(chat.meta, dict) else {}), **chat_context}
chat.save()
else:
- cache.set(Cache_Version.CHAT_VARIABLE.get_key(key=self.chat_id), chat_context,
- version=Cache_Version.CHAT_VARIABLE.get_version(),
- timeout=60 * 30)
+ cache.set(
+ Cache_Version.CHAT_VARIABLE.get_key(key=self.chat_id),
+ chat_context,
+ version=Cache_Version.CHAT_VARIABLE.get_version(),
+ timeout=60 * 30,
+ )
def get_chat_variable(self):
if not self.debug:
@@ -296,8 +410,13 @@ def get_chat_variable(self):
return chat.meta
return {}
else:
- return cache.get(Cache_Version.CHAT_VARIABLE.get_key(key=self.chat_id),
- version=Cache_Version.CHAT_VARIABLE.get_version()) or {}
+ return (
+ cache.get(
+ Cache_Version.CHAT_VARIABLE.get_key(key=self.chat_id),
+ version=Cache_Version.CHAT_VARIABLE.get_version(),
+ )
+ or {}
+ )
def append_chat_record(self, chat_record: ChatRecord):
chat_record.problem_text = chat_record.problem_text[0:10240] if chat_record.problem_text is not None else ""
@@ -312,137 +431,172 @@ def append_chat_record(self, chat_record: ChatRecord):
break
if is_save:
self.chat_record_list.append(chat_record)
- if not self.debug:
- if not QuerySet(Chat).filter(id=self.chat_id).exists():
- Chat(id=self.chat_id, application_id=self.application_id, abstract=chat_record.problem_text[0:1024],
- chat_user_id=self.chat_user_id, chat_user_type=self.chat_user_type,
- ip_address=self.ip_address, source=self.source,
- asker=self.get_chat_user()).save()
- else:
- QuerySet(Chat).filter(id=self.chat_id).update(update_time=timezone.now())
- # 插入会话记录
- QuerySet(ChatRecord).update_or_create(id=chat_record.id,
- create_defaults={'id': chat_record.id,
- 'chat_id': chat_record.chat_id,
- "vote_status": chat_record.vote_status,
- 'problem_text': chat_record.problem_text,
- 'answer_text': chat_record.answer_text,
- 'answer_text_list': chat_record.answer_text_list,
- 'message_tokens': chat_record.message_tokens,
- 'answer_tokens': chat_record.answer_tokens,
- 'const': chat_record.const,
- 'details': chat_record.details,
- 'improve_paragraph_id_list': chat_record.improve_paragraph_id_list,
- 'run_time': chat_record.run_time,
- 'source': chat_record.source,
- 'ip_address': chat_record.ip_address or '',
- 'index': chat_record.index},
- defaults={
- "vote_status": chat_record.vote_status,
- 'problem_text': chat_record.problem_text,
- 'answer_text': chat_record.answer_text,
- 'answer_text_list': chat_record.answer_text_list,
- 'message_tokens': chat_record.message_tokens,
- 'answer_tokens': chat_record.answer_tokens,
- 'const': chat_record.const,
- 'details': chat_record.details,
- 'improve_paragraph_id_list': chat_record.improve_paragraph_id_list,
- 'run_time': chat_record.run_time,
- 'index': chat_record.index,
- 'source': chat_record.source,
- 'ip_address': chat_record.ip_address or '',
- })
- ChatCountSerializer(data={'chat_id': self.chat_id}).update_chat()
+ if not QuerySet(Chat).filter(id=self.chat_id).exists():
+ Chat(
+ id=self.chat_id,
+ application_id=self.application_id,
+ abstract=chat_record.problem_text[0:1024],
+ chat_user_id=self.chat_user_id,
+ chat_user_type=self.chat_user_type,
+ ip_address=self.ip_address,
+ source=self.source,
+ asker=self.get_chat_user(),
+ ).save()
+ else:
+ QuerySet(Chat).filter(id=self.chat_id).update(update_time=timezone.now())
+ # 记录Token消耗
+ total_tokens = (chat_record.message_tokens or 0) + (chat_record.answer_tokens or 0)
+ if total_tokens > 0:
+ ChatUserTokenQuota.consume(self.chat_user_id, total_tokens)
+ # 插入会话记录
+ QuerySet(ChatRecord).update_or_create(
+ id=chat_record.id,
+ create_defaults={
+ "id": chat_record.id,
+ "chat_id": chat_record.chat_id,
+ "vote_status": chat_record.vote_status,
+ "problem_text": chat_record.problem_text,
+ "answer_text": chat_record.answer_text,
+ "answer_text_list": chat_record.answer_text_list,
+ "message_tokens": chat_record.message_tokens,
+ "answer_tokens": chat_record.answer_tokens,
+ "const": chat_record.const,
+ "details": chat_record.details,
+ "improve_paragraph_id_list": chat_record.improve_paragraph_id_list,
+ "run_time": chat_record.run_time,
+ "source": chat_record.source,
+ "ip_address": chat_record.ip_address or "",
+ "index": chat_record.index,
+ },
+ defaults={
+ "vote_status": chat_record.vote_status,
+ "problem_text": chat_record.problem_text,
+ "answer_text": chat_record.answer_text,
+ "answer_text_list": chat_record.answer_text_list,
+ "message_tokens": chat_record.message_tokens,
+ "answer_tokens": chat_record.answer_tokens,
+ "const": chat_record.const,
+ "details": chat_record.details,
+ "improve_paragraph_id_list": chat_record.improve_paragraph_id_list,
+ "run_time": chat_record.run_time,
+ "index": chat_record.index,
+ "source": chat_record.source,
+ "ip_address": chat_record.ip_address or "",
+ },
+ )
+ ChatCountSerializer(data={"chat_id": self.chat_id}).update_chat()
def to_dict(self):
return {
- 'chat_id': self.chat_id,
- 'chat_user_id': self.chat_user_id,
- 'chat_user_type': self.chat_user_type,
- 'ip_address': self.ip_address,
- 'source': self.source,
- 'knowledge_id_list': self.knowledge_id_list,
- 'exclude_document_id_list': self.exclude_document_id_list,
- 'application_id': self.application_id,
- 'chat_record_list': [self.chat_record_to_map(c) for c in self.chat_record_list][-20:],
- 'debug': self.debug
+ "chat_id": self.chat_id,
+ "chat_user_id": self.chat_user_id,
+ "chat_user_type": self.chat_user_type,
+ "ip_address": self.ip_address,
+ "source": self.source,
+ "knowledge_id_list": self.knowledge_id_list,
+ "exclude_document_id_list": self.exclude_document_id_list,
+ "application_id": self.application_id,
+ "chat_record_list": [self.chat_record_to_map(c) for c in self.chat_record_list][-20:],
+ "debug": self.debug,
}
def chat_record_to_map(self, chat_record):
- return {'id': chat_record.id,
- 'chat_id': chat_record.chat_id,
- 'vote_status': chat_record.vote_status,
- 'problem_text': chat_record.problem_text,
- 'answer_text': chat_record.answer_text,
- 'answer_text_list': chat_record.answer_text_list,
- 'message_tokens': chat_record.message_tokens,
- 'answer_tokens': chat_record.answer_tokens,
- 'const': chat_record.const,
- 'details': chat_record.details,
- 'improve_paragraph_id_list': chat_record.improve_paragraph_id_list,
- 'run_time': chat_record.run_time,
- 'source': chat_record.source,
- 'ip_address': chat_record.ip_address,
- 'index': chat_record.index}
+ return {
+ "id": chat_record.id,
+ "chat_id": chat_record.chat_id,
+ "vote_status": chat_record.vote_status,
+ "problem_text": chat_record.problem_text,
+ "answer_text": chat_record.answer_text,
+ "answer_text_list": chat_record.answer_text_list,
+ "message_tokens": chat_record.message_tokens,
+ "answer_tokens": chat_record.answer_tokens,
+ "const": chat_record.const,
+ "details": chat_record.details,
+ "improve_paragraph_id_list": chat_record.improve_paragraph_id_list,
+ "run_time": chat_record.run_time,
+ "source": chat_record.source,
+ "ip_address": chat_record.ip_address,
+ "index": chat_record.index,
+ }
@staticmethod
def map_to_chat_record(chat_record_dict):
- return ChatRecord(id=chat_record_dict.get('id'),
- chat_id=chat_record_dict.get('chat_id'),
- vote_status=chat_record_dict.get('vote_status'),
- problem_text=chat_record_dict.get('problem_text'),
- answer_text=chat_record_dict.get('answer_text'),
- answer_text_list=chat_record_dict.get('answer_text_list'),
- message_tokens=chat_record_dict.get('message_tokens'),
- answer_tokens=chat_record_dict.get('answer_tokens'),
- const=chat_record_dict.get('const'),
- details=chat_record_dict.get('details'),
- improve_paragraph_id_list=chat_record_dict.get('improve_paragraph_id_list'),
- run_time=chat_record_dict.get('run_time'),
- index=chat_record_dict.get('index'),
- source=chat_record_dict.get('source'),
- ip_address=chat_record_dict.get('ip_address'))
+ return ChatRecord(
+ id=chat_record_dict.get("id"),
+ chat_id=chat_record_dict.get("chat_id"),
+ vote_status=chat_record_dict.get("vote_status"),
+ problem_text=chat_record_dict.get("problem_text"),
+ answer_text=chat_record_dict.get("answer_text"),
+ answer_text_list=chat_record_dict.get("answer_text_list"),
+ message_tokens=chat_record_dict.get("message_tokens"),
+ answer_tokens=chat_record_dict.get("answer_tokens"),
+ const=chat_record_dict.get("const"),
+ details=chat_record_dict.get("details"),
+ improve_paragraph_id_list=chat_record_dict.get("improve_paragraph_id_list"),
+ run_time=chat_record_dict.get("run_time"),
+ index=chat_record_dict.get("index"),
+ source=chat_record_dict.get("source"),
+ ip_address=chat_record_dict.get("ip_address"),
+ )
def set_cache(self):
- cache.set(Cache_Version.CHAT.get_key(key=self.chat_id), self.to_dict(),
- version=Cache_Version.CHAT_INFO.get_version(),
- timeout=60 * 30)
+ cache.set(
+ Cache_Version.CHAT.get_key(key=self.chat_id),
+ self.to_dict(),
+ version=Cache_Version.CHAT_INFO.get_version(),
+ timeout=60 * 30,
+ )
@staticmethod
def map_to_chat_info(chat_info_dict):
- c = ChatInfo(chat_info_dict.get('chat_id'), chat_info_dict.get('chat_user_id'),
- chat_info_dict.get('chat_user_type'), chat_info_dict.get('ip_address'),
- chat_info_dict.get('source'),
- chat_info_dict.get('knowledge_id_list'),
- chat_info_dict.get('exclude_document_id_list'),
- chat_info_dict.get('application_id'),
- debug=chat_info_dict.get('debug'))
- c.chat_record_list = [ChatInfo.map_to_chat_record(c_r) for c_r in chat_info_dict.get('chat_record_list')]
+ c = ChatInfo(
+ chat_info_dict.get("chat_id"),
+ chat_info_dict.get("chat_user_id"),
+ chat_info_dict.get("chat_user_type"),
+ chat_info_dict.get("ip_address"),
+ chat_info_dict.get("source"),
+ chat_info_dict.get("knowledge_id_list"),
+ chat_info_dict.get("exclude_document_id_list"),
+ chat_info_dict.get("application_id"),
+ debug=chat_info_dict.get("debug"),
+ )
+ c.chat_record_list = [ChatInfo.map_to_chat_record(c_r) for c_r in chat_info_dict.get("chat_record_list")]
return c
@staticmethod
def get_cache(chat_id):
- chat_info_dict = cache.get(Cache_Version.CHAT.get_key(key=chat_id),
- version=Cache_Version.CHAT_INFO.get_version())
+ chat_info_dict = cache.get(
+ Cache_Version.CHAT.get_key(key=chat_id), version=Cache_Version.CHAT_INFO.get_version()
+ )
if chat_info_dict:
return ChatInfo.map_to_chat_info(chat_info_dict)
return None
def update_resource_mapping_by_application(application_id: str, other_resource_mapping=None):
- from application.flow.tools import get_instance_resource, save_workflow_mapping, \
- application_instance_field_call_dict
+ from system_manage.services.resource_mapping import (
+ application_instance_field_call_dict,
+ get_instance_resource,
+ save_workflow_mapping,
+ )
from system_manage.models.resource_mapping import ResourceType
+
if other_resource_mapping is None:
other_resource_mapping = []
application = QuerySet(Application).filter(id=application_id).first()
- instance_mapping = get_instance_resource(application, ResourceType.APPLICATION, str(application.id),
- application_instance_field_call_dict)
- if application.type == 'WORK_FLOW':
- save_workflow_mapping(application.work_flow, ResourceType.APPLICATION, str(application_id),
- instance_mapping + other_resource_mapping)
+ instance_mapping = get_instance_resource(
+ application, ResourceType.APPLICATION, str(application.id), application_instance_field_call_dict
+ )
+ if application.type == "WORK_FLOW":
+ save_workflow_mapping(
+ application.work_flow,
+ ResourceType.APPLICATION,
+ str(application_id),
+ instance_mapping + other_resource_mapping,
+ )
return
else:
- save_workflow_mapping({}, ResourceType.APPLICATION, str(application_id),
- instance_mapping + other_resource_mapping)
+ save_workflow_mapping(
+ {}, ResourceType.APPLICATION, str(application_id), instance_mapping + other_resource_mapping
+ )
diff --git a/apps/application/sql/list_application.sql b/apps/application/sql/list_application.sql
index 3b6e863fd97..c26218223d7 100644
--- a/apps/application/sql/list_application.sql
+++ b/apps/application/sql/list_application.sql
@@ -2,6 +2,7 @@ select *
from (select application."id"::text, application."name",
application."desc",
application."is_publish",
+ application."is_portal",
application."type",
'application' as "resource_type",
application."workspace_id",
diff --git a/apps/application/sql/list_application_user.sql b/apps/application/sql/list_application_user.sql
index ecffcd93daf..cc25dab288f 100644
--- a/apps/application/sql/list_application_user.sql
+++ b/apps/application/sql/list_application_user.sql
@@ -2,6 +2,7 @@ select *
from (select application."id"::text, application."name",
application."desc",
application."is_publish",
+ application."is_portal",
application."type",
'application' as "resource_type",
application."workspace_id",
@@ -16,5 +17,13 @@ from (select application."id"::text, application."name",
left join "user" on user_id = "user".id
where application."id"::text in (select target
from workspace_user_resource_permission ${workspace_user_resource_permission_query_set}
- and 'VIEW' = any (permission_list))) temp
-${application_query_set}
\ No newline at end of file
+ and 'VIEW' = any (permission_list)
+ union
+ select distinct target
+ from workspace_user_group_resource_permission
+ inner join system_user_group_relation
+ on system_user_group_relation.group_id =
+ workspace_user_group_resource_permission.user_group_id
+ ${workspace_user_group_resource_permission_query_set}
+ and 'VIEW' = any (permission_list))) temp
+${application_query_set}
diff --git a/apps/application/sql/list_application_user_ee.sql b/apps/application/sql/list_application_user_ee.sql
index 0fe61a1402c..da5c356d5e6 100644
--- a/apps/application/sql/list_application_user_ee.sql
+++ b/apps/application/sql/list_application_user_ee.sql
@@ -2,6 +2,7 @@ select *
from (select application."id"::text, application."name",
application."desc",
application."is_publish",
+ application."is_portal",
application."type",
'application' as "resource_type",
application."workspace_id",
@@ -33,5 +34,33 @@ from (select application."id"::text, application."name",
else
'VIEW' = any (permission_list)
- end)) temp
-${application_query_set}
\ No newline at end of file
+ end
+ union
+ select distinct target
+ from workspace_user_group_resource_permission
+ inner join system_user_group_relation
+ on system_user_group_relation.group_id =
+ workspace_user_group_resource_permission.user_group_id
+ ${workspace_user_group_resource_permission_query_set}
+ and (
+ 'VIEW' = any (permission_list)
+ or (
+ auth_type = 'ROLE'
+ and 'ROLE' = any (permission_list)
+ and 'APPLICATION:READ' in (select (case
+ when user_role_relation.role_id =
+ any (array['USER'])
+ then 'APPLICATION:READ'
+ else
+ role_permission.permission_id end)
+ from role_permission role_permission
+ right join user_role_relation user_role_relation
+ on user_role_relation.role_id =
+ role_permission.role_id
+ where user_role_relation.user_id =
+ system_user_group_relation.user_id
+ and user_role_relation.workspace_id =
+ workspace_user_group_resource_permission.workspace_id)
+ )
+ ))) temp
+${application_query_set}
diff --git a/apps/application/tests.py b/apps/application/tests.py
index 7ce503c2dd9..d3acbc933ae 100644
--- a/apps/application/tests.py
+++ b/apps/application/tests.py
@@ -1,3 +1,143 @@
-from django.test import TestCase
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
-# Create your tests here.
+from django.test import SimpleTestCase
+
+from application.workflow.nodes.ai_chat_node.ai_chat_node import AIChatNode, _get_upstream_knowledge_images
+from application.workflow.nodes.search_knowledge_node.search_knowledge_node import (
+ _get_recalled_image_list,
+ _record_recalled_items,
+ _reset_paragraph,
+)
+from knowledge.models import SourceType
+
+
+class SearchKnowledgeNodeTests(SimpleTestCase):
+ def test_image_hit_metadata_is_attached_to_recalled_paragraph(self):
+ paragraph_id = "00000000-0000-0000-0000-000000000001"
+ asset_id = "00000000-0000-0000-0000-000000000002"
+ file_id = "00000000-0000-0000-0000-000000000003"
+ created_at = datetime(2026, 9, 3, 10, 0, 0)
+ paragraph = {
+ "id": paragraph_id,
+ "knowledge_id": "00000000-0000-0000-0000-000000000004",
+ "document_id": "00000000-0000-0000-0000-000000000005",
+ "directly_return_similarity": 0.8,
+ "hit_handling_method": "normal",
+ "update_time": created_at,
+ "create_time": created_at,
+ "meta": {},
+ }
+ embedding = {
+ "paragraph_id": paragraph_id,
+ "similarity": 0.91,
+ "comprehensive_score": 0.93,
+ "source_id": asset_id,
+ "source_type": SourceType.IMAGE.value,
+ "query_unit_type": "text",
+ "query_unit_index": 0,
+ }
+ asset = {"id": asset_id, "file_id": file_id, "file_name": "chart.png"}
+
+ result = _reset_paragraph(paragraph, [embedding], {asset_id: asset})
+
+ self.assertEqual(result["hit_unit_type"], "image")
+ self.assertEqual(result["hit_asset"], asset)
+ self.assertEqual(result["comprehensive_score"], 0.93)
+ self.assertEqual(_get_recalled_image_list([result, result]), [asset])
+
+ def test_image_text_hit_adds_visual_text_to_retrieval_context(self):
+ paragraph_id = "00000000-0000-0000-0000-000000000001"
+ asset_id = "00000000-0000-0000-0000-000000000002"
+ created_at = datetime(2026, 9, 3, 10, 0, 0)
+ paragraph = {
+ "id": paragraph_id,
+ "knowledge_id": "00000000-0000-0000-0000-000000000004",
+ "document_id": "00000000-0000-0000-0000-000000000005",
+ "content": "paragraph text",
+ "directly_return_similarity": 0.8,
+ "hit_handling_method": "normal",
+ "update_time": created_at,
+ "create_time": created_at,
+ "meta": {},
+ }
+ embedding = {
+ "paragraph_id": paragraph_id,
+ "similarity": 0.91,
+ "comprehensive_score": 0.93,
+ "source_id": asset_id,
+ "source_type": SourceType.IMAGE.value,
+ "meta": {"unit_type": "text", "content_type": "image_description"},
+ }
+ asset = {
+ "id": asset_id,
+ "file_id": "00000000-0000-0000-0000-000000000003",
+ "caption": "chart",
+ "ocr_text": "revenue 100",
+ "description": "an upward trend",
+ }
+
+ result = _reset_paragraph(paragraph, [embedding], {asset_id: asset})
+
+ self.assertEqual(result["hit_unit_type"], "text")
+ self.assertEqual(result["retrieval_content"], "paragraph text\nchart\nrevenue 100\nan upward trend")
+
+ @patch("application.workflow.nodes.search_knowledge_node.search_knowledge_node.record_recall_safely")
+ @patch("application.workflow.nodes.search_knowledge_node.search_knowledge_node.get_recall_tracker")
+ def test_records_only_embeddings_returned_to_the_user(self, get_tracker, record_recall):
+ workflow_manage = MagicMock()
+ tracker = {}
+ get_tracker.return_value = tracker
+ embedding_list = [
+ {"paragraph_id": "paragraph-1", "source_type": SourceType.PARAGRAPH.value},
+ {"paragraph_id": "paragraph-2", "source_type": SourceType.PARAGRAPH.value},
+ ]
+
+ _record_recalled_items(embedding_list, [{"id": "paragraph-2"}], workflow_manage)
+
+ record_recall.assert_called_once_with([embedding_list[1]], tracker=tracker)
+
+ @patch("application.workflow.nodes.search_knowledge_node.search_knowledge_node.record_recall_safely")
+ def test_does_not_record_recall_in_debug_mode(self, record_recall):
+ _record_recalled_items([{"paragraph_id": "paragraph-1"}], [{"id": "paragraph-1"}], MagicMock(), True)
+
+ record_recall.assert_not_called()
+
+
+class AIChatKnowledgeImageTests(SimpleTestCase):
+ def setUp(self):
+ search_node = SimpleNamespace(id="search", type="search-knowledge-node")
+ condition_node = SimpleNamespace(id="condition", type="condition-node")
+ start_node = SimpleNamespace(id="start", type="start-node")
+ self.workflow_manage = MagicMock()
+ self.workflow_manage.workflow.up_node_map = {
+ "ai": [SimpleNamespace(node=condition_node)],
+ "condition": [SimpleNamespace(node=search_node)],
+ "search": [SimpleNamespace(node=start_node)],
+ }
+ self.asset = {
+ "file_id": "00000000-0000-0000-0000-000000000006",
+ "file_name": "chart.png",
+ }
+ self.workflow_manage.get_context.side_effect = lambda node_id, key: (
+ [self.asset, self.asset] if node_id == "search" and key == "image_list" else None
+ )
+
+ def test_collects_deduplicated_images_from_upstream_knowledge_searches(self):
+ self.assertEqual(_get_upstream_knowledge_images(self.workflow_manage, "ai"), [self.asset])
+
+ @patch("application.workflow.nodes.ai_chat_node.ai_chat_node._process_images")
+ def test_vision_chat_receives_recalled_knowledge_images(self, process_images):
+ processed_image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,image"}}
+ process_images.return_value = [processed_image]
+ self.workflow_manage.generate_prompt.return_value = "answer with the recalled context"
+ node = AIChatNode.__new__(AIChatNode)
+ node.node = SimpleNamespace(id="ai")
+ node.workflow_manage = self.workflow_manage
+
+ question = node._generate_prompt_question("prompt", MagicMock(), True, None, None)
+
+ process_images.assert_called_once_with([self.asset])
+ self.assertEqual(question.content[0], processed_image)
+ self.assertEqual(question.content[-1]["text"], "answer with the recalled context")
diff --git a/apps/application/urls.py b/apps/application/urls.py
index 1c473b63935..05707a9f48f 100644
--- a/apps/application/urls.py
+++ b/apps/application/urls.py
@@ -43,5 +43,10 @@
path('workspace//application//play_demo_text', views.PlayDemoText.as_view()),
path('workspace//application//mcp_tools', views.McpServers.as_view()),
path('workspace//application//model//prompt_generate', views.PromptGenerateView.as_view()),
- path('chat_message/', views.ChatView.as_view()),
+ path('workspace//application//chat//chat_message', views.ChatView.as_view()),
+ path('workspace//application//chat//cancel_chat_message', views.CancelWorkflowView.as_view()),
+ path('workspace//application//chat//chat_record//resume_chat_message', views.ResumeStreamView.as_view()),
+ path('workspace//application//historical_conversation//', views.DebugHistoricalConversation.PageView.as_view()),
+ path('workspace//application//historical_conversation/', views.DebugHistoricalConversation.Operate.as_view()),
+ path('workspace//application//historical_conversation_record///', views.DebugHistoricalConversation.RecordPageView.as_view()),
]
diff --git a/apps/application/views/application.py b/apps/application/views/application.py
index 5f5058bca4d..6bb977ab75c 100644
--- a/apps/application/views/application.py
+++ b/apps/application/views/application.py
@@ -23,7 +23,10 @@
from common import result
from common.auth import TokenAuth
from common.auth.authentication import has_permissions, get_is_permissions, check_batch_permissions
-from common.constants.permission_constants import PermissionConstants, RoleConstants, ViewPermission, CompareConstants
+from common.auth.constants.compare_constants import CompareConstants
+from common.auth.constants.permission_constants import PermissionConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.aggregate_permission import ViewPermission
from common.log.log import log
from tools.api.tool import GetInternalToolAPI
@@ -151,7 +154,7 @@ class Export(APIView):
PermissionConstants.APPLICATION_EXPORT.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
@log(menu='Application', operate="Export Application",
get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id')),
@@ -178,7 +181,7 @@ class Operate(APIView):
PermissionConstants.APPLICATION_DELETE.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
@log(menu='Application', operate='Deleting application',
get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id')),
@@ -204,7 +207,7 @@ def delete(self, request: Request, workspace_id: str, application_id: str):
PermissionConstants.APPLICATION_EDIT.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
@log(menu='Application', operate="Modify the application",
get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id')),
@@ -230,7 +233,7 @@ def put(self, request: Request, workspace_id: str, application_id: str):
PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str):
return result.success(ApplicationOperateSerializer(
@@ -254,7 +257,7 @@ class Move(APIView):
PermissionConstants.APPLICATION_EDIT.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
@log(menu='Application', operate='Move an application',
get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id')))
@@ -281,7 +284,7 @@ class Publish(APIView):
PermissionConstants.APPLICATION_PUBLISH.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
@log(menu='Application', operate='Publishing an application',
get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id')))
@@ -315,11 +318,11 @@ class BatchDelete(APIView):
methods=['PUT'],
description=_("Batch delete applications"),
summary=_("Batch delete applications"),
- operation_id=_("Batch delete applications"),
+ operation_id=_("Batch delete applications"), # type: ignore
parameters=ApplicationBatchOperateAPI.get_parameters(),
request=ApplicationBatchOperateAPI.get_request(),
responses=result.DefaultResultSerializer,
- tags=[_('Application')]
+ tags=[_('Application')] # type: ignore
)
@has_permissions(PermissionConstants.APPLICATION_BATCH_DELETE.get_workspace_permission(),
RoleConstants.USER.get_workspace_role(),
@@ -333,17 +336,18 @@ def put(self, request: Request, workspace_id: str):
PermissionConstants.APPLICATION_DELETE.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role()), workspace_id=workspace_id
)
+
@log(menu='Application', operate='Batch delete applications',
get_operation_object=lambda r, k: get_application_operation_object_batch(permitted_ids))
- def inner(view,r, **kwargs):
+ def inner(view, r, **kwargs):
return ApplicationBatchOperateSerializer(
data={'workspace_id': workspace_id, 'user_id': request.user.id}
).batch_delete({'id_list': permitted_ids})
- return result.success(inner(self,request, workspace_id=workspace_id))
+ return result.success(inner(self, request, workspace_id=workspace_id))
class BatchMove(APIView):
authentication_classes = [TokenAuth]
@@ -352,11 +356,11 @@ class BatchMove(APIView):
methods=['PUT'],
description=_("Batch move applications"),
summary=_("Batch move applications"),
- operation_id=_("Batch move applications"),
+ operation_id=_("Batch move applications"), # type: ignore
parameters=ApplicationBatchOperateAPI.get_parameters(),
request=ApplicationBatchOperateAPI.get_move_request(),
responses=result.DefaultResultSerializer,
- tags=[_('Application')]
+ tags=[_('Application')] # type: ignore
)
@has_permissions(PermissionConstants.APPLICATION_BATCH_MOVE.get_workspace_permission(),
RoleConstants.USER.get_workspace_role(),
@@ -370,19 +374,19 @@ def put(self, request: Request, workspace_id: str):
PermissionConstants.APPLICATION_EDIT.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role()),
workspace_id=workspace_id
)
@log(menu='Application', operate='Batch move applications',
get_operation_object=lambda r, k: get_application_operation_object_batch(permitted_ids))
- def inner(view,r, **kwargs):
+ def inner(view, r, **kwargs):
return ApplicationBatchOperateSerializer(
data={'workspace_id': workspace_id, 'user_id': request.user.id}
).batch_move({'id_list': permitted_ids, 'folder_id': request.data.get('folder_id')})
- return result.success(inner(self,request, workspace_id=workspace_id))
+ return result.success(inner(self, request, workspace_id=workspace_id))
class BatchCleanTime(APIView):
authentication_classes = [TokenAuth]
@@ -391,11 +395,11 @@ class BatchCleanTime(APIView):
methods=['PUT'],
description=_("Batch update application chat log clear policy"),
summary=_("Batch update application chat log clear policy"),
- operation_id=_("Batch update application chat log clear policy"),
+ operation_id=_("Batch update application chat log clear policy"), # type: ignore
parameters=ApplicationBatchOperateAPI.get_parameters(),
request=ApplicationBatchOperateAPI.get_clean_time_request(),
responses=result.DefaultResultSerializer,
- tags=[_('Application')]
+ tags=[_('Application')] # type: ignore
)
@has_permissions(PermissionConstants.APPLICATION_READ.get_workspace_permission(),
RoleConstants.USER.get_workspace_role(),
@@ -409,14 +413,14 @@ def put(self, request: Request, workspace_id: str):
PermissionConstants.APPLICATION_CHAT_LOG_CLEAR_POLICY.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role()),
workspace_id=workspace_id
)
@log(menu='Application', operate='Batch update application chat log clear policy',
get_operation_object=lambda r, k: get_application_operation_object_batch(permitted_ids))
- def inner(view,r, **kwargs):
+ def inner(view, r, **kwargs):
return ApplicationBatchOperateSerializer(
data={'workspace_id': workspace_id, 'user_id': request.user.id}
).batch_clean_time({
@@ -425,7 +429,8 @@ def inner(view,r, **kwargs):
'file_clean_time': request.data.get('file_clean_time')
})
- return result.success(inner(self,request, workspace_id=workspace_id))
+ return result.success(inner(self, request, workspace_id=workspace_id))
+
class McpServers(APIView):
authentication_classes = [TokenAuth]
@@ -444,7 +449,7 @@ class McpServers(APIView):
PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def post(self, request: Request, workspace_id, application_id: str):
return result.success(ApplicationOperateSerializer(
@@ -470,7 +475,7 @@ class SpeechToText(APIView):
PermissionConstants.APPLICATION_EDIT.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def post(self, request: Request, workspace_id: str, application_id: str):
return result.success(
@@ -496,7 +501,7 @@ class TextToSpeech(APIView):
PermissionConstants.APPLICATION_EDIT.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def post(self, request: Request, workspace_id: str, application_id: str):
byte_data = ApplicationOperateSerializer(
@@ -523,7 +528,7 @@ class PlayDemoText(APIView):
PermissionConstants.APPLICATION_EDIT.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
@log(menu='Application', operate="trial listening",
get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id')))
diff --git a/apps/application/views/application_access_token.py b/apps/application/views/application_access_token.py
index f7dc6bcaf83..ebf7da12e9f 100644
--- a/apps/application/views/application_access_token.py
+++ b/apps/application/views/application_access_token.py
@@ -18,7 +18,10 @@
from common import result
from common.auth import TokenAuth
from common.auth.authentication import has_permissions
-from common.constants.permission_constants import PermissionConstants, RoleConstants, ViewPermission, CompareConstants
+from common.auth.constants.compare_constants import CompareConstants
+from common.auth.constants.permission_constants import PermissionConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.aggregate_permission import ViewPermission
from common.log.log import log
def get_application_operation_object(application_id):
@@ -49,7 +52,7 @@ class AccessToken(APIView):
PermissionConstants.APPLICATION_OVERVIEW_ACCESS.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def put(self, request: Request, workspace_id: str, application_id: str):
return result.success(
@@ -68,7 +71,7 @@ def put(self, request: Request, workspace_id: str, application_id: str):
PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role()
)
def get(self, request: Request, workspace_id: str, application_id: str):
diff --git a/apps/application/views/application_api_key.py b/apps/application/views/application_api_key.py
index 213c8fe7221..bd07d07a44f 100644
--- a/apps/application/views/application_api_key.py
+++ b/apps/application/views/application_api_key.py
@@ -9,7 +9,11 @@
from application.serializers.application_api_key import ApplicationKeySerializer
from common.auth import TokenAuth
from common.auth.authentication import has_permissions
-from common.constants.permission_constants import PermissionConstants, RoleConstants, ViewPermission, CompareConstants
+from common.auth.constants.compare_constants import CompareConstants
+from common.auth.constants.permission_constants import PermissionConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.aggregate_permission import ViewPermission
+
from common.log.log import log
from common.result import result, DefaultResultSerializer
@@ -42,8 +46,9 @@ class ApplicationKey(APIView):
@has_permissions(PermissionConstants.APPLICATION_OVERVIEW_API_KEY.get_workspace_application_permission(),
PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ [
+ PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role()
)
def post(self, request: Request, workspace_id: str, application_id: str):
@@ -67,7 +72,7 @@ class Page(APIView):
PermissionConstants.APPLICATION_OVERVIEW_API_KEY.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str, current_page: int, page_size: int):
return result.success(ApplicationKeySerializer(
@@ -92,7 +97,7 @@ class Operate(APIView):
PermissionConstants.APPLICATION_OVERVIEW_API_KEY.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
@log(menu='Application', operate="Modify application API_KEY",
get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id')),
@@ -118,7 +123,7 @@ def put(self, request: Request, workspace_id: str, application_id: str, api_key_
PermissionConstants.APPLICATION_OVERVIEW_API_KEY.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
@log(menu='Application', operate="Delete application API_KEY",
get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id')),
diff --git a/apps/application/views/application_chat.py b/apps/application/views/application_chat.py
index d60206ded5a..01dbcb464ec 100644
--- a/apps/application/views/application_chat.py
+++ b/apps/application/views/application_chat.py
@@ -1,179 +1,407 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: application_chat.py
- @date:2025/6/10 11:00
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: application_chat.py
+@date:2025/6/10 11:00
+@desc:
"""
-import uuid_utils.compat as uuid
-from django.db.models import QuerySet
+from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema
from rest_framework.request import Request
from rest_framework.views import APIView
-from application.api.application_chat import ApplicationChatQueryAPI, ApplicationChatQueryPageAPI, \
- ApplicationChatExportAPI
-from application.models import ChatUserType, Application
+from application.api.application_chat import (
+ ApplicationChatQueryAPI,
+ ApplicationChatQueryPageAPI,
+ ApplicationChatExportAPI,
+)
+from application.models import ChatUserType, Application, ChatSourceChoices
from application.serializers.application_chat import ApplicationChatQuerySerializers
-from chat.api.chat_api import ChatAPI, PromptGenerateAPI
+from chat.api.chat_api import ChatAPI, PromptGenerateAPI, PageHistoricalConversationAPI, HistoricalConversationRecordAPI
from chat.api.chat_authentication_api import ChatOpenAPI
-from chat.serializers.chat import OpenChatSerializers, ChatSerializers, DebugChatSerializers, PromptGenerateSerializer
+from chat.serializers.chat import DebugChatSerializers, OpenChatSerializers, PromptGenerateSerializer, ResumeSerializers
from common.auth import TokenAuth
from common.auth.authentication import has_permissions
-from common.constants.permission_constants import PermissionConstants, RoleConstants, ViewPermission, CompareConstants
-from common.log.log import log
+from common.auth.constants.compare_constants import CompareConstants
+from common.auth.constants.permission_constants import PermissionConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.aggregate_permission import ViewPermission
+from common.log.log import log, _get_ip_address
from common.result import result
from common.utils.common import query_params_to_single_dict
+
def get_application_operation_object(application_id):
application_model = QuerySet(model=Application).filter(id=application_id).first()
if application_model is not None:
- return {
- 'name': application_model.name
- }
+ return {"name": application_model.name}
return {}
+
class ApplicationChat(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['GET'],
+ methods=["GET"],
description=_("Get the conversation list"),
summary=_("Get the conversation list"),
operation_id=_("Get the conversation list"), # type: ignore
request=ApplicationChatQueryAPI.get_request(),
parameters=ApplicationChatQueryAPI.get_parameters(),
responses=ApplicationChatQueryAPI.get_response(),
- tags=[_("Application/Conversation Log")] # type: ignore
+ tags=[_("Application/Conversation Log")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str):
- return result.success(ApplicationChatQuerySerializers(
- data={**query_params_to_single_dict(request.query_params), 'workspace_id': workspace_id,
- 'application_id': application_id,
- }).list())
+ return result.success(
+ ApplicationChatQuerySerializers(
+ data={
+ **query_params_to_single_dict(request.query_params),
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ }
+ ).list()
+ )
class Page(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['GET'],
+ methods=["GET"],
description=_("Get the conversation list by page"),
summary=_("Get the conversation list by page"),
operation_id=_("Get the conversation list by page"), # type: ignore
request=ApplicationChatQueryPageAPI.get_request(),
parameters=ApplicationChatQueryPageAPI.get_parameters(),
responses=ApplicationChatQueryPageAPI.get_response(),
- tags=[_("Application/Conversation Log")] # type: ignore
+ tags=[_("Application/Conversation Log")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str, current_page: int, page_size: int):
- return result.success(ApplicationChatQuerySerializers(
- data={**query_params_to_single_dict(request.query_params), 'workspace_id': workspace_id,
- 'application_id': application_id,
- }).page(current_page=current_page,
- page_size=page_size))
+ return result.success(
+ ApplicationChatQuerySerializers(
+ data={
+ **query_params_to_single_dict(request.query_params),
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ }
+ ).page(current_page=current_page, page_size=page_size)
+ )
class Export(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['POST'],
+ methods=["POST"],
description=_("Export conversation"),
summary=_("Export conversation"),
operation_id=_("Export conversation"), # type: ignore
request=ApplicationChatExportAPI.get_request(),
parameters=ApplicationChatExportAPI.get_parameters(),
responses=ApplicationChatExportAPI.get_response(),
- tags=[_("Application/Conversation Log")] # type: ignore
+ tags=[_("Application/Conversation Log")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_CHAT_LOG_EXPORT.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_CHAT_LOG_EXPORT.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_EXPORT.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_CHAT_LOG_EXPORT.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def post(self, request: Request, workspace_id: str, application_id: str):
return ApplicationChatQuerySerializers(
- data={**query_params_to_single_dict(request.query_params), 'workspace_id': workspace_id,
- 'application_id': application_id,
- }).export(request.data)
+ data={
+ **query_params_to_single_dict(request.query_params),
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ }
+ ).export(request.data)
class OpenView(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['GET'],
+ methods=["GET"],
description=_("Get a temporary session id based on the application id"),
summary=_("Get a temporary session id based on the application id"),
operation_id=_("Get a temporary session id based on the application id"), # type: ignore
parameters=ChatOpenAPI.get_parameters(),
responses=None,
- tags=[_('Application')] # type: ignore
+ tags=[_("Application")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- @has_permissions(PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str):
- return result.success(OpenChatSerializers(
- data={'workspace_id': workspace_id, 'application_id': application_id,
- 'chat_user_id': str(uuid.uuid7()), 'chat_user_type': ChatUserType.ANONYMOUS_USER,
- 'debug': True}).open())
+ ip_address = _get_ip_address(request)
+ return result.success(
+ OpenChatSerializers(
+ data={
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ "chat_user_id": str(request.user.id),
+ "chat_user_type": ChatUserType.SYSTEM_USER,
+ "ip_address": ip_address,
+ "source": {"type": ChatSourceChoices.ONLINE.value},
+ "debug": True,
+ }
+ ).open()
+ )
class ChatView(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['POST'],
+ methods=["POST"],
description=_("dialogue"),
summary=_("dialogue"),
operation_id=_("dialogue"), # type: ignore
request=ChatAPI.get_request(),
parameters=ChatAPI.get_parameters(),
responses=None,
- tags=[_('Application')] # type: ignore
+ tags=[_("Application")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- def post(self, request: Request, chat_id: str):
- return DebugChatSerializers(data={'chat_id': chat_id}).chat(request.data)
+ def post(self, request: Request, workspace_id: str, application_id: str, chat_id: str):
+ # 携带 open 上下文:前端本地生成 chat_id 首次发消息时,缓存缺失则按该 id 现开会话。
+ return DebugChatSerializers(
+ data={
+ "chat_id": chat_id,
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ "chat_user_id": str(request.user.id),
+ "chat_user_type": ChatUserType.SYSTEM_USER,
+ "ip_address": _get_ip_address(request),
+ "source": {"type": ChatSourceChoices.ONLINE.value},
+ }
+ ).chat(request.data)
+
+
+class CancelWorkflowView(APIView):
+ authentication_classes = [TokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("Cancel running workflow"),
+ summary=_("Cancel running workflow"),
+ operation_id=_("Cancel running workflow"), # type: ignore
+ tags=[_("Application")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
+ )
+ def post(self, request: Request, workspace_id: str, application_id: str, chat_id: str):
+ from application.workflow.workflow_run_registry import WorkflowRunRegistry, CancelResult
+
+ result_enum = WorkflowRunRegistry.cancel_by_chat_id(chat_id)
+ if result_enum == CancelResult.CANCELLED:
+ return result.success({"status": "cancelled", "chat_id": chat_id})
+ elif result_enum == CancelResult.NOT_FOUND:
+ return result.success({"status": "not_found", "chat_id": chat_id})
+ else:
+ return result.error(_("Failed to cancel workflow"))
+
+
+class ResumeStreamView(APIView):
+ authentication_classes = [TokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("Resume stream for workflow"),
+ summary=_("Resume stream for workflow"),
+ operation_id=_("Resume stream for workflow"), # type: ignore
+ tags=[_("Application")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
+ )
+ def post(self, request: Request, workspace_id: str, application_id: str, chat_id: str, chat_record_id: str):
+ return ResumeSerializers(data={"chat_id": chat_id, "chat_record_id": chat_record_id}).resume(request)
+
class PromptGenerateView(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['POST'],
+ methods=["POST"],
description=_("generate prompt"),
summary=_("generate prompt"),
operation_id=_("generate prompt"), # type: ignore
request=PromptGenerateAPI.get_request(),
parameters=PromptGenerateAPI.get_parameters(),
responses=None,
- tags=[_('Application')] # type: ignore
+ tags=[_("Application")], # type: ignore
)
- @has_permissions(PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
- @log(menu='Application', operate='Generate prompt',
- get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id')))
- def post(self, request: Request, workspace_id: str, model_id:str, application_id: str):
- return PromptGenerateSerializer(data={'workspace_id': workspace_id, 'model_id': model_id, 'application_id': application_id}).generate_prompt(instance=request.data)
\ No newline at end of file
+ @has_permissions(
+ PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
+ )
+ @log(
+ menu="Application",
+ operate="Generate prompt",
+ get_operation_object=lambda r, k: get_application_operation_object(k.get("application_id")),
+ )
+ def post(self, request: Request, workspace_id: str, model_id: str, application_id: str):
+ return PromptGenerateSerializer(
+ data={"workspace_id": workspace_id, "model_id": model_id, "application_id": application_id}
+ ).generate_prompt(instance=request.data)
+
+
+class DebugHistoricalConversation(APIView):
+ authentication_classes = [TokenAuth]
+
+ class PageView(APIView):
+ authentication_classes = [TokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get historical conversation by page"),
+ summary=_("Get historical conversation by page"),
+ operation_id=_("Get historical conversation by page"), # type: ignore
+ parameters=PageHistoricalConversationAPI.get_parameters(),
+ responses=PageHistoricalConversationAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
+ )
+ def get(self, request: Request, workspace_id: str, application_id: str, current_page: int, page_size: int):
+ from chat.serializers.chat_record import HistoricalConversationSerializer
+
+ return result.success(
+ HistoricalConversationSerializer(
+ data={
+ "application_id": application_id,
+ "chat_user_id": str(request.user.id),
+ }
+ ).page(current_page, page_size)
+ )
+
+ class RecordPageView(APIView):
+ authentication_classes = [TokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get historical conversation records"),
+ summary=_("Get historical conversation records"),
+ operation_id=_("Get historical conversation records"), # type: ignore
+ parameters=HistoricalConversationRecordAPI.get_parameters(),
+ responses=HistoricalConversationRecordAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
+ )
+ def get(
+ self,
+ request: Request,
+ workspace_id: str,
+ application_id: str,
+ chat_id: str,
+ current_page: int,
+ page_size: int,
+ ):
+ from chat.serializers.chat_record import HistoricalConversationRecordSerializer
+
+ serializer = HistoricalConversationRecordSerializer(
+ data={
+ "application_id": application_id,
+ "chat_id": chat_id,
+ "chat_user_id": str(request.user.id),
+ }
+ )
+ return result.success(serializer.page(current_page, page_size))
+
+ class Operate(APIView):
+ authentication_classes = [TokenAuth]
+
+ def delete(self, request: Request, workspace_id: str, application_id: str, chat_id: str):
+ from django.db.models import QuerySet
+ from application.models import Chat
+
+ QuerySet(Chat).filter(id=chat_id, application_id=application_id).update(is_deleted=True)
+ return result.success(True)
+
+ def put(self, request: Request, workspace_id: str, application_id: str, chat_id: str):
+ from django.db.models import QuerySet
+ from application.models import Chat
+
+ abstract = request.data.get("abstract", "")
+ QuerySet(Chat).filter(id=chat_id, application_id=application_id).update(abstract=abstract)
+ return result.success(True)
diff --git a/apps/application/views/application_chat_link.py b/apps/application/views/application_chat_link.py
index d410ef392a6..2ed46cbae2d 100644
--- a/apps/application/views/application_chat_link.py
+++ b/apps/application/views/application_chat_link.py
@@ -1,10 +1,11 @@
"""
- @project: MaxKB
- @Author: niu
- @file: application_chat_link.py
- @date: 2026/2/9 10:44
- @desc:
+@project: MaxKB
+@Author: niu
+@file: application_chat_link.py
+@date: 2026/2/9 10:44
+@desc:
"""
+
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema
from rest_framework.request import Request
@@ -20,36 +21,32 @@ class ChatRecordLinkView(APIView):
authentication_classes = [ChatTokenAuth]
@extend_schema(
- methods=['POST'],
+ methods=["POST"],
description=_("Generate share link"),
summary=_("Generate share link"),
operation_id=_("Generate share link"), # type: ignore
request=ChatRecordLinkAPI.get_request(),
parameters=ChatRecordLinkAPI.get_parameters(),
responses=ChatRecordLinkAPI.get_response(),
- tags=[_("Chat record link")] # type: ignore
+ tags=[_("Chat record link")], # type: ignore
)
-
def post(self, request: Request, application_id: str, chat_id: str):
- return result.success(ChatRecordShareLinkSerializer(data={
- "application_id": application_id,
- "chat_id": chat_id,
- "user_id": request.auth.chat_user_id
- }).generate_link(request.data))
+ return result.success(
+ ChatRecordShareLinkSerializer(
+ data={"application_id": application_id, "chat_id": chat_id, "user_id": request.user.id}
+ ).generate_link(request.data)
+ )
class ChatRecordDetailView(APIView):
-
@extend_schema(
- methods=['GET'],
+ methods=["GET"],
description=_("Get chat record by share link"),
summary=_("Get chat record by share link"),
operation_id=_("Get chat record by share link"), # type: ignore
parameters=ChatRecordDetailShareAPI.get_parameters(),
responses=ChatRecordDetailShareAPI.get_response(),
- tags=[_("Chat record link")] # type: ignore
+ tags=[_("Chat record link")], # type: ignore
)
def get(self, request, link: str):
- return result.success(
- ChatShareLinkDetailSerializer(data={'link':link}).get_record_list()
- )
+ return result.success(ChatShareLinkDetailSerializer(data={"link": link}).get_record_list())
diff --git a/apps/application/views/application_chat_record.py b/apps/application/views/application_chat_record.py
index 0d59146b29d..91213efcc32 100644
--- a/apps/application/views/application_chat_record.py
+++ b/apps/application/views/application_chat_record.py
@@ -1,217 +1,313 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: application_chat_record.py
- @date:2025/6/10 15:08
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: application_chat_record.py
+@date:2025/6/10 15:08
+@desc:
"""
-from django.utils.translation import gettext_lazy as _
-from drf_spectacular.utils import extend_schema
-from rest_framework.request import Request
-from rest_framework.views import APIView
-from application.api.application_chat_record import ApplicationChatRecordQueryAPI, \
- ApplicationChatRecordImproveParagraphAPI, ApplicationChatRecordAddKnowledgeAPI
-from application.serializers.application_chat_record import ApplicationChatRecordQuerySerializers, \
- ApplicationChatRecordImproveSerializer, ChatRecordImproveSerializer, ApplicationChatRecordAddKnowledgeSerializer, \
- ChatRecordOperateSerializer
from common import result
from common.auth import TokenAuth
from common.auth.authentication import has_permissions
-from common.constants.permission_constants import PermissionConstants, RoleConstants, ViewPermission, CompareConstants
+from common.auth.constants.compare_constants import CompareConstants
+from common.auth.constants.permission_constants import PermissionConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.aggregate_permission import ViewPermission
from common.utils.common import query_params_to_single_dict
+from django.utils.translation import gettext_lazy as _
+from drf_spectacular.utils import extend_schema
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from application.api.application_chat_record import (
+ ApplicationChatRecordAddKnowledgeAPI,
+ ApplicationChatRecordImproveParagraphAPI,
+ ApplicationChatRecordQueryAPI,
+)
+from application.serializers.application_chat_record import (
+ ApplicationChatRecordAddKnowledgeSerializer,
+ ApplicationChatRecordImproveSerializer,
+ ApplicationChatRecordQuerySerializers,
+ ChatRecordImproveSerializer,
+ ChatRecordOperateSerializer,
+)
class ApplicationChatRecord(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['GET'],
+ methods=["GET"],
description=_("Get the conversation record list"),
summary=_("Get the conversation record list"),
operation_id=_("Get the conversation record list"), # type: ignore
request=ApplicationChatRecordQueryAPI.get_request(),
parameters=ApplicationChatRecordQueryAPI.get_parameters(),
responses=ApplicationChatRecordQueryAPI.get_response(),
- tags=[_("Application/Conversation Log")] # type: ignore
+ tags=[_("Application/Conversation Log")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str, chat_id: str):
- return result.success(ApplicationChatRecordQuerySerializers(
- data={**query_params_to_single_dict(request.query_params), 'workspace_id': workspace_id,
- 'application_id': application_id,
- 'chat_id': chat_id
- }).list())
+ return result.success(
+ ApplicationChatRecordQuerySerializers(
+ data={
+ **query_params_to_single_dict(request.query_params),
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ "chat_id": chat_id,
+ }
+ ).list()
+ )
class Page(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['GET'],
+ methods=["GET"],
description=_("Get the conversation record list by page"),
summary=_("Get the conversation record list by page"),
operation_id=_("Get the conversation record list by page"), # type: ignore
request=ApplicationChatRecordQueryAPI.get_request(),
parameters=ApplicationChatRecordQueryAPI.get_parameters(),
responses=ApplicationChatRecordQueryAPI.get_response(),
- tags=[_("Application/Conversation Log")] # type: ignore
+ tags=[_("Application/Conversation Log")], # type: ignore
)
- @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
- def get(self, request: Request, workspace_id: str, application_id: str, chat_id: str, current_page: int,
- page_size: int):
- return result.success(ApplicationChatRecordQuerySerializers(
- data={**query_params_to_single_dict(request.query_params), 'workspace_id': workspace_id,
- 'application_id': application_id,
- 'chat_id': chat_id}).page(
- current_page=current_page,
- page_size=page_size))
+ @has_permissions(
+ PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND, ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
+ def get(
+ self,
+ request: Request,
+ workspace_id: str,
+ application_id: str,
+ chat_id: str,
+ current_page: int,
+ page_size: int,
+ ):
+ return result.success(
+ ApplicationChatRecordQuerySerializers(
+ data={
+ **query_params_to_single_dict(request.query_params),
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ "chat_id": chat_id,
+ }
+ ).page(current_page=current_page, page_size=page_size)
+ )
class ApplicationChatRecordOperateAPI(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['GET'],
+ methods=["GET"],
description=_("Get conversation record details"),
summary=_("Get conversation record details"),
operation_id=_("Get conversation record details"), # type: ignore
request=ApplicationChatRecordQueryAPI.get_request(),
parameters=ApplicationChatRecordQueryAPI.get_parameters(),
responses=ApplicationChatRecordQueryAPI.get_response(),
- tags=[_("Application/Conversation Log")] # type: ignore
+ tags=[_("Application/Conversation Log")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
+ PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_CHAT_LOG_READ.get_workspace_permission_workspace_manage_role(),
- PermissionConstants.APPLICATION_READ.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str, chat_id: str, chat_record_id: str):
- return result.success(ChatRecordOperateSerializer(
- data={
- 'workspace_id': workspace_id,
- 'application_id': application_id,
- 'chat_id': chat_id,
- 'chat_record_id': chat_record_id}).one(True))
+ return result.success(
+ ChatRecordOperateSerializer(
+ data={
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ "chat_id": chat_id,
+ "chat_record_id": chat_record_id,
+ }
+ ).one(True)
+ )
class ApplicationChatRecordAddKnowledge(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['POST'],
+ methods=["POST"],
description=_("Add to Knowledge Base"),
summary=_("Add to Knowledge Base"),
operation_id=_("Add to Knowledge Base"), # type: ignore
request=ApplicationChatRecordAddKnowledgeAPI.get_request(),
parameters=ApplicationChatRecordAddKnowledgeAPI.get_parameters(),
responses=ApplicationChatRecordAddKnowledgeAPI.get_response(),
- tags=[_("Application/Conversation Log")] # type: ignore
+ tags=[_("Application/Conversation Log")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_CHAT_LOG_ADD_KNOWLEDGE.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_CHAT_LOG_ADD_KNOWLEDGE.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_ADD_KNOWLEDGE.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_CHAT_LOG_ADD_KNOWLEDGE.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def post(self, request: Request, workspace_id: str, application_id: str):
- return result.success(ApplicationChatRecordAddKnowledgeSerializer(data = {'workspace_id': workspace_id, 'application_id': application_id, **request.data}).post_improve(
- {'workspace_id': workspace_id, 'application_id': application_id, **request.data}, request=request))
+ return result.success(
+ ApplicationChatRecordAddKnowledgeSerializer(
+ data={"workspace_id": workspace_id, "application_id": application_id, **request.data}
+ ).post_improve(
+ {"workspace_id": workspace_id, "application_id": application_id, **request.data}, request=request
+ )
+ )
class ApplicationChatRecordImprove(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['GET'],
+ methods=["GET"],
description=_("Get the list of marked paragraphs"),
summary=_("Get the list of marked paragraphs"),
operation_id=_("Get the list of marked paragraphs"), # type: ignore
request=ApplicationChatRecordQueryAPI.get_request(),
parameters=ApplicationChatRecordQueryAPI.get_parameters(),
responses=ApplicationChatRecordQueryAPI.get_response(),
- tags=[_("Application/Conversation Log")] # type: ignore
+ tags=[_("Application/Conversation Log")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str, chat_id: str, chat_record_id: str):
- return result.success(ChatRecordImproveSerializer(
- data={'workspace_id': workspace_id, 'application_id': application_id, 'chat_id': chat_id,
- 'chat_record_id': chat_record_id}).get())
+ return result.success(
+ ChatRecordImproveSerializer(
+ data={
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ "chat_id": chat_id,
+ "chat_record_id": chat_record_id,
+ }
+ ).get()
+ )
class ApplicationChatRecordImproveParagraph(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['PUT'],
+ methods=["PUT"],
description=_("Annotation"),
summary=_("Annotation"),
operation_id=_("Annotation"), # type: ignore
request=ApplicationChatRecordImproveParagraphAPI.get_request(),
parameters=ApplicationChatRecordImproveParagraphAPI.get_parameters(),
responses=ApplicationChatRecordImproveParagraphAPI.get_response(),
- tags=[_("Application/Conversation Log")] # type: ignore
+ tags=[_("Application/Conversation Log")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
- def put(self, request: Request,
+ def put(
+ self,
+ request: Request,
workspace_id: str,
application_id: str,
chat_id: str,
chat_record_id: str,
knowledge_id: str,
- document_id: str):
- return result.success(ApplicationChatRecordImproveSerializer(
- data={'workspace_id': workspace_id, 'application_id': application_id, 'chat_id': chat_id,
- 'chat_record_id': chat_record_id,
- 'knowledge_id': knowledge_id, 'document_id': document_id}).improve(request.data, request=request))
+ document_id: str,
+ ):
+ return result.success(
+ ApplicationChatRecordImproveSerializer(
+ data={
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ "chat_id": chat_id,
+ "chat_record_id": chat_record_id,
+ "knowledge_id": knowledge_id,
+ "document_id": document_id,
+ }
+ ).improve(request.data, request=request)
+ )
class Operate(APIView):
authentication_classes = [TokenAuth]
@extend_schema(
- methods=['DELETE'],
+ methods=["DELETE"],
description=_("Delete a Annotation"),
summary=_("Delete a Annotation"),
operation_id=_("Delete a Annotation"), # type: ignore
request=ApplicationChatRecordImproveParagraphAPI.Operate.get_request(),
parameters=ApplicationChatRecordImproveParagraphAPI.Operate.get_parameters(),
responses=ApplicationChatRecordImproveParagraphAPI.Operate.get_response(),
- tags=[_("Application/Conversation Log")] # type: ignore
+ tags=[_("Application/Conversation Log")], # type: ignore
+ )
+ @has_permissions(
+ PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_application_permission(),
+ PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_permission_workspace_manage_role(),
+ ViewPermission(
+ [RoleConstants.USER.get_workspace_role()],
+ [PermissionConstants.APPLICATION.get_workspace_application_permission()],
+ compare=CompareConstants.AND,
+ ),
+ RoleConstants.WORKSPACE_MANAGE.get_workspace_role(),
)
- @has_permissions(PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_application_permission(),
- PermissionConstants.APPLICATION_CHAT_LOG_ANNOTATION.get_workspace_permission_workspace_manage_role(),
- ViewPermission([RoleConstants.USER.get_workspace_role()],
- [PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
- RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
- def delete(self, request: Request, workspace_id: str, application_id: str, chat_id: str, chat_record_id: str,
- knowledge_id: str,
- document_id: str, paragraph_id: str):
- return result.success(ApplicationChatRecordImproveSerializer.Operate(
- data={'chat_id': chat_id, 'chat_record_id': chat_record_id, 'workspace_id': workspace_id,
- 'application_id': application_id,
- 'knowledge_id': knowledge_id, 'document_id': document_id,
- 'paragraph_id': paragraph_id}).delete(request=request))
+ def delete(
+ self,
+ request: Request,
+ workspace_id: str,
+ application_id: str,
+ chat_id: str,
+ chat_record_id: str,
+ knowledge_id: str,
+ document_id: str,
+ paragraph_id: str,
+ ):
+ return result.success(
+ ApplicationChatRecordImproveSerializer.Operate(
+ data={
+ "chat_id": chat_id,
+ "chat_record_id": chat_record_id,
+ "workspace_id": workspace_id,
+ "application_id": application_id,
+ "knowledge_id": knowledge_id,
+ "document_id": document_id,
+ "paragraph_id": paragraph_id,
+ }
+ ).delete(request=request)
+ )
diff --git a/apps/application/views/application_stats.py b/apps/application/views/application_stats.py
index 4567156175e..c4fbe65bf9b 100644
--- a/apps/application/views/application_stats.py
+++ b/apps/application/views/application_stats.py
@@ -17,7 +17,10 @@
from django.utils.translation import gettext_lazy as _
from common.auth.authentication import has_permissions
-from common.constants.permission_constants import PermissionConstants, RoleConstants, ViewPermission, CompareConstants
+from common.auth.constants.compare_constants import CompareConstants
+from common.auth.constants.permission_constants import PermissionConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.aggregate_permission import ViewPermission
class ApplicationStats(APIView):
@@ -36,7 +39,7 @@ class ApplicationStats(APIView):
PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str):
return result.success(
@@ -64,7 +67,7 @@ class TokenUsageStatistics(APIView):
PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str):
return result.success(
@@ -91,7 +94,7 @@ class TopQuestionsStatistics(APIView):
PermissionConstants.APPLICATION_OVERVIEW_READ.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str):
return result.success(
diff --git a/apps/application/views/application_version.py b/apps/application/views/application_version.py
index 3a87a1533f0..ad3c3db9797 100644
--- a/apps/application/views/application_version.py
+++ b/apps/application/views/application_version.py
@@ -18,7 +18,10 @@
from common import result
from common.auth import TokenAuth
from common.auth.authentication import has_permissions
-from common.constants.permission_constants import PermissionConstants, RoleConstants, ViewPermission, CompareConstants
+from common.auth.constants.compare_constants import CompareConstants
+from common.auth.constants.permission_constants import PermissionConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.aggregate_permission import ViewPermission
from common.log.log import log
@@ -38,7 +41,7 @@ class ApplicationVersionView(APIView):
PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id, application_id: str):
return result.success(
@@ -62,7 +65,7 @@ class Page(APIView):
PermissionConstants.APPLICATION_READ.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str, current_page: int, page_size: int):
return result.success(
@@ -87,7 +90,7 @@ class Operate(APIView):
PermissionConstants.APPLICATION_EDIT.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
def get(self, request: Request, workspace_id: str, application_id: str, application_version_id: str):
return result.success(
@@ -109,7 +112,7 @@ def get(self, request: Request, workspace_id: str, application_id: str, applicat
PermissionConstants.APPLICATION_EDIT.get_workspace_permission_workspace_manage_role(),
ViewPermission([RoleConstants.USER.get_workspace_role()],
[PermissionConstants.APPLICATION.get_workspace_application_permission()],
- CompareConstants.AND),
+ compare=CompareConstants.AND),
RoleConstants.WORKSPACE_MANAGE.get_workspace_role())
@log(menu='Application', operate="Modify application version information",
get_operation_object=lambda r, k: get_application_operation_object(k.get('application_id')),
diff --git a/apps/application/workflow/__init__.py b/apps/application/workflow/__init__.py
new file mode 100644
index 00000000000..c4f90ff4f38
--- /dev/null
+++ b/apps/application/workflow/__init__.py
@@ -0,0 +1,8 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/6/29 16:15
+ @desc:
+"""
diff --git a/apps/application/flow/backend/__init__.py b/apps/application/workflow/backend/__init__.py
similarity index 100%
rename from apps/application/flow/backend/__init__.py
rename to apps/application/workflow/backend/__init__.py
diff --git a/apps/application/workflow/backend/sandbox_mcp.py b/apps/application/workflow/backend/sandbox_mcp.py
new file mode 100644
index 00000000000..12f0780248a
--- /dev/null
+++ b/apps/application/workflow/backend/sandbox_mcp.py
@@ -0,0 +1,47 @@
+"""MCP backend honoring the application's sandbox switch."""
+
+from langchain_mcp_adapters.client import MultiServerMCPClient
+from mcp.types import CallToolResult
+
+from common.mcp.config import InternalMCPConfig, remote_connection, validate_mcp_servers
+from common.mcp.sandbox import sandbox_connection
+from maxkb.const import CONFIG
+
+
+class SandboxMCPBackend(MultiServerMCPClient):
+ """Provide MCP tools and sessions using the configured sandbox mode.
+
+ With SANDBOX enabled, inherited get_tools() creates tools whose later
+ invocations also open sandbox workers. When explicitly disabled, use remote
+ SDK connections directly for local development. This backend supplies the
+ agent's tools; SandboxShellBackend handles skill files and shell commands.
+ """
+
+ def __init__(self, servers: dict):
+ super().__init__(connections=self._build_connections(servers))
+
+ @staticmethod
+ def _build_connections(servers: dict) -> dict:
+ if not isinstance(servers, dict):
+ raise ValueError("MCP servers must be an object")
+ connections = {}
+ for name, config in servers.items():
+ if not isinstance(config, dict):
+ raise ValueError("MCP server configuration must be an object")
+ internal = isinstance(config, InternalMCPConfig)
+ if internal and config.get("transport") == "stdio":
+ connections[name] = dict(config)
+ continue
+ validate_mcp_servers({name: config})
+ if internal:
+ connections[name] = dict(config)
+ elif bool(int(CONFIG.get("SANDBOX", 1))):
+ connections[name] = sandbox_connection(config)
+ else:
+ connections[name] = remote_connection(config)
+ return connections
+
+ async def call_tool(self, server_name: str, tool_name: str, arguments: dict | None = None) -> CallToolResult:
+ """Call one tool and close its session/worker, preserving the MCP result."""
+ async with self.session(server_name) as session:
+ return await session.call_tool(tool_name, arguments)
diff --git a/apps/application/workflow/backend/sandbox_shell.py b/apps/application/workflow/backend/sandbox_shell.py
new file mode 100644
index 00000000000..7dac1f90376
--- /dev/null
+++ b/apps/application/workflow/backend/sandbox_shell.py
@@ -0,0 +1,311 @@
+import getpass
+import os
+import re
+import shlex
+
+from deepagents.backends import LocalShellBackend
+from deepagents.backends.protocol import ExecuteResponse
+
+from common.utils.logger import maxkb_logger
+from maxkb.const import CONFIG
+
+_enable_sandbox = bool(int(CONFIG.get("SANDBOX", 1)))
+_run_user = "sandbox" if _enable_sandbox else getpass.getuser()
+_sandbox_python_sys_path = CONFIG.get_sandbox_python_package_paths().replace(",", ":")
+
+
+class SandboxShellBackend(LocalShellBackend):
+ def __init__(self, root_dir: str, **kwargs):
+ if "env" not in kwargs and not kwargs.get("inherit_env", False):
+ env = os.environ.copy()
+ python_path = env.get("PYTHONPATH", "")
+
+ # 将 sandbox Python 包路径分解为列表,检查每个路径是否已存在
+ existing_paths = set(python_path.split(os.pathsep))
+ sandbox_paths = _sandbox_python_sys_path.split(os.pathsep) if _sandbox_python_sys_path else []
+ new_paths = [p for p in sandbox_paths if p and p not in existing_paths]
+
+ if new_paths:
+ env["PYTHONPATH"] = (
+ f"{os.pathsep.join(new_paths)}{os.pathsep}{python_path}"
+ if python_path
+ else os.pathsep.join(new_paths)
+ )
+
+ kwargs["env"] = env
+ super().__init__(root_dir=root_dir, **kwargs)
+
+ def _translate_virtual_paths(self, command: str) -> str:
+ """Translate virtual absolute paths in the command to real filesystem paths.
+
+ In virtual_mode=True, file tools (ls, glob, read_file) return virtual absolute
+ paths like /skills/foo.py which map to {root_dir}/skills/foo.py. But execute()
+ runs a real shell where /skills/foo.py does not exist. This method replaces
+ any path token that exists under root_dir with its real path, while leaving
+ genuine system paths (e.g. /usr/bin/python3) untouched.
+ """
+ root = str(self.cwd)
+
+ def translate(m: re.Match) -> str:
+ virtual_path = m.group(0)
+ real_path = root + virtual_path
+ return real_path if os.path.lexists(real_path) else virtual_path
+
+ # Match absolute-path-like tokens: / followed by a non-whitespace sequence
+ # that isn't clearly a flag (e.g. avoid matching -/something).
+ # Only translate when virtual_mode is active.
+ return re.sub(r'(?<:,]*', translate, command)
+
+ def _consume_group(self, command: str, start_index: int) -> tuple[str, int]:
+ current = []
+ in_single_quote = False
+ in_double_quote = False
+ in_backticks = False
+ escaped = False
+ substitution_depth = 0
+ group_depth = 1
+ index = start_index + 1
+
+ while index < len(command):
+ char = command[index]
+
+ if escaped:
+ current.append(char)
+ escaped = False
+ index += 1
+ continue
+
+ if char == "\\" and not in_single_quote:
+ current.append(char)
+ escaped = True
+ index += 1
+ continue
+
+ if char == "`" and not in_single_quote:
+ in_backticks = not in_backticks
+ current.append(char)
+ index += 1
+ continue
+
+ if in_backticks:
+ current.append(char)
+ index += 1
+ continue
+
+ if char == "'" and not in_double_quote:
+ in_single_quote = not in_single_quote
+ current.append(char)
+ index += 1
+ continue
+
+ if char == '"' and not in_single_quote:
+ in_double_quote = not in_double_quote
+ current.append(char)
+ index += 1
+ continue
+
+ if in_single_quote or in_double_quote:
+ current.append(char)
+ index += 1
+ continue
+
+ if command.startswith("$(", index):
+ substitution_depth += 1
+ current.append("$(")
+ index += 2
+ continue
+
+ if substitution_depth:
+ if char == ")":
+ substitution_depth -= 1
+ current.append(char)
+ index += 1
+ continue
+
+ if char == "(":
+ group_depth += 1
+ current.append(char)
+ index += 1
+ continue
+
+ if char == ")":
+ group_depth -= 1
+ if group_depth == 0:
+ return "".join(current).strip(), index + 1
+ current.append(char)
+ index += 1
+ continue
+
+ current.append(char)
+ index += 1
+
+ raise ValueError("unclosed command group")
+
+ def _append_pending_command_part(self, parts: list[str | tuple[str, str]], current: list[str]) -> None:
+ part = "".join(current).strip()
+ if part:
+ parts.append(part)
+ return
+
+ if not parts:
+ parts.append("")
+ return
+
+ last_part = parts[-1]
+ if isinstance(last_part, str) and last_part in {";", "&&", "||", "|", "&"}:
+ parts.append("")
+
+ def _split_shell_command_list(self, command: str) -> list[str | tuple[str, str]]:
+ parts = []
+ current = []
+ in_single_quote = False
+ in_double_quote = False
+ in_backticks = False
+ escaped = False
+ substitution_depth = 0
+ index = 0
+
+ while index < len(command):
+ char = command[index]
+
+ if escaped:
+ current.append(char)
+ escaped = False
+ index += 1
+ continue
+
+ if char == "\\" and not in_single_quote:
+ current.append(char)
+ escaped = True
+ index += 1
+ continue
+
+ if char == "`" and not in_single_quote:
+ in_backticks = not in_backticks
+ current.append(char)
+ index += 1
+ continue
+
+ if in_backticks:
+ current.append(char)
+ index += 1
+ continue
+
+ if char == "'" and not in_double_quote:
+ in_single_quote = not in_single_quote
+ current.append(char)
+ index += 1
+ continue
+
+ if char == '"' and not in_single_quote:
+ in_double_quote = not in_double_quote
+ current.append(char)
+ index += 1
+ continue
+
+ if not in_single_quote and not in_double_quote:
+ if command.startswith("$(", index):
+ substitution_depth += 1
+ current.append("$(")
+ index += 2
+ continue
+
+ if substitution_depth:
+ if char == ")":
+ substitution_depth -= 1
+ current.append(char)
+ index += 1
+ continue
+
+ if char == "(" and not "".join(current).strip():
+ group_content, index = self._consume_group(command, index)
+ parts.append(("group", group_content))
+ current = []
+ continue
+
+ if command.startswith("&&", index) or command.startswith("||", index):
+ self._append_pending_command_part(parts, current)
+ parts.append(command[index : index + 2])
+ current = []
+ index += 2
+ continue
+
+ if char in {";", "|", "&"}:
+ self._append_pending_command_part(parts, current)
+ parts.append(char)
+ current = []
+ index += 1
+ continue
+
+ if char == "\n":
+ self._append_pending_command_part(parts, current)
+ parts.append(";")
+ current = []
+ index += 1
+ continue
+
+ current.append(char)
+ index += 1
+
+ self._append_pending_command_part(parts, current)
+ return parts
+
+ def _build_sandbox_command(self, command: str) -> str:
+ prefix = (
+ "env -i LD_PRELOAD=/opt/maxkb-app/sandbox/lib/sandbox.so "
+ f'PATH="${{PATH}}" PYTHONPATH="${{PYTHONPATH}}" gosu {_run_user} '
+ )
+ parts = self._split_shell_command_list(command)
+ sandboxed_parts = []
+ expect_command = True
+
+ for part in parts:
+ if expect_command:
+ if isinstance(part, tuple):
+ group_kind, group_content = part
+ if group_kind != "group":
+ raise ValueError(f"unsupported command part: {group_kind}")
+ if not group_content:
+ raise ValueError("empty command group")
+ sandboxed_parts.append(f"( {self._build_sandbox_command(group_content)} )")
+ elif not part:
+ raise ValueError("empty command")
+ else:
+ tokens = shlex.split(part)
+ if not tokens:
+ raise ValueError("empty command")
+ sandboxed_parts.append(prefix + " ".join(shlex.quote(token) for token in tokens))
+ else:
+ if part not in {";", "&&", "||", "|", "&"}:
+ raise ValueError(f"unsupported shell operator: {part}")
+ sandboxed_parts.append(part)
+
+ expect_command = not expect_command
+
+ if expect_command:
+ raise ValueError("command cannot end with a shell operator")
+
+ return " ".join(sandboxed_parts)
+
+ def execute(
+ self,
+ command: str,
+ *,
+ timeout: int | None = None,
+ ) -> ExecuteResponse:
+ if self.virtual_mode:
+ command = self._translate_virtual_paths(command)
+
+ if _enable_sandbox:
+ # 用 runuser 在子进程里切换用户,父进程凭据保持不变,
+ # 避免父进程 ruid/euid 不一致导致 execve 报 Permission denied
+ try:
+ # 将命令列表拆成多个简单命令,并分别在 sandbox 用户下执行。
+ # 每个简单命令仍按 argv 重新 quote,避免 $()、反引号等在父 shell 中展开。
+ command = self._build_sandbox_command(command)
+ except ValueError as e:
+ return ExecuteResponse(output=f"Invalid command: {e}", exit_code=1)
+ # command = f"runuser -u {_run_user} -- env -i PATH=${{PATH}} {command}"
+
+ maxkb_logger.debug(f"Executing command in sandbox: {command}")
+ return super().execute(command=command, timeout=timeout)
diff --git a/apps/application/workflow/common.py b/apps/application/workflow/common.py
new file mode 100644
index 00000000000..802b2a4ee39
--- /dev/null
+++ b/apps/application/workflow/common.py
@@ -0,0 +1,248 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: workflow.py
+@date:2026/6/29 10:58
+@desc:
+"""
+
+from enum import Enum
+from typing import List, Dict
+
+from django.utils.translation import gettext as _
+from common.exception.app_exception import AppApiException
+from common.utils.common import group_by
+
+
+class Node:
+ def __init__(self, _id: str, _type: str, x: int, y: int, properties: dict, **kwargs):
+ """
+
+ @param _id: 节点id
+ @param _type: 类型
+ @param x: 节点x轴位置
+ @param y: 节点y轴位置
+ @param properties:
+ @param kwargs:
+ """
+ self.id = _id
+ self.type = _type
+ self.x = x
+ self.y = y
+ self.properties = properties
+ for keyword in kwargs:
+ self.__setattr__(keyword, kwargs.get(keyword))
+
+
+class Edge:
+ def __init__(self, _id: str, _type: str, sourceNodeId: str, targetNodeId: str, **keywords):
+ """
+ 线
+ @param _id: 线id
+ @param _type: 线类型
+ @param sourceNodeId:
+ @param targetNodeId:
+ @param keywords:
+ """
+ self.id = _id
+ self.type = _type
+ self.sourceNodeId = sourceNodeId
+ self.targetNodeId = targetNodeId
+ for keyword in keywords:
+ self.__setattr__(keyword, keywords.get(keyword))
+
+
+class EdgeNode:
+ edge: Edge
+ node: Node
+
+ def __init__(self, edge, node):
+ self.edge = edge
+ self.node = node
+
+
+def init_fields(workflow):
+ result = []
+ for node in workflow.nodes:
+ properties = node.properties
+ node_name = properties.get("stepName")
+ node_id = node.id
+ node_config = properties.get("config")
+ result.append(NodeField(node_id, node_name, "异常信息", "exception_message"))
+ if node_config is not None:
+ fields = node_config.get("fields")
+ if fields is not None:
+ for field in fields:
+ result.append(NodeField(node_id, node_name, field.get("label"), field.get("value")))
+ global_fields = node_config.get("globalFields")
+ if global_fields is not None:
+ for global_field in global_fields:
+ result.append(NodeField("global", "全局变量", global_field.get("label"), global_field.get("value")))
+ chat_fields = node_config.get("chatFields")
+ if chat_fields is not None:
+ for chat_field in chat_fields:
+ result.append(NodeField("chat", "chat", chat_field.get("label"), chat_field.get("value")))
+ result.sort(key=lambda f: len(f.node_name + f.value), reverse=True)
+ return result
+
+
+def get_node_parameters(node):
+ return node.properties.get("node_data", {})
+
+
+class NodeField:
+ def __init__(self, node_id, node_name, label, value):
+ self.node_id = node_id
+ self.node_name = node_name
+ self.label = label
+ self.value = value
+
+ def reset_variable(self, prompt: str):
+ userVariable = self.node_name + "." + self.value
+ systemVariable = f"context.get('{self.node_id}').get('{self.value}','')"
+ prompt = prompt.replace(userVariable, systemVariable)
+ # 全局变量:前端用 global.xxx 引用,也要能解析到 context['global']
+ if self.node_id == "global":
+ prompt = prompt.replace(f"global.{self.value}", systemVariable)
+ return prompt
+
+
+class WorkflowType(Enum):
+ # 应用
+ APPLICATION = "APPLICATION"
+ # 知识库
+ KNOWLEDGE = "KNOWLEDGE"
+ # 工具
+ TOOL = "TOOL"
+
+
+class Workflow:
+ """
+ 节点列表
+ """
+
+ nodes: List[Node]
+ """
+ 线列表
+ """
+ edges: List[Edge]
+ """
+ 节点id:node
+ """
+ node_map: Dict[str, Node]
+ """
+ 节点id:当前节点id上面的所有节点
+ """
+ up_node_map: Dict[str, List[EdgeNode]]
+ """
+ 节点id:当前节点id下面的所有节点
+ """
+ next_node_map: Dict[str, List[EdgeNode]]
+ """
+ 节点字段
+ """
+ node_field_list: List[NodeField]
+
+ def __init__(self, nodes: List[Node], edges: List[Edge]):
+ self.nodes = nodes
+ self.edges = edges
+ self.node_map = {node.id: node for node in nodes}
+
+ self.up_node_map = {
+ key: [EdgeNode(edge, self.node_map.get(edge.sourceNodeId)) for edge in edges]
+ for key, edges in group_by(edges, key=lambda edge: edge.targetNodeId).items()
+ }
+
+ self.next_node_map = {
+ key: [EdgeNode(edge, self.node_map.get(edge.targetNodeId)) for edge in edges]
+ for key, edges in group_by(edges, key=lambda edge: edge.sourceNodeId).items()
+ }
+ self.node_field_list = init_fields(self)
+
+ def get_node(self, node_id):
+ """
+ 根据node_id 获取节点信息
+ @param node_id: node_id
+ @return: 节点信息
+ """
+ return self.node_map.get(node_id)
+
+ def get_up_edge_nodes(self, node_id) -> List[EdgeNode]:
+ """
+ 根据节点id 获取当前连接前置节点和连线
+ @param node_id: 节点id
+ @return: 节点连线列表
+ """
+ return self.up_node_map.get(node_id)
+
+ def get_next_edge_nodes(self, node_id) -> List[EdgeNode]:
+ """
+ 根据节点id 获取当前连接目标节点和连线
+ @param node_id: 节点id
+ @return: 节点连线列表
+ """
+ return self.next_node_map.get(node_id)
+
+ def get_up_nodes(self, node_id) -> List[Node]:
+ """
+ 根据节点id 获取当前连接前置节点
+ @param node_id: 节点id
+ @return: 节点列表
+ """
+ return [en.node for en in self.up_node_map.get(node_id)]
+
+ def get_next_nodes(self, node_id) -> List[Node]:
+ """
+ 根据节点id 获取当前连接目标节点
+ @param node_id: 节点id
+ @return: 节点列表
+ """
+ return [en.node for en in self.next_node_map.get(node_id, [])]
+
+ def reset_prompt(self, prompt):
+ for node_field in self.node_field_list:
+ prompt = node_field.reset_variable(prompt)
+ return prompt
+
+ def is_valid(self, workflow_type: WorkflowType):
+ """
+ 校验工作流数据:一趟遍历同时统计节点id出现次数、校验每个节点的参数
+ """
+ start_node_list = []
+ for node in self.nodes:
+ if node.id == "start-node":
+ start_node_list.append(node)
+ self.is_valid_node(node, workflow_type)
+ self.is_valid_start_node(start_node_list)
+
+ def is_valid_start_node(self, start_node_list: List[Node]):
+ """
+ 校验开始节点:有且只有一个 start-node
+ """
+ if len(start_node_list) == 0:
+ raise AppApiException(500, _("The starting node is required"))
+ if len(start_node_list) > 1:
+ raise AppApiException(500, _("There can only be one starting node"))
+
+ def is_valid_node(self, node: Node, workflow_type: WorkflowType = WorkflowType.APPLICATION):
+ """
+ 校验单个节点:交给该节点类型对应的序列化器
+ """
+ from application.workflow.nodes import node_map
+
+ node_class = node_map.get(node.type, {}).get(workflow_type)
+ if node_class is None or node_class.serializer_class is None:
+ return
+ try:
+ node_class.serializer_class(data=get_node_parameters(node)).is_valid(raise_exception=True)
+ except AppApiException as e:
+ raise AppApiException(500, f"{node.properties.get('stepName')}:{e.message}")
+
+
+def new_instance(flow_obj: Dict, workflow_type: WorkflowType = WorkflowType.APPLICATION):
+ nodes = flow_obj.get("nodes")
+ edges = flow_obj.get("edges")
+ nodes = [Node(node.get("id"), node.get("type"), **node) for node in nodes]
+ edges = [Edge(edge.get("id"), edge.get("type"), **edge) for edge in edges]
+ return Workflow(nodes, edges)
diff --git a/apps/application/flow/compare/__init__.py b/apps/application/workflow/compare/__init__.py
similarity index 100%
rename from apps/application/flow/compare/__init__.py
rename to apps/application/workflow/compare/__init__.py
diff --git a/apps/application/flow/compare/compare.py b/apps/application/workflow/compare/compare.py
similarity index 100%
rename from apps/application/flow/compare/compare.py
rename to apps/application/workflow/compare/compare.py
diff --git a/apps/application/flow/compare/contain_compare.py b/apps/application/workflow/compare/contain_compare.py
similarity index 100%
rename from apps/application/flow/compare/contain_compare.py
rename to apps/application/workflow/compare/contain_compare.py
diff --git a/apps/application/flow/compare/end_with.py b/apps/application/workflow/compare/end_with.py
similarity index 100%
rename from apps/application/flow/compare/end_with.py
rename to apps/application/workflow/compare/end_with.py
diff --git a/apps/application/flow/compare/equal_compare.py b/apps/application/workflow/compare/equal_compare.py
similarity index 100%
rename from apps/application/flow/compare/equal_compare.py
rename to apps/application/workflow/compare/equal_compare.py
diff --git a/apps/application/flow/compare/ge_compare.py b/apps/application/workflow/compare/ge_compare.py
similarity index 100%
rename from apps/application/flow/compare/ge_compare.py
rename to apps/application/workflow/compare/ge_compare.py
diff --git a/apps/application/flow/compare/gt_compare.py b/apps/application/workflow/compare/gt_compare.py
similarity index 100%
rename from apps/application/flow/compare/gt_compare.py
rename to apps/application/workflow/compare/gt_compare.py
diff --git a/apps/application/flow/compare/is_not_null_compare.py b/apps/application/workflow/compare/is_not_null_compare.py
similarity index 100%
rename from apps/application/flow/compare/is_not_null_compare.py
rename to apps/application/workflow/compare/is_not_null_compare.py
diff --git a/apps/application/flow/compare/is_not_true.py b/apps/application/workflow/compare/is_not_true.py
similarity index 100%
rename from apps/application/flow/compare/is_not_true.py
rename to apps/application/workflow/compare/is_not_true.py
diff --git a/apps/application/flow/compare/is_null_compare.py b/apps/application/workflow/compare/is_null_compare.py
similarity index 100%
rename from apps/application/flow/compare/is_null_compare.py
rename to apps/application/workflow/compare/is_null_compare.py
diff --git a/apps/application/flow/compare/is_true.py b/apps/application/workflow/compare/is_true.py
similarity index 100%
rename from apps/application/flow/compare/is_true.py
rename to apps/application/workflow/compare/is_true.py
diff --git a/apps/application/flow/compare/le_compare.py b/apps/application/workflow/compare/le_compare.py
similarity index 100%
rename from apps/application/flow/compare/le_compare.py
rename to apps/application/workflow/compare/le_compare.py
diff --git a/apps/application/flow/compare/len_equal_compare.py b/apps/application/workflow/compare/len_equal_compare.py
similarity index 100%
rename from apps/application/flow/compare/len_equal_compare.py
rename to apps/application/workflow/compare/len_equal_compare.py
diff --git a/apps/application/flow/compare/len_ge_compare.py b/apps/application/workflow/compare/len_ge_compare.py
similarity index 100%
rename from apps/application/flow/compare/len_ge_compare.py
rename to apps/application/workflow/compare/len_ge_compare.py
diff --git a/apps/application/flow/compare/len_gt_compare.py b/apps/application/workflow/compare/len_gt_compare.py
similarity index 100%
rename from apps/application/flow/compare/len_gt_compare.py
rename to apps/application/workflow/compare/len_gt_compare.py
diff --git a/apps/application/flow/compare/len_le_compare.py b/apps/application/workflow/compare/len_le_compare.py
similarity index 100%
rename from apps/application/flow/compare/len_le_compare.py
rename to apps/application/workflow/compare/len_le_compare.py
diff --git a/apps/application/flow/compare/len_lt_compare.py b/apps/application/workflow/compare/len_lt_compare.py
similarity index 100%
rename from apps/application/flow/compare/len_lt_compare.py
rename to apps/application/workflow/compare/len_lt_compare.py
diff --git a/apps/application/flow/compare/lt_compare.py b/apps/application/workflow/compare/lt_compare.py
similarity index 100%
rename from apps/application/flow/compare/lt_compare.py
rename to apps/application/workflow/compare/lt_compare.py
diff --git a/apps/application/flow/compare/not_contain_compare.py b/apps/application/workflow/compare/not_contain_compare.py
similarity index 100%
rename from apps/application/flow/compare/not_contain_compare.py
rename to apps/application/workflow/compare/not_contain_compare.py
diff --git a/apps/application/flow/compare/not_equal_compare.py b/apps/application/workflow/compare/not_equal_compare.py
similarity index 100%
rename from apps/application/flow/compare/not_equal_compare.py
rename to apps/application/workflow/compare/not_equal_compare.py
diff --git a/apps/application/flow/compare/regex_compare.py b/apps/application/workflow/compare/regex_compare.py
similarity index 100%
rename from apps/application/flow/compare/regex_compare.py
rename to apps/application/workflow/compare/regex_compare.py
diff --git a/apps/application/flow/compare/start_with.py b/apps/application/workflow/compare/start_with.py
similarity index 100%
rename from apps/application/flow/compare/start_with.py
rename to apps/application/workflow/compare/start_with.py
diff --git a/apps/application/flow/compare/wildcard_compare.py b/apps/application/workflow/compare/wildcard_compare.py
similarity index 100%
rename from apps/application/flow/compare/wildcard_compare.py
rename to apps/application/workflow/compare/wildcard_compare.py
diff --git a/apps/application/workflow/content_type.py b/apps/application/workflow/content_type.py
new file mode 100644
index 00000000000..193cf713cd2
--- /dev/null
+++ b/apps/application/workflow/content_type.py
@@ -0,0 +1,21 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: content_type.py
+@date:2026/6/30 15:57
+@desc:
+"""
+
+from enum import Enum
+
+
+class ContentType(Enum):
+ TEXT = "TEXT"
+ REASONING = "REASONING"
+ FAILURE = "FAILURE"
+ TOOL = "TOOL"
+ CONTINUE = "CONTINUE"
+ BREAK = "BREAK"
+ FORM = "FORM"
+ PROGRESS = "PROGRESS"
diff --git a/apps/application/workflow/i_node.py b/apps/application/workflow/i_node.py
new file mode 100644
index 00000000000..0b3a17c0c94
--- /dev/null
+++ b/apps/application/workflow/i_node.py
@@ -0,0 +1,259 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: i_node.py
+@date:2026/6/29 16:41
+@desc:
+"""
+
+import time
+import traceback
+from enum import Enum
+from typing import Optional, Type, Callable
+
+from rest_framework import serializers
+
+from application.workflow.common import Node
+from application.workflow.message.struct.content import Content, NodeInfo, Position
+from application.workflow.message.struct.progress_content import ProgressContent
+from application.workflow.status import Status
+from common.utils.logger import maxkb_logger
+
+
+class CancelledException(Exception):
+ """工作流取消异常"""
+
+ pass
+
+
+class Signal(str, Enum):
+ BREAK = "BREAK"
+ CONTINUE = "CONTINUE"
+ FORM = "FORM"
+ CANCELLED = "CANCELLED"
+
+
+class INode:
+ # 当前节点支持的工作流类型
+ supported_workflow_type_list = []
+ # 节点类型
+ type = None
+ # 序列化校验器
+ serializer_class: Optional[Type[serializers.Serializer]] = None
+
+ @classmethod
+ def is_valid(cls, data):
+ if cls.serializer_class:
+ cls.serializer_class(data=data).is_valid(raise_exception=True)
+
+ def __init__(self, node, workflow_manage, get_node_parameters: Callable[[Node], dict]):
+ self.node = node
+ self.status = Status.BEFORE_RUNNING
+ self.workflow_manage = workflow_manage
+ # 节点参数
+ self.parameters = get_node_parameters(node)
+ # 节点运行时产生的数据
+ self.data = {}
+ self._completed = False
+ # ---- 锚点构造,全项目唯一的拼接点 ----
+
+ def anchor(self, *parts):
+ """
+ 通用锚点: anchor('right') → '{id}_right',
+ anchor(branch_id, 'right') → '{id}_{branch_id}_right'
+ """
+ return "_".join([self.node.id, *map(str, parts)])
+
+ def success_anchor(self):
+ """
+ 成功锚点
+ @return: 成功锚点
+ """
+ return self.anchor("right")
+
+ def fail_anchor(self):
+ """
+ 失败锚点
+ @return: 失败锚点
+ """
+ return self.branch_anchor("exception")
+
+ def branch_anchor(self, branch_id):
+ """
+ 自定义锚点
+ @param branch_id: 自定义分支id
+ @return: 自定义锚点
+ """
+ return self.anchor(branch_id, "right")
+
+ def execute(self):
+ pass
+
+ def run(self):
+ """
+ 运行节点
+ @return: 不响应数据
+ """
+ self.data["start_time"] = time.time()
+ self.status = Status.RUNNING
+ try:
+ self._run()
+ except CancelledException:
+ self.complete(Status.CANCELLED)
+ except Exception as e:
+ traceback.print_exc()
+ self.complete(Status.FAIL, error=e)
+
+ def _run(self):
+ """
+ 执行节点
+ @return:
+ """
+ self.write(
+ ProgressContent(
+ self.node.id,
+ Status.BEFORE_RUNNING,
+ NodeInfo(self.get_node_id(), self.get_node_name(), Status.BEFORE_RUNNING),
+ Position(self.get_node_id()),
+ )
+ )
+ self.execute()
+ self.complete(Status.SUCCESS)
+
+ def complete(self, status, anchors=None, error=None, signal: Optional[Signal] = None):
+ """
+ 节点结束调用函数
+
+ @param status: 状态
+ @param anchors 锚点信息
+ @param error: 错误信息
+ @param signal: 信号
+ @return:
+ """
+ if self._completed:
+ return
+ self._completed = True
+ self.status = status
+ if error:
+ self.data["error"] = str(error)
+ self.data["run_time"] = time.time() - self.data["start_time"]
+ if signal:
+ self.workflow_manage.signal = signal
+ anchors = []
+ if anchors is None:
+ anchors = [
+ self.success_anchor() if [Status.SUCCESS, Status.CANCELLED].__contains__(status) else self.fail_anchor()
+ ]
+ self._dispatch(anchors)
+ self.workflow_manage.assertion_end(error)
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ """
+ 获取节点运行详情
+ @param index: 节点索引
+ @param position: 位置信息,用于表单节点等断点续跑场景
+ @param old_details: 旧的详情数据,用于表单节点等断点续跑场景
+ @return: 节点详情字典
+ """
+ return {
+ "node_id": self.node.id,
+ "name": self.get_node_name(),
+ "index": index,
+ "run_time": self.data.get("run_time"),
+ "type": self.type,
+ "status": self.status.value if self.status else None,
+ "error": self.data.get("error"),
+ }
+
+ def _dispatch(self, anchors):
+ """
+ 根据锚点执行下一个节点
+ @param anchors: 锚点列表
+ @return:不返回
+ """
+ edge_node_list = self.workflow_manage.workflow.get_next_edge_nodes(self.node.id) or []
+ known = {en.edge.sourceAnchorId for en in edge_node_list}
+ unknown = set(anchors) - known
+ if unknown and known:
+ maxkb_logger.warning(f"node {self.node.id}: anchors {unknown} matched no edges, known={known}")
+ self.workflow_manage.next_nodes([en.node for en in edge_node_list if en.edge.sourceAnchorId in anchors])
+
+ def get_node_id(self):
+ """
+ 获取节点id
+ @return: 节点id
+ """
+ return self.node.id
+
+ def get_node_name(self):
+ """
+ 获取节点名称
+ @return: 节点名称
+ """
+ return self.node.properties.get("stepName")
+
+ def write_context(self, key, value, append=False):
+ """
+ 将数据写入节点上下文
+ @param key: 数据key
+ @param value: 数据value
+ @param append: 是否追加
+ @return: None
+ """
+ self.workflow_manage.write_context(self.node.id, key, value, append)
+
+ def get_context(self, key):
+ """
+ 获取上下文数据 根据key
+ @param key: key
+ @return: 数据
+ """
+ return self.workflow_manage.get_context(self.node.id, key)
+
+ def get_workflow_type(self):
+ """
+ 获取工作流类型
+ @return: 工作流类型
+ """
+ return self.workflow_manage.workflow_type
+
+ def get_workflow_parameters(self):
+ """
+ 获取工作流body
+ @return: 工作流body数据
+ """
+ return self.workflow_manage.get_parameters()
+
+ def get_parameters(self):
+ """
+ 获取节点参数数据
+ @return: 节点参数数据
+ """
+ return self.parameters
+
+ def get_next_nodes(self, wf):
+ """
+ 获取下n个基点
+ @param wf: 工作流对象
+ @return: 下n个节点
+ """
+ return wf.get_next_nodes(self.get_node_id())
+
+ def write(self, message: Content):
+ self.workflow_manage.write(message)
+
+ def cancel(self):
+ """
+ 取消运行
+ @return:
+ """
+ self.status = Status.CANCELLED
+
+ def _check_cancelled(self):
+ """
+ 检查是否已取消,如果已取消则抛出 CancelledException
+ @return:
+ """
+ if self.status == Status.CANCELLED or self.workflow_manage.signal == Signal.CANCELLED:
+ raise CancelledException()
diff --git a/apps/application/workflow/loop_workflow_manage.py b/apps/application/workflow/loop_workflow_manage.py
new file mode 100644
index 00000000000..9a3ef30351d
--- /dev/null
+++ b/apps/application/workflow/loop_workflow_manage.py
@@ -0,0 +1,81 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: loop_workflow_manage.py
+@date:2026/7/2 10:00
+@desc:
+"""
+
+from typing import Dict, Callable
+
+from application.workflow.common import Workflow, WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.workflow_manage import WorkflowManage, CallBack
+from common.utils.prompt_template import render_prompt
+
+
+class LoopWorkFlowManage(WorkflowManage):
+ def __init__(
+ self,
+ workflow: Workflow,
+ parameters: Dict,
+ workflow_type: WorkflowType,
+ call_back: CallBack,
+ get_start_node: Callable[[Workflow, WorkflowManage], INode],
+ parent_workflow_manage: WorkflowManage,
+ ):
+ self.parent_workflow_manage = parent_workflow_manage
+ super().__init__(workflow, parameters, workflow_type, call_back, get_start_node)
+
+ def get_parameters(self):
+ return self.parameters
+
+ def get_parent_context(self, node_id, key):
+ return self.parent_workflow_manage.get_context(node_id, key)
+
+ def generate_prompt(self, prompt):
+ input_template = self.workflow.reset_prompt(prompt)
+ input_template = self.parent_workflow_manage.workflow.reset_prompt(input_template)
+ context = {**self.context, **self.parent_workflow_manage.context}
+ return render_prompt(input_template, context)
+
+ def get_reference_field(self, node_id, fields):
+ """
+ 获取引用字段,先从当前工作流获取,获取不到再从父工作流获取
+ @param node_id: 节点id
+ @param fields: 字段
+ @return: 引用数据
+ """
+ # 先从当前工作流获取
+ result = super().get_reference_field(node_id, fields)
+ if result is not None:
+ return result
+
+ # 从父工作流获取
+ return self.parent_workflow_manage.get_reference_field(node_id, fields)
+
+ @classmethod
+ def from_context(
+ cls, get_context, workflow, parameters, workflow_type, call_back, get_start_node, parent_workflow_manage=None
+ ):
+ try:
+ context = get_context()
+
+ instance = cls(
+ workflow=workflow,
+ parameters=parameters,
+ workflow_type=workflow_type,
+ call_back=call_back,
+ get_start_node=get_start_node,
+ parent_workflow_manage=parent_workflow_manage,
+ )
+ if context:
+ instance.context = context
+
+ return instance
+ except Exception:
+ import traceback
+
+ traceback.print_exc()
+ return None
diff --git a/apps/application/workflow/message/aggregator/__init__.py b/apps/application/workflow/message/aggregator/__init__.py
new file mode 100644
index 00000000000..82b549143e2
--- /dev/null
+++ b/apps/application/workflow/message/aggregator/__init__.py
@@ -0,0 +1,12 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: __init__.py
+ @date:2026/7/22 16:24
+ @desc: 内容聚合器模块
+"""
+from application.workflow.message.aggregator.content_aggregator import ContentAggregator
+from application.workflow.message.aggregator.aggregator_factory import AggregatorFactory
+from application.workflow.message.aggregator.aggregation_manager import AggregationManager
+
+__all__ = ['ContentAggregator', 'AggregatorFactory', 'AggregationManager']
diff --git a/apps/application/workflow/message/aggregator/aggregation_manager.py b/apps/application/workflow/message/aggregator/aggregation_manager.py
new file mode 100644
index 00000000000..5f3c1c7441e
--- /dev/null
+++ b/apps/application/workflow/message/aggregator/aggregation_manager.py
@@ -0,0 +1,62 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: aggregation_manager.py
+@date:2026/7/22 16:24
+@desc: 聚合管理器
+"""
+
+from typing import Dict, List
+
+from application.workflow.message.struct.content import Content
+from application.workflow.message.aggregator.aggregator_factory import AggregatorFactory
+
+
+class AggregationManager:
+ """
+ 聚合管理器
+ 管理内容块的聚合,将相同id和类型的内容合并
+ """
+
+ def __init__(self):
+ self._key_to_index: Dict[str, int] = {}
+ self._contents: List[Content] = []
+
+ @property
+ def contents(self) -> List[Content]:
+ """获取聚合后的内容列表"""
+ return self._contents
+
+ def aggregate(self, chunk: Content) -> None:
+ """
+ 聚合内容块
+
+ @param chunk: 内容块
+ """
+ if not AggregatorFactory.is_aggregatable(chunk):
+ return
+ key = f"{chunk.id}_{chunk.type.value if hasattr(chunk.type, 'value') else chunk.type}"
+
+ idx = self._key_to_index.get(key)
+ if idx is None:
+ # 新key
+ self._key_to_index[key] = len(self._contents)
+ self._contents.append(chunk)
+ else:
+ # 已存在,聚合
+ prev = self._contents[idx]
+ aggregator = AggregatorFactory.get_aggregator(type(prev))
+ self._contents[idx] = aggregator.aggregate(prev, chunk)
+
+ def clear(self) -> None:
+ """清空聚合器"""
+ self._contents.clear()
+ self._key_to_index.clear()
+
+ def get_contents(self) -> List[Dict]:
+ """
+ 获取所有聚合后的内容(字典格式)
+
+ @return: 内容字典列表
+ """
+ return [content.to_dict() for content in self._contents]
diff --git a/apps/application/workflow/message/aggregator/aggregator_factory.py b/apps/application/workflow/message/aggregator/aggregator_factory.py
new file mode 100644
index 00000000000..7f79d6ee1fc
--- /dev/null
+++ b/apps/application/workflow/message/aggregator/aggregator_factory.py
@@ -0,0 +1,70 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: aggregator_factory.py
+@date:2026/7/22 16:24
+@desc: 聚合器工厂
+"""
+
+from typing import Dict, Type, Optional
+
+from application.workflow.message.aggregator.content_aggregator import ContentAggregator
+from application.workflow.message.aggregator.impl import FormAggregator
+from application.workflow.message.aggregator.impl.reasoning_aggregator import ReasoningAggregator
+from application.workflow.message.aggregator.impl.text_aggregator import TextAggregator
+from application.workflow.message.aggregator.impl.tool_aggregator import ToolAggregator
+from application.workflow.message.struct.content import Content
+from application.workflow.message.struct.form_content import FormContent
+from application.workflow.message.struct.reasoning_content import ReasoningContent
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.message.struct.tool_content import ToolContent
+
+
+class AggregatorFactory:
+ """
+ 聚合器工厂
+ 根据内容类型获取对应的聚合器
+ """
+
+ _aggregators: Dict[Type[Content], ContentAggregator] = {
+ TextContent: TextAggregator(),
+ ReasoningContent: ReasoningAggregator(),
+ ToolContent: ToolAggregator(),
+ FormContent: FormAggregator(),
+ }
+
+ @classmethod
+ def get_aggregator(cls, content_class: Type[Content]) -> ContentAggregator:
+ """
+ 获取聚合器
+
+ @param content_class: 内容类型
+ @return: 聚合器实例
+ @raises ValueError: 如果找不到对应的聚合器
+ """
+ aggregator = cls._aggregators.get(content_class)
+ if aggregator is None:
+ raise ValueError(f"No aggregator found for class: {content_class.__name__}")
+ return aggregator
+
+ @classmethod
+ def get_aggregator_optional(cls, content_class: Type[Content]) -> Optional[ContentAggregator]:
+ """
+ 获取聚合器(可选)
+
+ @param content_class: 内容类型
+ @return: 聚合器实例或None
+ """
+ return cls._aggregators.get(content_class)
+
+ @classmethod
+ def is_aggregatable(cls, chunk: Content) -> bool:
+ """
+ 判断给定的内容对象是否可以被聚合
+
+ @param chunk: 内容对象实例
+ @return: True 表示有对应的聚合器,False 表示没有
+ """
+ if chunk is None:
+ return False
+ return type(chunk) in cls._aggregators
diff --git a/apps/application/workflow/message/aggregator/content_aggregator.py b/apps/application/workflow/message/aggregator/content_aggregator.py
new file mode 100644
index 00000000000..fb3def1f997
--- /dev/null
+++ b/apps/application/workflow/message/aggregator/content_aggregator.py
@@ -0,0 +1,45 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: content_aggregator.py
+ @date:2026/7/22 16:24
+ @desc: 内容聚合器接口
+"""
+from abc import ABC, abstractmethod
+from typing import TypeVar, Generic
+
+from application.workflow.message.struct.content import Content
+
+T = TypeVar('T', bound=Content)
+
+
+class ContentAggregator(ABC, Generic[T]):
+ """
+ 内容聚合器接口
+ 用于合并相同类型的流式内容块
+ """
+
+ @abstractmethod
+ def aggregate(self, prev: T, chunk: T) -> T:
+ """
+ 聚合两个内容块
+
+ @param prev: 之前的内容
+ @param chunk: 新的内容块
+ @return: 合并后的内容
+ """
+ pass
+
+ def merge_base_fields(self, prev: T, chunk: T, result: T) -> None:
+ """
+ 合并基础字段
+
+ @param prev: 之前的内容
+ @param chunk: 新的内容块
+ @param result: 结果对象
+ """
+ result.id = chunk.id if chunk.id else prev.id
+ result.status = chunk.status if chunk.status else prev.status
+ result.node_info = chunk.node_info if chunk.node_info else prev.node_info
+ result.position = chunk.position if chunk.position else prev.position
+ result.extra = chunk.extra if chunk.extra else prev.extra
diff --git a/apps/application/workflow/message/aggregator/impl/__init__.py b/apps/application/workflow/message/aggregator/impl/__init__.py
new file mode 100644
index 00000000000..34a83e07003
--- /dev/null
+++ b/apps/application/workflow/message/aggregator/impl/__init__.py
@@ -0,0 +1,14 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: __init__.py
+@date:2026/7/22 16:24
+@desc: 聚合器实现模块
+"""
+
+from application.workflow.message.aggregator.impl.text_aggregator import TextAggregator
+from application.workflow.message.aggregator.impl.reasoning_aggregator import ReasoningAggregator
+from application.workflow.message.aggregator.impl.tool_aggregator import ToolAggregator
+from application.workflow.message.aggregator.impl.form_aggregator import FormAggregator
+
+__all__ = ["TextAggregator", "ReasoningAggregator", "ToolAggregator", "FormAggregator"]
diff --git a/apps/application/workflow/message/aggregator/impl/form_aggregator.py b/apps/application/workflow/message/aggregator/impl/form_aggregator.py
new file mode 100644
index 00000000000..663fef26bd2
--- /dev/null
+++ b/apps/application/workflow/message/aggregator/impl/form_aggregator.py
@@ -0,0 +1,49 @@
+"""
+@project: MaxKB
+@file: form_aggregator.py
+@date:2026/9/16
+@desc:
+"""
+
+from application.workflow.message.aggregator.content_aggregator import ContentAggregator
+from application.workflow.message.struct.form_content import FormContent
+
+
+class FormAggregator(ContentAggregator[FormContent]):
+ """
+ 推理内容聚合器
+ 用于合并流式推理内容块
+ """
+
+ def aggregate(self, prev: FormContent, chunk: FormContent) -> FormContent:
+ """
+ 聚合推理内容
+
+ @param prev: 之前的内容
+ @param chunk: 新的内容块
+ @return: 合并后的内容
+ """
+ if prev is None:
+ return chunk
+
+ # 合并 status: 优先使用 chunk 的,否则使用 prev 的
+ merged_status = chunk.status if chunk.status else prev.status
+ form_field_list = chunk.form_field_list if chunk.form_field_list else prev.form_field_list
+ form_content_format = chunk.form_content_format if chunk.form_content_format else prev.form_content_format
+ is_submit = chunk.is_submit if chunk.is_submit else prev.is_submit
+ form_data = chunk.form_data if chunk.form_data else prev.form_data
+ # 合并基础字段
+ merged_id = chunk.id if chunk.id else prev.id
+ merged_node_info = chunk.node_info if chunk.node_info else prev.node_info
+ merged_position = chunk.position if chunk.position else prev.position
+ result = FormContent(
+ merged_id,
+ form_field_list,
+ form_content_format,
+ is_submit,
+ merged_status,
+ merged_node_info,
+ merged_position,
+ form_data,
+ )
+ return result
diff --git a/apps/application/workflow/message/aggregator/impl/reasoning_aggregator.py b/apps/application/workflow/message/aggregator/impl/reasoning_aggregator.py
new file mode 100644
index 00000000000..c278f6a6887
--- /dev/null
+++ b/apps/application/workflow/message/aggregator/impl/reasoning_aggregator.py
@@ -0,0 +1,44 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: reasoning_aggregator.py
+ @date:2026/7/22 16:24
+ @desc: ReasoningContent 聚合器
+"""
+from application.workflow.message.aggregator.content_aggregator import ContentAggregator
+from application.workflow.message.struct.reasoning_content import ReasoningContent
+
+
+class ReasoningAggregator(ContentAggregator[ReasoningContent]):
+ """
+ 推理内容聚合器
+ 用于合并流式推理内容块
+ """
+
+ def aggregate(self, prev: ReasoningContent, chunk: ReasoningContent) -> ReasoningContent:
+ """
+ 聚合推理内容
+
+ @param prev: 之前的内容
+ @param chunk: 新的内容块
+ @return: 合并后的内容
+ """
+ if prev is None:
+ return chunk
+
+ # 合并 content
+ prev_content = prev.content if prev.content else ""
+ chunk_content = chunk.content if chunk.content else ""
+ merged_content = prev_content + chunk_content
+
+ # 合并 status: 优先使用 chunk 的,否则使用 prev 的
+ merged_status = chunk.status if chunk.status else prev.status
+
+ # 合并基础字段
+ merged_id = chunk.id if chunk.id else prev.id
+ merged_node_info = chunk.node_info if chunk.node_info else prev.node_info
+ merged_position = chunk.position if chunk.position else prev.position
+
+ result = ReasoningContent(merged_id, merged_content, merged_status, merged_node_info, merged_position)
+
+ return result
diff --git a/apps/application/workflow/message/aggregator/impl/text_aggregator.py b/apps/application/workflow/message/aggregator/impl/text_aggregator.py
new file mode 100644
index 00000000000..af519bad38e
--- /dev/null
+++ b/apps/application/workflow/message/aggregator/impl/text_aggregator.py
@@ -0,0 +1,44 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: text_aggregator.py
+ @date:2026/7/22 16:24
+ @desc: TextContent 聚合器
+"""
+from application.workflow.message.aggregator.content_aggregator import ContentAggregator
+from application.workflow.message.struct.text_content import TextContent
+
+
+class TextAggregator(ContentAggregator[TextContent]):
+ """
+ 文本内容聚合器
+ 用于合并流式文本内容块
+ """
+
+ def aggregate(self, prev: TextContent, chunk: TextContent) -> TextContent:
+ """
+ 聚合文本内容
+
+ @param prev: 之前的内容
+ @param chunk: 新的内容块
+ @return: 合并后的内容
+ """
+ if prev is None:
+ return chunk
+
+ # 合并 content
+ prev_content = prev.content if prev.content else ""
+ chunk_content = chunk.content if chunk.content else ""
+ merged_content = prev_content + chunk_content
+
+ # 合并 status: 优先使用 chunk 的,否则使用 prev 的
+ merged_status = chunk.status if chunk.status else prev.status
+
+ # 合并基础字段
+ merged_id = chunk.id if chunk.id else prev.id
+ merged_node_info = chunk.node_info if chunk.node_info else prev.node_info
+ merged_position = chunk.position if chunk.position else prev.position
+
+ result = TextContent(merged_id, merged_content, merged_status, merged_node_info, merged_position)
+
+ return result
diff --git a/apps/application/workflow/message/aggregator/impl/tool_aggregator.py b/apps/application/workflow/message/aggregator/impl/tool_aggregator.py
new file mode 100644
index 00000000000..f2b5a5b1e85
--- /dev/null
+++ b/apps/application/workflow/message/aggregator/impl/tool_aggregator.py
@@ -0,0 +1,56 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: tool_aggregator.py
+@date:2026/7/22 16:24
+@desc: ToolContent 聚合器
+"""
+
+from application.workflow.message.aggregator.content_aggregator import ContentAggregator
+from application.workflow.message.struct.tool_content import ToolContent
+
+
+class ToolAggregator(ContentAggregator[ToolContent]):
+ """
+ 工具内容聚合器
+ 用于合并流式工具调用内容块
+ """
+
+ def aggregate(self, prev: ToolContent, chunk: ToolContent) -> ToolContent:
+ """
+ 聚合工具内容
+
+ @param prev: 之前的内容
+ @param chunk: 新的内容块
+ @return: 合并后的内容
+ """
+ if prev is None:
+ return chunk
+
+ # 合并 name (tool_name):取新回退旧
+ prev_name = prev.name if prev.name else ""
+ chunk_name = chunk.name if chunk.name else ""
+ merged_name = chunk_name if chunk_name else prev_name
+
+ # 合并 arguments:拼接
+ prev_arguments = prev.arguments if prev.arguments else ""
+ chunk_arguments = chunk.arguments if chunk.arguments else ""
+ merged_arguments = prev_arguments + chunk_arguments
+
+ # 合并 content(即 result 结果):拼接
+ prev_content = prev.content if prev.content else ""
+ chunk_content = chunk.content if chunk.content else ""
+ merged_content = prev_content + chunk_content
+
+ # 合并基础字段
+ merged_id = chunk.id if chunk.id else prev.id
+ merged_status = chunk.status if chunk.status else prev.status
+ merged_node_info = chunk.node_info if chunk.node_info else prev.node_info
+ merged_position = chunk.position if chunk.position else prev.position
+
+ # ToolContent(_id, tool_name, arguments, result, status, node_info, position)
+ result = ToolContent(
+ merged_id, merged_name, merged_arguments, merged_content, merged_status, merged_node_info, merged_position
+ )
+
+ return result
diff --git a/apps/application/workflow/message/struct/content.py b/apps/application/workflow/message/struct/content.py
new file mode 100644
index 00000000000..ed57f6bd8a3
--- /dev/null
+++ b/apps/application/workflow/message/struct/content.py
@@ -0,0 +1,63 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: content.py
+ @date:2026/6/30 15:38
+ @desc:
+"""
+from enum import Enum
+from typing import Optional
+
+from application.workflow.content_type import ContentType
+from application.workflow.status import Status
+
+
+class NodeInfo:
+ def __init__(self, _id: str, name: str, status: Status):
+ self.id = _id
+ self.name = name
+ self.status = status
+
+ def to_dict(self):
+ return {
+ 'id': self.id,
+ 'name': self.name,
+ 'status': self.status.value if hasattr(self.status, 'value') else str(self.status),
+ }
+
+
+class Position:
+ def __init__(self, _id: str, index: Optional[int] = None, children: Optional['Position'] = None):
+ self.id = _id
+ self.index = index
+ self.children = children
+
+ def to_dict(self):
+ return {
+ 'id': self.id,
+ 'index': self.index,
+ 'children': self.children.to_dict() if self.children else None,
+ }
+
+
+class Content:
+ def __init__(self, _id, status: Status, _type: ContentType, node_info: NodeInfo, position: Position, **kwargs):
+ self.id = _id
+ self.status = status
+ self.type = _type
+ self.node_info = node_info
+ self.position = position
+ self.extra = kwargs
+
+ def to_dict(self):
+ result = {
+ 'id': self.id,
+ 'type': self.type.value if hasattr(self.type, 'value') else str(self.type),
+ 'status': self.status.value if hasattr(self.status, 'value') else str(self.status),
+ 'node_info': self.node_info.to_dict() if self.node_info else None,
+ 'position': self.position.to_dict() if self.position else None,
+ }
+ if self.extra:
+ result.update(self.extra)
+ return result
diff --git a/apps/application/workflow/message/struct/failure_content.py b/apps/application/workflow/message/struct/failure_content.py
new file mode 100644
index 00000000000..b867c21c967
--- /dev/null
+++ b/apps/application/workflow/message/struct/failure_content.py
@@ -0,0 +1,25 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: failure_content.py
+ @date:2026/7/27 11:14
+ @desc:
+"""
+from typing import Optional
+
+from application.workflow.content_type import ContentType
+from application.workflow.message.struct.content import Content, NodeInfo, Position
+from application.workflow.status import Status
+
+
+class FailureContent(Content):
+ def __init__(self, _id, content: str, status: Status, node_info: Optional[NodeInfo], position: Optional[Position],
+ **kwargs):
+ self.content = content
+ super().__init__(_id, status, ContentType.FAILURE, node_info, position, **kwargs)
+
+ def to_dict(self):
+ result = super().to_dict()
+ result['content'] = self.content
+ return result
diff --git a/apps/application/workflow/message/struct/form_content.py b/apps/application/workflow/message/struct/form_content.py
new file mode 100644
index 00000000000..080837d409c
--- /dev/null
+++ b/apps/application/workflow/message/struct/form_content.py
@@ -0,0 +1,32 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: form_content.py
+ @date:2026/7/6 15:30
+ @desc:
+"""
+from typing import List, Dict, Optional
+
+from application.workflow.content_type import ContentType
+from application.workflow.message.struct.content import Content, NodeInfo, Position
+from application.workflow.status import Status
+
+
+class FormContent(Content):
+ def __init__(self, _id, form_field_list: List[Dict], form_content_format: str,
+ is_submit: bool, status: Status, node_info: NodeInfo, position: Position,
+ form_data: Optional[Dict] = None, **kwargs):
+ self.form_field_list = form_field_list
+ self.form_content_format = form_content_format
+ self.is_submit = is_submit
+ self.form_data = form_data or {}
+ super().__init__(_id, status, ContentType.FORM, node_info, position, **kwargs)
+
+ def to_dict(self):
+ result = super().to_dict()
+ result['form_field_list'] = self.form_field_list
+ result['form_content_format'] = self.form_content_format
+ result['is_submit'] = self.is_submit
+ result['form_data'] = self.form_data
+ return result
diff --git a/apps/application/workflow/message/struct/progress_content.py b/apps/application/workflow/message/struct/progress_content.py
new file mode 100644
index 00000000000..cb19d50465c
--- /dev/null
+++ b/apps/application/workflow/message/struct/progress_content.py
@@ -0,0 +1,21 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎
+@file: reasoning_content.py
+@date:2026/6/30 16:07
+@desc:
+"""
+
+from application.workflow.content_type import ContentType
+from application.workflow.message.struct.content import Content, NodeInfo, Position
+from application.workflow.status import Status
+
+
+class ProgressContent(Content):
+ def __init__(self, _id, status: Status, node_info: NodeInfo, position: Position, **kwargs):
+ super().__init__(_id, status, ContentType.PROGRESS, node_info, position, **kwargs)
+
+ def to_dict(self):
+ result = super().to_dict()
+ return result
diff --git a/apps/application/workflow/message/struct/reasoning_content.py b/apps/application/workflow/message/struct/reasoning_content.py
new file mode 100644
index 00000000000..e7903d2e188
--- /dev/null
+++ b/apps/application/workflow/message/struct/reasoning_content.py
@@ -0,0 +1,22 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎
+ @file: reasoning_content.py
+ @date:2026/6/30 16:07
+ @desc:
+"""
+from application.workflow.content_type import ContentType
+from application.workflow.message.struct.content import Content, NodeInfo, Position
+from application.workflow.status import Status
+
+
+class ReasoningContent(Content):
+ def __init__(self, _id, content: str, status: Status, node_info: NodeInfo, position: Position, **kwargs):
+ self.content = content
+ super().__init__(_id, status, ContentType.REASONING, node_info, position, **kwargs)
+
+ def to_dict(self):
+ result = super().to_dict()
+ result['content'] = self.content
+ return result
diff --git a/apps/application/workflow/message/struct/text_content.py b/apps/application/workflow/message/struct/text_content.py
new file mode 100644
index 00000000000..a2691778ac4
--- /dev/null
+++ b/apps/application/workflow/message/struct/text_content.py
@@ -0,0 +1,22 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎
+ @file: text_content.py
+ @date:2026/6/30 16:03
+ @desc:
+"""
+from application.workflow.content_type import ContentType
+from application.workflow.message.struct.content import Content, NodeInfo, Position
+from application.workflow.status import Status
+
+
+class TextContent(Content):
+ def __init__(self, _id, content: str, status: Status, node_info: NodeInfo, position: Position, **kwargs):
+ self.content = content
+ super().__init__(_id, status, ContentType.TEXT, node_info, position, **kwargs)
+
+ def to_dict(self):
+ result = super().to_dict()
+ result['content'] = self.content
+ return result
diff --git a/apps/application/workflow/message/struct/tool_content.py b/apps/application/workflow/message/struct/tool_content.py
new file mode 100644
index 00000000000..e632df00406
--- /dev/null
+++ b/apps/application/workflow/message/struct/tool_content.py
@@ -0,0 +1,37 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎
+@file: tool_content.py
+@date:2026/6/30 16:17
+@desc:
+"""
+
+from application.workflow.content_type import ContentType
+from application.workflow.message.struct.content import Content, NodeInfo, Position
+from application.workflow.status import Status
+
+
+class ToolContent(Content):
+ def __init__(
+ self,
+ _id,
+ tool_name: str,
+ arguments: str,
+ result: str,
+ status: Status,
+ node_info: NodeInfo,
+ position: Position,
+ **kwargs,
+ ):
+ self.name = tool_name
+ self.arguments = arguments
+ self.content = result
+ super().__init__(_id, status, ContentType.TOOL, node_info, position, **kwargs)
+
+ def to_dict(self):
+ result = super().to_dict()
+ result["content"] = self.content
+ result["arguments"] = self.arguments
+ result["name"] = self.name
+ return result
diff --git a/apps/application/workflow/message_queue.py b/apps/application/workflow/message_queue.py
new file mode 100644
index 00000000000..d0ae940e314
--- /dev/null
+++ b/apps/application/workflow/message_queue.py
@@ -0,0 +1,724 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: message_queue.py
+@date:2026/7/27 10:10
+@desc: 消息队列管理,用于流式响应的消息存储和消费
+支持多消费者、断线重连、消息持久化
+"""
+
+import bisect
+import fnmatch
+import json
+import os
+import socket
+import threading
+import time
+from abc import ABC, abstractmethod
+from collections import deque
+from concurrent.futures import ThreadPoolExecutor
+from enum import Enum
+from typing import Any, Callable, List, Optional, Tuple
+
+from common.utils.logger import maxkb_logger
+
+DEFAULT_TTL = 3600
+DEFAULT_BATCH_SIZE = 100
+DEFAULT_BLOCK_TIMEOUT_MS = 5000
+MAX_CONSECUTIVE_ERRORS = 5
+# XREAD block 窗口相对 socket_timeout 的安全比例,保证服务端先返回、客户端后超时
+BLOCK_SAFETY_RATIO = 0.6
+
+
+def _benign_timeouts() -> tuple:
+ """
+ 阻塞读读空窗口时的超时属于正常现象,不能计入故障预算。
+ redis.exceptions.TimeoutError 与内建 TimeoutError/socket.timeout 都要覆盖。
+ """
+ candidates = [TimeoutError, socket.timeout]
+ try:
+ from redis.exceptions import TimeoutError as RedisTimeoutError
+
+ candidates.append(RedisTimeoutError)
+ except ImportError:
+ pass
+ return tuple({c for c in candidates if isinstance(c, type)})
+
+
+BENIGN_TIMEOUTS = _benign_timeouts()
+
+
+class MessageQueueError(Exception):
+ """
+ 队列后端不可用。
+
+ 刻意与"队列不存在"区分开:Redis 抖动不应该被上层误判成会话已失效。
+ """
+
+
+class MessageStatus(str, Enum):
+ RUNNING = "RUNNING"
+ SUCCESS = "SUCCESS"
+ FAIL = "FAIL"
+ CANCELLED = "CANCELLED"
+
+
+def parse_stream_id(value: Any) -> Tuple[int, int]:
+ """
+ 把 Redis Stream ID ("1699999999999-0") 解析成可比较的元组。
+ 非法值一律退化成 (0, 0),即"从头开始"。
+ """
+ if value is None:
+ return 0, 0
+ if isinstance(value, (bytes, bytearray)):
+ value = value.decode("utf-8")
+ value = str(value)
+ if not value or value == "0":
+ return 0, 0
+ if value == "$":
+ return (1 << 63) - 1, 0
+ parts = value.split("-", 1)
+ try:
+ ms = int(parts[0])
+ seq = int(parts[1]) if len(parts) > 1 and parts[1] else 0
+ return ms, seq
+ except (TypeError, ValueError):
+ return 0, 0
+
+
+class IMessageQueue(ABC):
+ """消息队列接口"""
+
+ @abstractmethod
+ def exists(self, queue_id: str) -> bool:
+ pass
+
+ @abstractmethod
+ def produce(self, queue_id: str, message: Any, ttl: int = None) -> None:
+ pass
+
+ @abstractmethod
+ def produce_done(self, queue_id: str, ttl: int = None) -> None:
+ pass
+
+ @abstractmethod
+ def is_done(self, queue_id: str) -> bool:
+ pass
+
+ @abstractmethod
+ def consume(
+ self,
+ queue_id: str,
+ start_id: str = "0",
+ on_message: Optional[Callable[[str, str], None]] = None,
+ on_done: Optional[Callable[[], None]] = None,
+ timeout: float = 300,
+ should_stop: Optional[Callable[[], bool]] = None,
+ ) -> None:
+ """
+ 消费消息,阻塞直到队列结束 / 超时 / 被取消。
+
+ @param start_id: 起始消息ID,"0" 表示从头;语义为"返回 ID 严格大于 start_id 的消息"
+ @param on_message: 回调 (message_id, message_data)
+ @param on_done: 结束回调,保证有且只调用一次
+ @param timeout: 最长消费时间(秒)
+ @param should_stop: 取消钩子,返回 True 则立即结束消费
+ """
+ pass
+
+ @abstractmethod
+ def get_messages(self, queue_id: str, start_id: str = "0", count: int = 100) -> list:
+ """拉取 ID 严格大于 start_id 的历史消息,用于断线重连补发。"""
+ pass
+
+ @abstractmethod
+ def delete(self, queue_id: str) -> None:
+ pass
+
+ @abstractmethod
+ def clear_by_pattern(self, pattern: str) -> int:
+ pass
+
+
+_instances: dict[str, IMessageQueue] = {}
+_instances_lock = threading.Lock()
+
+
+class InMemoryMessageQueue(IMessageQueue):
+ """内存消息队列,仅适用于单进程环境(多 worker 下生产者和消费者可能不在同一进程)"""
+
+ def __init__(self, default_ttl: int = DEFAULT_TTL):
+ # queue_id -> (sort_keys, items),两个列表下标一一对应,便于 bisect 定位游标
+ self._sort_keys: dict[str, List[Tuple[int, int]]] = {}
+ self._items: dict[str, List[Tuple[str, str]]] = {}
+ self._done_flags: dict[str, bool] = {}
+ self._last_id: dict[str, Tuple[int, int]] = {}
+ self._expire_at: dict[str, float] = {}
+ self._default_ttl = default_ttl
+ # 用 RLock,避免 clear_by_pattern -> delete 这类内部复用造成自死锁
+ self._cond = threading.Condition(threading.RLock())
+
+ # ---------- 内部工具 ----------
+
+ def _next_id(self, queue_id: str) -> str:
+ """生成与 Redis Stream 同构的 ID,保证两种实现的 start_id 可以互换。"""
+ now = int(time.time() * 1000)
+ last_ms, last_seq = self._last_id.get(queue_id, (0, 0))
+ new_id = (now, 0) if now > last_ms else (last_ms, last_seq + 1)
+ self._last_id[queue_id] = new_id
+ return f"{new_id[0]}-{new_id[1]}"
+
+ def _drop(self, queue_id: str) -> None:
+ """调用方必须已持有锁。"""
+ self._sort_keys.pop(queue_id, None)
+ self._items.pop(queue_id, None)
+ self._done_flags.pop(queue_id, None)
+ self._last_id.pop(queue_id, None)
+ self._expire_at.pop(queue_id, None)
+
+ def _purge_if_expired(self, queue_id: str) -> None:
+ """调用方必须已持有锁。"""
+ expire_at = self._expire_at.get(queue_id)
+ if expire_at is not None and expire_at <= time.time():
+ self._drop(queue_id)
+
+ def _read_after(self, cursor: Tuple[int, int], queue_id: str) -> Tuple[List[Tuple[str, str]], bool]:
+ """
+ 原子地返回 (游标之后的消息, 是否已结束)。
+
+ 两个值必须在同一次加锁内读取:生产者是先 produce 再 produce_done,
+ 所以只要读到 done=True,就说明所有消息在本次快照里已经全部可见,
+ 不存在"最后一条消息还没写进来就判定结束"的竞态。
+ """
+ with self._cond:
+ self._purge_if_expired(queue_id)
+ keys = self._sort_keys.get(queue_id)
+ done = self._done_flags.get(queue_id, False)
+ if not keys:
+ return [], done
+ start = bisect.bisect_right(keys, cursor)
+ return list(self._items[queue_id][start:]), done
+
+ def purge_expired(self) -> int:
+ """惰性清理兜底:建议由定时任务周期调用,防止用户关页面后队列常驻内存。"""
+ now = time.time()
+ with self._cond:
+ expired = [k for k, exp in self._expire_at.items() if exp <= now]
+ for k in expired:
+ self._drop(k)
+ return len(expired)
+
+ # ---------- 接口实现 ----------
+
+ def exists(self, queue_id: str) -> bool:
+ with self._cond:
+ self._purge_if_expired(queue_id)
+ return queue_id in self._items
+
+ def produce(self, queue_id: str, message: Any, ttl: int = None) -> None:
+ data = message if isinstance(message, str) else json.dumps(message, ensure_ascii=False)
+ with self._cond:
+ self._purge_if_expired(queue_id)
+ if queue_id not in self._items:
+ self._items[queue_id] = []
+ self._sort_keys[queue_id] = []
+ msg_id = self._next_id(queue_id)
+ self._sort_keys[queue_id].append(parse_stream_id(msg_id))
+ self._items[queue_id].append((msg_id, data))
+ # 滑动过期,长会话不会中途被清掉
+ self._expire_at[queue_id] = time.time() + (ttl or self._default_ttl)
+ self._cond.notify_all()
+
+ def produce_done(self, queue_id: str, ttl: int = None) -> None:
+ with self._cond:
+ self._done_flags[queue_id] = True
+ self._expire_at[queue_id] = time.time() + (ttl or self._default_ttl)
+ self._cond.notify_all()
+
+ def is_done(self, queue_id: str) -> bool:
+ with self._cond:
+ return self._done_flags.get(queue_id, False)
+
+ def consume(
+ self,
+ queue_id: str,
+ start_id: str = "0",
+ on_message: Optional[Callable[[str, str], None]] = None,
+ on_done: Optional[Callable[[], None]] = None,
+ timeout: float = 300,
+ should_stop: Optional[Callable[[], bool]] = None,
+ ) -> None:
+ deadline = time.monotonic() + timeout
+ cursor = parse_stream_id(start_id)
+ try:
+ while True:
+ if should_stop is not None and should_stop():
+ break
+
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ maxkb_logger.warning(f"MessageQueue consume timeout: {queue_id}")
+ break
+
+ batch, done = self._read_after(cursor, queue_id)
+ if batch:
+ for msg_id, msg_data in batch:
+ if on_message:
+ on_message(msg_id, msg_data)
+ cursor = parse_stream_id(msg_id)
+ continue
+ if done:
+ break
+
+ # 等待生产者唤醒,而不是固定 sleep,降低首字延迟
+ with self._cond:
+ self._cond.wait(min(0.05, remaining))
+ finally:
+ if on_done:
+ on_done()
+
+ def get_messages(self, queue_id: str, start_id: str = "0", count: int = 100) -> list:
+ batch, _ = self._read_after(parse_stream_id(start_id), queue_id)
+ return [{"id": mid, "data": data} for mid, data in batch[:count]]
+
+ def delete(self, queue_id: str) -> None:
+ with self._cond:
+ self._drop(queue_id)
+ self._cond.notify_all()
+
+ def clear_by_pattern(self, pattern: str) -> int:
+ with self._cond:
+ keys = [k for k in self._items if fnmatch.fnmatch(k, pattern)]
+ for k in keys:
+ self._drop(k)
+ self._cond.notify_all()
+ return len(keys)
+
+
+class RedisStreamMessageQueue(IMessageQueue):
+ """
+ Redis Stream 消息队列
+ 支持多消费者、断线重连、消息持久化
+ """
+
+ def __init__(self, namespace: str = "mq", redis_client=None, alias: str = "default"):
+ self._namespace = namespace
+ self._redis = redis_client
+ self._alias = alias
+ self._resolved = None
+ self._default_ttl = DEFAULT_TTL
+ self._batch_size = DEFAULT_BATCH_SIZE
+ self._block_timeout = DEFAULT_BLOCK_TIMEOUT_MS
+ self._block_limit = None
+
+ # ---------- 连接 ----------
+
+ def _resolve_redis(self):
+ """
+ django-redis 的 cache.client 是 DefaultClient 包装层,没有 xadd/xread,
+ 必须取出底层 redis-py 连接。
+ """
+ try:
+ from django_redis import get_redis_connection
+
+ return get_redis_connection(self._alias)
+ except ImportError:
+ pass
+ except Exception as e:
+ maxkb_logger.warning(f"get_redis_connection({self._alias}) failed: {e}")
+
+ from django.core.cache import cache
+
+ client = getattr(cache, "client", None)
+ if client is not None and hasattr(client, "get_client"):
+ return client.get_client(write=True)
+ if client is not None and hasattr(client, "xadd"):
+ return client
+ raise MessageQueueError("当前 CACHES 后端不是 django-redis,无法使用 RedisStreamMessageQueue")
+
+ def _get_redis(self):
+ if self._redis is not None:
+ return self._redis
+ if self._resolved is None:
+ self._resolved = self._resolve_redis()
+ return self._resolved
+
+ def ping(self) -> bool:
+ """真实探活,不吞异常,供 create_message_queue 判断是否降级。"""
+ return bool(self._get_redis().ping())
+
+ def _block_limit_ms(self, redis) -> int:
+ """
+ XREAD 的 block 是让服务端挂起的时长,而客户端等响应用的是连接的 socket_timeout。
+ 一旦 block >= socket_timeout,空窗口必然先触发 "Timeout reading from socket"
+ 并导致 redis-py 断连重建。这里按连接实际配置反推一个安全上限。
+ """
+ if self._block_limit is not None:
+ return self._block_limit
+
+ limit = self._block_timeout
+ try:
+ kwargs = getattr(getattr(redis, "connection_pool", None), "connection_kwargs", None) or {}
+ socket_timeout = kwargs.get("socket_timeout")
+ if socket_timeout:
+ safe = int(float(socket_timeout) * 1000 * BLOCK_SAFETY_RATIO)
+ limit = max(100, min(limit, safe))
+ if limit < self._block_timeout:
+ maxkb_logger.info(f"MessageQueue: socket_timeout={socket_timeout}s,XREAD block 收敛到 {limit}ms")
+ except Exception as e:
+ maxkb_logger.warning(f"MessageQueue: 无法读取 socket_timeout,沿用默认 block: {e}")
+
+ self._block_limit = limit
+ return limit
+
+ # ---------- 编解码 ----------
+
+ def _key(self, queue_id: str) -> str:
+ return f"{self._namespace}:{queue_id}"
+
+ def _done_key(self, queue_id: str) -> str:
+ return f"{self._namespace}:{queue_id}:done"
+
+ def _encode(self, value: Any) -> str:
+ if isinstance(value, str):
+ return value
+ return json.dumps(value, ensure_ascii=False)
+
+ @staticmethod
+ def _decode_bytes(data: Any) -> str:
+ return data.decode("utf-8") if isinstance(data, (bytes, bytearray)) else str(data)
+
+ def _decode_field(self, fields: dict, name: str = "data") -> str:
+ """同时兼容 decode_responses=True / False 两种 client 配置。"""
+ if not fields:
+ return ""
+ val = fields.get(name)
+ if val is None:
+ val = fields.get(name.encode("utf-8"))
+ if val is None:
+ return ""
+ return self._decode_bytes(val)
+
+ def _emit(self, messages, on_message) -> Optional[str]:
+ last_id = None
+ for msg_id_raw, fields in messages:
+ msg_id = self._decode_bytes(msg_id_raw)
+ if on_message:
+ on_message(msg_id, self._decode_field(fields))
+ last_id = msg_id
+ return last_id
+
+ # ---------- 接口实现 ----------
+
+ def exists(self, queue_id: str) -> bool:
+ try:
+ return self._get_redis().exists(self._key(queue_id)) > 0
+ except Exception as e:
+ raise MessageQueueError(f"exists({queue_id}) failed: {e}") from e
+
+ def produce(self, queue_id: str, message: Any, ttl: int = None) -> None:
+ key = self._key(queue_id)
+ try:
+ # pipeline 合并 xadd + expire,流式场景每个 token 少一次 RTT;
+ # 同时每次都续期,避免长会话中途整条 stream 过期
+ pipe = self._get_redis().pipeline(transaction=False)
+ pipe.xadd(key, {"data": self._encode(message)})
+ pipe.expire(key, ttl or self._default_ttl)
+ pipe.execute()
+ except Exception as e:
+ maxkb_logger.error(f"MessageQueue produce error [{queue_id}]: {e}")
+ raise MessageQueueError(f"produce({queue_id}) failed: {e}") from e
+
+ def produce_done(self, queue_id: str, ttl: int = None) -> None:
+ try:
+ self._get_redis().set(self._done_key(queue_id), "1", ex=ttl or self._default_ttl)
+ except Exception as e:
+ maxkb_logger.error(f"MessageQueue produce_done error [{queue_id}]: {e}")
+ raise MessageQueueError(f"produce_done({queue_id}) failed: {e}") from e
+
+ def is_done(self, queue_id: str) -> bool:
+ try:
+ return self._get_redis().exists(self._done_key(queue_id)) > 0
+ except Exception as e:
+ raise MessageQueueError(f"is_done({queue_id}) failed: {e}") from e
+
+ def consume(
+ self,
+ queue_id: str,
+ start_id: str = "0",
+ on_message: Optional[Callable[[str, str], None]] = None,
+ on_done: Optional[Callable[[], None]] = None,
+ timeout: float = 300,
+ should_stop: Optional[Callable[[], bool]] = None,
+ ) -> None:
+ key = self._key(queue_id)
+ deadline = time.monotonic() + timeout
+ current_id = start_id or "0"
+ errors = 0
+
+ try:
+ redis = self._get_redis()
+ while True:
+ if should_stop is not None and should_stop():
+ break
+
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ maxkb_logger.warning(f"MessageQueue consume timeout: {queue_id}")
+ break
+
+ try:
+ # block 必须 >= 1:block=0 在 Redis 里是"无限阻塞",会挂死消费线程
+ block_ms = max(1, min(int(remaining * 1000), self._block_limit_ms(redis)))
+ result = redis.xread({key: current_id}, count=self._batch_size, block=block_ms)
+ errors = 0
+ except BENIGN_TIMEOUTS:
+ # 阻塞窗口内没有新消息而已,不是故障:不计入熔断预算,也不退避。
+ # 游标未推进,Stream 可重复读,不会丢消息。
+ result = None
+ except Exception as e:
+ errors += 1
+ maxkb_logger.error(
+ f"MessageQueue consume error [{queue_id}] ({errors}/{MAX_CONSECUTIVE_ERRORS}): {e}"
+ )
+ if errors >= MAX_CONSECUTIVE_ERRORS:
+ break
+ time.sleep(min(0.1 * errors, 1.0))
+ continue
+
+ if result:
+ for _, messages in result:
+ last_id = self._emit(messages, on_message)
+ if last_id:
+ current_id = last_id
+ continue
+
+ # 阻塞窗口内没有新消息,检查是否已结束
+ try:
+ finished = self.is_done(queue_id)
+ except MessageQueueError:
+ continue
+ if finished:
+ # done 标记是在所有 produce 之后写的,这里把尾部残留全部读干净
+ self._flush_remaining(redis, key, current_id, on_message)
+ break
+ except MessageQueueError as e:
+ maxkb_logger.error(f"MessageQueue consume aborted [{queue_id}]: {e}")
+ finally:
+ if on_done:
+ on_done()
+
+ def _flush_remaining(self, redis, key: str, current_id: str, on_message: Optional[Callable]) -> str:
+ """
+ 用非阻塞 xread 循环读完尾部消息。
+ 不传 block 参数即为非阻塞,且 xread 天然是"ID 严格大于游标"的语义,
+ 无需依赖 Redis 6.2+ 的 "(" 排他区间写法。
+ """
+ cursor = current_id
+ while True:
+ try:
+ result = redis.xread({key: cursor}, count=self._batch_size)
+ except Exception as e:
+ maxkb_logger.error(f"MessageQueue flush error [{key}]: {e}")
+ break
+ if not result:
+ break
+ total = 0
+ for _, messages in result:
+ total += len(messages)
+ last_id = self._emit(messages, on_message)
+ if last_id:
+ cursor = last_id
+ if total < self._batch_size:
+ break
+ return cursor
+
+ def get_messages(self, queue_id: str, start_id: str = "0", count: int = 100) -> list:
+ try:
+ result = self._get_redis().xread({self._key(queue_id): start_id or "0"}, count=count)
+ except Exception as e:
+ raise MessageQueueError(f"get_messages({queue_id}) failed: {e}") from e
+ return [
+ {"id": self._decode_bytes(mid), "data": self._decode_field(fields)}
+ for _, messages in (result or [])
+ for mid, fields in messages
+ ]
+
+ def delete(self, queue_id: str) -> None:
+ try:
+ self._get_redis().delete(self._key(queue_id), self._done_key(queue_id))
+ except Exception as e:
+ maxkb_logger.error(f"MessageQueue delete error [{queue_id}]: {e}")
+ raise MessageQueueError(f"delete({queue_id}) failed: {e}") from e
+
+ def clear_by_pattern(self, pattern: str) -> int:
+ """返回删除的 stream 数量;对应的 :done 标记也会一并清理。"""
+ try:
+ redis = self._get_redis()
+ count = 0
+ for full_pattern, counted in (
+ (f"{self._namespace}:{pattern}", True),
+ (f"{self._namespace}:{pattern}:done", False),
+ ):
+ cursor = 0
+ while True:
+ cursor, keys = redis.scan(cursor, match=full_pattern, count=500)
+ if keys:
+ redis.delete(*keys)
+ if counted:
+ count += len(keys)
+ if cursor == 0:
+ break
+ return count
+ except Exception as e:
+ maxkb_logger.error(f"MessageQueue clear_by_pattern error [{pattern}]: {e}")
+ return 0
+
+
+class _ProducerLane:
+ """单个 queue_id 的写入通道:一个 FIFO 缓冲 + 是否已有 flush 任务在跑的标记。"""
+
+ __slots__ = ("buffer", "active")
+
+ def __init__(self):
+ self.buffer: deque = deque()
+ self.active = False
+
+
+class AsyncMessageQueue(IMessageQueue):
+ """
+ 给后端队列套一层"异步写入 + 单会话保序"的生产侧包装。
+
+ 流式场景每 token 都要 produce 一次,若同步写后端(Redis)会让调用线程逐 token 等一次网络 RTT,
+ 进而拖慢整个工作流。这里把 produce / produce_done 交给共享线程池执行,调用方只做一次进程内入队后立即返回:
+ - 每个 queue_id 同一时刻至多一个 flush 任务在跑,消息按 deque FIFO 顺序写出,
+ 断线重连用的 Stream ID 顺序不受影响;
+ - produce_done 走同一条通道,保证在全部消息写完之后才落 done 标记;
+ - 会话写空后回收该 queue_id 的通道,避免长期占用内存;
+ - 读操作(exists / consume / get_messages / is_done 等)直接委托后端,语义不变。
+ 后端写入异常只记日志、不抛回业务线程(业务线程此时早已返回)。
+ """
+
+ _DONE = object()
+
+ def __init__(self, backend: IMessageQueue, max_workers: int = None):
+ self._backend = backend
+ self._lanes: dict[str, _ProducerLane] = {}
+ self._lock = threading.Lock()
+ self._pool = ThreadPoolExecutor(
+ max_workers=max_workers or int(os.getenv("MAXKB_MQ_PRODUCE_WORKERS", "8")),
+ thread_name_prefix="mq-produce",
+ )
+
+ # ---------- 生产侧:异步 + 保序 ----------
+
+ def produce(self, queue_id: str, message: Any, ttl: int = None) -> None:
+ self._enqueue(queue_id, (message, ttl))
+
+ def produce_done(self, queue_id: str, ttl: int = None) -> None:
+ self._enqueue(queue_id, (self._DONE, ttl))
+
+ def _enqueue(self, queue_id: str, item: Tuple[Any, Optional[int]]) -> None:
+ with self._lock:
+ lane = self._lanes.get(queue_id)
+ if lane is None:
+ lane = _ProducerLane()
+ self._lanes[queue_id] = lane
+ lane.buffer.append(item)
+ if lane.active:
+ return
+ lane.active = True
+ self._pool.submit(self._flush, queue_id)
+
+ def _flush(self, queue_id: str) -> None:
+ while True:
+ with self._lock:
+ lane = self._lanes.get(queue_id)
+ if lane is None:
+ return
+ if not lane.buffer:
+ # 写空即回收:新消息到来时 _enqueue 会重建通道并重新提交任务
+ self._lanes.pop(queue_id, None)
+ return
+ message, ttl = lane.buffer.popleft()
+ try:
+ if message is self._DONE:
+ self._backend.produce_done(queue_id, ttl=ttl)
+ else:
+ self._backend.produce(queue_id, message, ttl=ttl)
+ except Exception as e:
+ maxkb_logger.error(f"AsyncMessageQueue flush error [{queue_id}]: {e}")
+
+ def _drop_lane(self, queue_id: str) -> None:
+ with self._lock:
+ self._lanes.pop(queue_id, None)
+
+ # ---------- 读操作 / 清理:委托后端 ----------
+
+ def exists(self, queue_id: str) -> bool:
+ return self._backend.exists(queue_id)
+
+ def is_done(self, queue_id: str) -> bool:
+ return self._backend.is_done(queue_id)
+
+ def consume(
+ self,
+ queue_id: str,
+ start_id: str = "0",
+ on_message: Optional[Callable[[str, str], None]] = None,
+ on_done: Optional[Callable[[], None]] = None,
+ timeout: float = 300,
+ should_stop: Optional[Callable[[], bool]] = None,
+ ) -> None:
+ return self._backend.consume(queue_id, start_id, on_message, on_done, timeout, should_stop)
+
+ def get_messages(self, queue_id: str, start_id: str = "0", count: int = 100) -> list:
+ return self._backend.get_messages(queue_id, start_id, count)
+
+ def delete(self, queue_id: str) -> None:
+ # 先丢掉尚未 flush 的缓冲,避免删除后又把残留写回后端
+ self._drop_lane(queue_id)
+ self._backend.delete(queue_id)
+
+ def clear_by_pattern(self, pattern: str) -> int:
+ with self._lock:
+ for queue_id in [q for q in self._lanes if fnmatch.fnmatch(q, pattern)]:
+ self._lanes.pop(queue_id, None)
+ return self._backend.clear_by_pattern(pattern)
+
+
+def create_message_queue(namespace: str = "mq", use_redis: bool = True) -> IMessageQueue:
+ """
+ 创建消息队列实例
+ @param namespace: 命名空间
+ @param use_redis: 是否使用Redis(False则使用内存实现)
+ """
+ if use_redis:
+ try:
+ queue = RedisStreamMessageQueue(namespace=namespace)
+ queue.ping()
+ return queue
+ except Exception as e:
+ maxkb_logger.warning(f"Redis 不可用,降级为 InMemoryMessageQueue(多 worker 部署下跨进程消费将失效): {e}")
+ return InMemoryMessageQueue()
+
+
+def get_message_queue(namespace: str = "chat") -> IMessageQueue:
+ """进程内按 namespace 复用队列实例。禁止在业务代码里直接调 create_message_queue。"""
+ instance = _instances.get(namespace) # 快路径,GIL 下 dict.get 原子
+ if instance is not None:
+ return instance
+ with _instances_lock:
+ instance = _instances.get(namespace) # 双检
+ if instance is None:
+ from django.conf import settings
+
+ backend = create_message_queue(
+ namespace=namespace,
+ use_redis=getattr(settings, "MESSAGE_QUEUE_USE_REDIS", True),
+ )
+ # 套异步写入层:produce/produce_done 不再阻塞业务线程(逐 token 的 Redis RTT 拖慢工作流)
+ instance = AsyncMessageQueue(backend)
+ _instances[namespace] = instance
+ return instance
diff --git a/apps/application/workflow/nodes/__init__.py b/apps/application/workflow/nodes/__init__.py
new file mode 100644
index 00000000000..3f6eeb92644
--- /dev/null
+++ b/apps/application/workflow/nodes/__init__.py
@@ -0,0 +1,78 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py.py
+ @date:2026/6/29 16:15
+ @desc:
+"""
+import pkgutil
+import importlib
+import inspect
+from pathlib import Path
+
+from application.workflow.i_node import INode
+
+node_list: list[type[INode]] = []
+_seen: set[type] = set()
+
+for _, module_name, _ in pkgutil.iter_modules([str(Path(__file__).parent)]):
+ module = importlib.import_module(f".{module_name}", __package__)
+ for _, obj in inspect.getmembers(module, inspect.isclass):
+ if (
+ issubclass(obj, INode)
+ and obj is not INode
+ and obj.__module__.startswith(__package__)
+ and obj not in _seen
+ ):
+ _seen.add(obj)
+ node_list.append(obj)
+
+if not node_list:
+ raise RuntimeError(f"未发现任何节点,检查各子包 __init__.py 是否导出了 INode 子类: {Path(__file__).parent}")
+
+node_map = {n.type: {workflow_type: n for workflow_type in n.supported_workflow_type_list} for n in node_list}
+
+
+def get_node_class(_type, workflow_type):
+ """
+ 根据节点类型 获取此类型的处理器
+ @param _type: 节点类型
+ @param workflow_type: 工作流类型
+ @return: 节点处理器
+ """
+ node_class = node_map.get(_type, {}).get(workflow_type)
+ if node_class is None:
+ raise ValueError(f"节点不存在: type={_type}, workflow_type={workflow_type}")
+ return node_class
+
+
+def get_start_node(workflow, workflow_manage, workflow_type, position=None):
+ """
+ 获取开始节点实例
+ @param workflow: 工作流对象
+ @param workflow_manage 工作流管理器
+ @param workflow_type: 工作流类型
+ @param position: 位置信息(可选)
+ @return: 开始节点实例
+ """
+ # 如果有 position,根据 position 确定开始节点
+ if position and position.get('id'):
+ node_id = position.get('id')
+ node = workflow.get_node(node_id)
+ if node:
+ node_class = get_node_class(node.type, workflow_type)
+ def get_node_parameters(n):
+ return n.properties.get('node_data', {})
+ return node_class(node, workflow_manage, get_node_parameters)
+
+ # 默认返回开始节点
+ start_node = workflow.get_node('start-node')
+ if start_node is None:
+ raise ValueError("开始节点不存在")
+ node_class = get_node_class(start_node.type, workflow_type)
+
+ def get_node_parameters(node):
+ return node.properties.get('node_data', {})
+
+ return node_class(start_node, workflow_manage, get_node_parameters)
diff --git a/apps/application/workflow/nodes/ai_chat_node/__init__.py b/apps/application/workflow/nodes/ai_chat_node/__init__.py
new file mode 100644
index 00000000000..0682df52ea0
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/7/1 16:59
+ @desc:
+"""
+from .ai_chat_node import AIChatNode
diff --git a/apps/application/workflow/nodes/ai_chat_node/agent.py b/apps/application/workflow/nodes/ai_chat_node/agent.py
new file mode 100644
index 00000000000..9e8a561bd83
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/agent.py
@@ -0,0 +1,209 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: agent.py
+@date: 2026/9/14 16:59
+@desc: AI 对话节点的 Agent(MCP / deepagents)执行逻辑。
+
+从 application/flow/tools.py 抽离,供新工作流引擎的 ai_chat_node 使用,
+避免新引擎反向依赖旧引擎的 flow.tools 模块。
+"""
+
+import asyncio
+import json
+import os
+import re
+import shutil
+
+import langchain_core.messages.ai as _lc_ai_module
+import uuid_utils.compat as uuid
+from deepagents import create_deep_agent
+from langchain_core.utils._merge import merge_lists as _original_merge_lists
+from langchain_mcp_adapters.client import MultiServerMCPClient
+from langgraph.checkpoint.memory import MemorySaver
+
+from application.workflow.backend.sandbox_shell import SandboxShellBackend
+from application.workflow.i_node import CancelledException
+from application.workflow.nodes.ai_chat_node.tools.skill import init_skills
+from maxkb.const import CONFIG
+
+
+# ---------------------------------------------------------------------------
+# Fix: qwen's OpenAI-compatible streaming sends id='' (empty string) for
+# intermediate tool_call_chunks while only the first chunk carries the real
+# id ('call_xxx...'). langchain-core's merge_lists treats '' != 'call_xxx' as
+# an ID conflict and _appends_ instead of merging → the accumulated AIMessage
+# ends up with two separate tool_calls (one with empty args, one with empty
+# id) instead of one correct entry. This causes the Qwen API to reject the
+# next request with "function.arguments must be in JSON format".
+#
+# Patch: normalise id='' → None for items that have an 'index' key
+# (i.e. tool_call_chunk dicts). merge_lists treats None as "no id" and will
+# merge with any existing entry, keeping the real id from the first chunk.
+# ---------------------------------------------------------------------------
+def _merge_lists_normalize_empty_tool_chunk_ids(left, *others):
+ """Wrapper around merge_lists that normalises empty-string IDs to None in
+ tool_call_chunk items (those with an 'index' key) so that qwen streaming
+ chunks with id='' are merged correctly by index."""
+
+ def _norm(lst):
+ if lst is None:
+ return lst
+ result = []
+ for item in lst:
+ if isinstance(item, dict) and "index" in item and item.get("id") == "":
+ item = {**item, "id": None}
+ result.append(item)
+ return result
+
+ return _original_merge_lists(
+ _norm(left),
+ *[_norm(o) for o in others],
+ )
+
+
+# Replace the module-level reference used by add_ai_message_chunks in ai.py
+_lc_ai_module.merge_lists = _merge_lists_normalize_empty_tool_chunk_ids
+
+
+def _get_tool_call_id(raw_id):
+ if not raw_id:
+ return None
+ if not isinstance(raw_id, str):
+ raw_id = str(raw_id)
+
+ s = raw_id
+ prefix = "call_"
+ positions = [m.start() for m in re.finditer(re.escape(prefix), s)]
+ if not positions:
+ return raw_id
+
+ # 取最后一个前缀位置,截到下一个前缀或结尾
+ start = positions[-1]
+ end = len(s)
+ for pos in positions:
+ if pos > start:
+ end = pos
+ break
+
+ tool_id = s[start:end]
+ return tool_id or raw_id
+
+
+class ToolCallStreamManagement:
+ def __init__(self):
+ self.index_id_map = {}
+ self.id_name_map = {}
+ self.tool_uuid_map = {}
+ self.use_tool_id_list = set()
+
+ @staticmethod
+ def get_fallback_tool_calls(msg):
+ source = msg.tool_calls or msg.invalid_tool_calls
+ if source:
+ return [(tc.get("index"), tc.get("id"), tc.get("name"), tc.get("args", "")) for tc in source]
+ result = []
+ for tc in msg.additional_kwargs.get("tool_calls", []):
+ func = tc.get("function")
+ if isinstance(func, dict):
+ result.append((tc.get("index"), tc.get("id"), func.get("name"), func.get("arguments", "")))
+ else:
+ result.append((tc.get("index"), tc.get("id"), tc.get("name"), tc.get("arguments", "")))
+ return result
+
+ def get_tool_id(self, index, raw_id):
+ if raw_id and str(raw_id).strip():
+ tool_id = _get_tool_call_id(str(raw_id).strip())
+ if index is not None:
+ self.index_id_map[index] = tool_id
+ return tool_id
+ if index is not None:
+ return self.index_id_map.get(index)
+ return None
+
+ def get_tool_name(self, tool_id, default=None):
+ return self.id_name_map.get(tool_id, default)
+
+ def add_tool_id(self, tool_id):
+ self.use_tool_id_list.add(tool_id)
+
+ def get_tool_uuid(self, tool_id):
+ if tool_id not in self.tool_uuid_map:
+ self.tool_uuid_map[tool_id] = str(uuid.uuid7())
+ return self.tool_uuid_map.get(tool_id)
+
+ def tool_id_is_used(self, tool_id):
+ return tool_id in self.use_tool_id_list
+
+ def set_tool_id_name(self, tool_id, name):
+ self.id_name_map[tool_id] = name
+
+
+def create_agent(
+ chat_model,
+ system_prompt,
+ message_list,
+ mcp_servers,
+ call_back,
+ chat_id=None,
+ skill_tool_ids=None,
+ extra_tools=None,
+):
+ # 创建临时文件夹
+ if chat_id:
+ temp_dir = os.path.join("/tmp", chat_id)
+ else:
+ temp_dir = os.path.join("/tmp", str(uuid.uuid7()))
+ skills_dir = os.path.join(temp_dir, "skills")
+ os.makedirs(skills_dir, exist_ok=True)
+
+ async def _run():
+ checkpointer = MemorySaver()
+ await init_skills(skill_tool_ids, temp_dir)
+ client = MultiServerMCPClient(json.loads(mcp_servers))
+ tools = await client.get_tools()
+ for tool in tools:
+ tool.handle_tool_error = True
+ if extra_tools:
+ for tool in extra_tools:
+ tools.append(tool)
+
+ agent = create_deep_agent(
+ model=chat_model,
+ backend=SandboxShellBackend(root_dir=temp_dir, virtual_mode=True),
+ skills=["/skills"],
+ tools=tools,
+ system_prompt=system_prompt,
+ interrupt_on={"write_file": False, "read_file": False, "edit_file": False},
+ checkpointer=checkpointer,
+ )
+ recursion_limit = int(CONFIG.get("LANGCHAIN_GRAPH_RECURSION_LIMIT", "100"))
+ response = agent.astream(
+ {"messages": message_list},
+ config={"recursion_limit": recursion_limit, "configurable": {"thread_id": chat_id}},
+ stream_mode="messages",
+ )
+
+ async for chunk in response:
+ msg = chunk[0]
+ call_back.on_next(msg)
+
+ def _classify_error(e):
+ # 取消:原样保留(保持节点取消语义);MCP TaskGroup 的 ExceptionGroup:展开取真实异常并包成 RuntimeError
+ if isinstance(e, CancelledException):
+ return e
+ if isinstance(e, ExceptionGroup):
+ while isinstance(e, ExceptionGroup):
+ e = e.exceptions[0]
+ return RuntimeError(f"{type(e).__name__}: {str(e)}")
+
+ error = None
+ try:
+ asyncio.run(_run())
+ except Exception as e:
+ error = _classify_error(e)
+ finally:
+ # 清理临时文件夹
+ shutil.rmtree(temp_dir, ignore_errors=True)
+ call_back.on_complete(error)
diff --git a/apps/application/workflow/nodes/ai_chat_node/ai_chat_node.py b/apps/application/workflow/nodes/ai_chat_node/ai_chat_node.py
new file mode 100644
index 00000000000..ae744850396
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/ai_chat_node.py
@@ -0,0 +1,666 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: ai_chat_node.py
+@date:2026/7/1 16:59
+@desc:
+"""
+
+import base64
+import json
+import re
+from functools import reduce
+from typing import Callable, Optional
+
+import uuid_utils.compat as uuid
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage, AIMessageChunk
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.aggregator import AggregationManager
+from application.workflow.message.struct.content import NodeInfo, Position, Content
+from application.workflow.message.struct.reasoning_content import ReasoningContent
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.message.struct.tool_content import ToolContent
+from application.workflow.nodes.ai_chat_node.agent import create_agent, ToolCallStreamManagement, _get_tool_call_id
+from application.workflow.nodes.ai_chat_node.tools import (
+ get_application_tools,
+ get_mcp_servers,
+ get_tool_tools,
+)
+from application.workflow.status import Status
+from application.workflow.tools import Reasoning
+from common.utils.common import guess_image_format
+from common.utils.messages_util import to_ai_message_list, to_human_message_list
+from common.utils.shared_resource_auth import filter_authorized_ids
+from common.utils.tool_code import ToolExecutor
+from knowledge.models import File
+from models_provider.models import Model
+from models_provider.tools import get_model_credential, get_model_instance_by_model_workspace_id
+from common.exception.app_exception import AppApiException
+
+
+class AgentCallBack:
+ def __init__(
+ self,
+ on_next: Callable[[any], None],
+ on_complete: Callable[[Optional[Exception]], None],
+ ):
+ self.on_next = on_next
+ self.on_complete = on_complete
+
+
+class ChatNodeSerializer(serializers.Serializer):
+ model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+ system = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Role Setting"))
+ prompt = serializers.CharField(required=True, label=_("Prompt word"))
+ dialogue_number = serializers.IntegerField(required=True, label=_("Number of multi-round conversations"))
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+ model_params_setting = serializers.DictField(required=False, label=_("Model parameter settings"))
+ model_setting = serializers.DictField(required=False, label="Model settings")
+ dialogue_type = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Context Type"))
+ mcp_servers = serializers.JSONField(required=False, label=_("MCP Server"))
+ mcp_tool_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("MCP Tool ID"))
+ mcp_tool_ids = serializers.ListField(
+ child=serializers.UUIDField(),
+ required=False,
+ allow_empty=True,
+ label=_("MCP Tool IDs"),
+ )
+ mcp_source = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("MCP Source"))
+ tool_ids = serializers.ListField(
+ child=serializers.UUIDField(),
+ required=False,
+ allow_empty=True,
+ label=_("Tool IDs"),
+ )
+ application_ids = serializers.ListField(
+ child=serializers.UUIDField(),
+ required=False,
+ allow_empty=True,
+ label=_("App IDs"),
+ )
+ skill_tool_ids = serializers.ListField(
+ child=serializers.UUIDField(),
+ required=False,
+ allow_empty=True,
+ label=_("Skill IDs"),
+ )
+ mcp_output_enable = serializers.BooleanField(required=False, default=True, label=_("Whether to enable MCP output"))
+ video_list = serializers.ListField(required=False, label=_("video"))
+ image_list = serializers.ListField(required=False, label=_("picture"))
+ vision = serializers.BooleanField(required=False, default=False, label=_("vision"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+def _get_default_model_params_setting(model_id):
+ model = QuerySet(Model).filter(id=model_id).first()
+ credential = get_model_credential(model.provider, model.model_type, model.model_name)
+ model_params_setting = credential.get_model_params_setting_form(model.model_name).get_default_form_data()
+ return model_params_setting
+
+
+def _get_node_message(chat_record, runtime_node_id):
+ node_details = chat_record.get_node_details_runtime_node_id(runtime_node_id)
+ if node_details is None:
+ return []
+ return [*to_human_message_list(node_details.get("question")), *to_ai_message_list(node_details.get("messages"))]
+ return [HumanMessage(node_details.get("question")), AIMessage(node_details.get("messages"))]
+
+
+def _get_workflow_message(chat_record):
+ return [*chat_record.get_human_message(), *chat_record.get_ai_message()]
+
+
+def _get_message(chat_record, dialogue_type, runtime_node_id):
+ if dialogue_type == "NODE":
+ return _get_node_message(chat_record, runtime_node_id)
+ return _get_workflow_message(chat_record)
+
+
+def _get_history_message(history_chat_record, dialogue_number, dialogue_type, runtime_node_id):
+ start_index = len(history_chat_record) - dialogue_number
+ history_message = reduce(
+ lambda x, y: [*x, *y],
+ [
+ _get_message(history_chat_record[index], dialogue_type, runtime_node_id)
+ for index in range(max(start_index, 0), len(history_chat_record))
+ ],
+ [],
+ )
+ for message in history_message:
+ if isinstance(message.content, str):
+ message.content = re.sub(r".*?", "", message.content, flags=re.DOTALL)
+ return history_message
+
+
+def _process_images(image):
+ images = []
+ if isinstance(image, str) and image.startswith("http"):
+ images.append({"type": "image_url", "image_url": {"url": image}})
+ elif image is not None and len(image) > 0:
+ for img in image:
+ if "file_id" in img:
+ file_id = img["file_id"]
+ file = QuerySet(File).filter(id=file_id).first()
+ image_bytes = file.get_bytes()
+ base64_image = base64.b64encode(image_bytes).decode("utf-8")
+ image_format = guess_image_format(image_bytes)
+ images.append(
+ {"type": "image_url", "image_url": {"url": f"data:image/{image_format};base64,{base64_image}"}}
+ )
+ elif "url" in img and img["url"].startswith("http"):
+ images.append({"type": "image_url", "image_url": {"url": img["url"]}})
+ return images
+
+
+def _get_upstream_knowledge_images(workflow_manage, node_id):
+ """Collect image hits produced by executed upstream knowledge-search nodes."""
+ workflow = workflow_manage.workflow
+ pending_node_ids = [node_id]
+ visited_node_ids = set()
+ image_list = []
+ seen_file_ids = set()
+
+ while pending_node_ids:
+ current_node_id = pending_node_ids.pop()
+ if current_node_id in visited_node_ids:
+ continue
+ visited_node_ids.add(current_node_id)
+ for edge_node in workflow.up_node_map.get(current_node_id, []):
+ upstream_node = edge_node.node
+ pending_node_ids.append(upstream_node.id)
+ if upstream_node.type != "search-knowledge-node":
+ continue
+ for image in workflow_manage.get_context(upstream_node.id, "image_list") or []:
+ file_id = str(image.get("file_id") or "")
+ if not file_id or file_id in seen_file_ids:
+ continue
+ seen_file_ids.add(file_id)
+ image_list.append(image)
+ return image_list
+
+
+def _process_videos(video, video_model):
+ videos = []
+ if isinstance(video, str) and video.startswith("http"):
+ videos.append({"type": "video_url", "video_url": {"url": video}})
+ elif video is not None and len(video) > 0:
+ for v in video:
+ if "file_id" in v:
+ file_id = v["file_id"]
+ file = QuerySet(File).filter(id=file_id).first()
+ url = video_model.upload_file_and_get_url(file.get_bytes(), file.file_name)
+ videos.append({"type": "video_url", "video_url": {"url": url}})
+ elif "url" in v and v["url"].startswith("http"):
+ videos.append({"type": "video_url", "video_url": {"url": v["url"]}})
+ return videos
+
+
+class AIChatNode(INode):
+ serializer_class = ChatNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "ai-chat-node"
+
+ def write(self, message: Content):
+ super().write(message)
+ if not self.data.get("messages"):
+ self.data["messages"] = []
+ self.data["messages"].append(message)
+
+ def execute(self):
+ workflow_params = self.get_workflow_parameters()
+ node_params = self.get_parameters()
+ reasoning_content_id = str(uuid.uuid7())
+ text_content_id = str(uuid.uuid7())
+
+ model_id = node_params.get("model_id")
+ model_id_type = node_params.get("model_id_type", "custom")
+ model_id_reference = node_params.get("model_id_reference")
+ model_params_setting = node_params.get("model_params_setting")
+ model_setting = node_params.get("model_setting")
+ system = node_params.get("system", "")
+ prompt = node_params.get("prompt", "")
+ dialogue_number = node_params.get("dialogue_number", 0)
+ dialogue_type = node_params.get("dialogue_type", "WORKFLOW") or "WORKFLOW"
+ is_result = node_params.get("is_result", False)
+ vision = node_params.get("vision", False)
+ image_list = node_params.get("image_list")
+ video_list = node_params.get("video_list")
+ stream = node_params.get("stream", True)
+
+ mcp_servers = node_params.get("mcp_servers")
+ mcp_tool_id = node_params.get("mcp_tool_id")
+ mcp_tool_ids = node_params.get("mcp_tool_ids")
+ mcp_source = node_params.get("mcp_source")
+ tool_ids = node_params.get("tool_ids")
+ application_ids = node_params.get("application_ids")
+ skill_tool_ids = node_params.get("skill_tool_ids")
+ mcp_output_enable = node_params.get("mcp_output_enable", True)
+
+ workflow_type = self.get_workflow_type()
+ if workflow_type in (WorkflowType.KNOWLEDGE, WorkflowType.TOOL):
+ history_chat_record = []
+ chat_id = None
+ workspace_id = workflow_params.get("workspace_id")
+ else:
+ history_chat_record = workflow_params.get("history_chat_record", [])
+ chat_id = workflow_params.get("chat_id")
+ workspace_id = workflow_params.get("workspace_id")
+
+ if model_id_type == "reference" and model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ model_id_reference[0],
+ model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ model_id = reference_data.get("model_id", model_id)
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("LLM") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ model_id = default_model_setting.get("model_id", model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ if model_params_setting is None and model_id:
+ model_params_setting = _get_default_model_params_setting(model_id)
+
+ if model_setting is None:
+ model_setting = {
+ "reasoning_content_enable": False,
+ "reasoning_content_end": "",
+ "reasoning_content_start": "",
+ }
+ self.data["model_setting"] = model_setting
+
+ chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {}))
+
+ history_message = _get_history_message(history_chat_record, dialogue_number, dialogue_type, self.get_node_id())
+ self.write_context(
+ "history_message",
+ [{"content": message.content, "role": message.type} for message in (history_message or [])],
+ )
+ question_str = self.workflow_manage.generate_prompt(prompt)
+ question = self._generate_prompt_question(question_str, chat_model, vision, image_list, video_list)
+ self.data["question"] = {"content": question_str, "image_list": image_list, "video_list": video_list}
+
+ system = self.workflow_manage.generate_prompt(system)
+ self.data["system"] = system
+
+ message_list = [*history_message, question]
+
+ all_tool_ids = list(
+ set(
+ (mcp_tool_ids or [])
+ + (tool_ids or [])
+ + (skill_tool_ids or [])
+ + ([mcp_tool_id] if mcp_tool_id else [])
+ )
+ )
+ authorized_set = set(filter_authorized_ids("tool", all_tool_ids, workspace_id))
+ mcp_tool_ids = [i for i in (mcp_tool_ids or []) if i in authorized_set]
+ tool_ids = [i for i in (tool_ids or []) if i in authorized_set]
+ skill_tool_ids = [i for i in (skill_tool_ids or []) if i in authorized_set]
+ mcp_tool_id = mcp_tool_id if (mcp_tool_id and mcp_tool_id in authorized_set) else None
+
+ mcp_handled = self._handle_mcp(
+ mcp_source,
+ mcp_servers,
+ mcp_tool_id,
+ mcp_tool_ids,
+ tool_ids,
+ application_ids,
+ skill_tool_ids,
+ mcp_output_enable,
+ chat_model,
+ SystemMessage(system),
+ message_list,
+ question,
+ chat_id,
+ workspace_id,
+ workflow_type,
+ is_result,
+ )
+ if not mcp_handled:
+ message_list_with_system = [SystemMessage(system)] + message_list
+
+ if stream:
+ r = chat_model.stream(message_list_with_system)
+ self._stream_response(
+ r, chat_model, message_list_with_system, question.content, reasoning_content_id, text_content_id
+ )
+ else:
+ r = chat_model.invoke(message_list_with_system)
+ self._invoke_response(
+ r, chat_model, message_list_with_system, question.content, is_result, text_content_id
+ )
+
+ def _generate_prompt_question(self, question_str, model, vision, image_list, video_list):
+ images = []
+ videos = []
+ if vision:
+ if image_list:
+ image = self.workflow_manage.get_reference_field(image_list[0], image_list[1:])
+ images = _process_images(image)
+ recalled_images = _get_upstream_knowledge_images(self.workflow_manage, self.get_node_id())
+ if recalled_images:
+ images.extend(_process_images(recalled_images))
+ if video_list:
+ video = self.workflow_manage.get_reference_field(video_list[0], video_list[1:])
+ videos = _process_videos(video, model)
+ return HumanMessage(content=[*videos, *images, {"type": "text", "text": question_str}])
+
+ def _stream_response(self, response, chat_model, message_list, question, reasoning_content_id, text_content_id):
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.RUNNING)
+ model_setting = self.get_context("model_setting") or {}
+ reasoning = Reasoning(
+ model_setting.get("reasoning_content_start", ""),
+ model_setting.get("reasoning_content_end", ""),
+ )
+ answer = ""
+ reasoning_content = ""
+ response_reasoning_content = False
+
+ for chunk in response:
+ self._check_cancelled()
+ reasoning_chunk = reasoning.get_reasoning_content(chunk)
+ content_chunk = reasoning_chunk.get("content")
+ if "reasoning_content" in chunk.additional_kwargs:
+ response_reasoning_content = True
+ reasoning_content_chunk = chunk.additional_kwargs.get("reasoning_content", "")
+ else:
+ reasoning_content_chunk = reasoning_chunk.get("reasoning_content")
+ answer += content_chunk
+ if reasoning_content_chunk is None:
+ reasoning_content_chunk = ""
+ reasoning_content += reasoning_content_chunk
+ reasoning_end = False
+ if content_chunk:
+ if not reasoning_end:
+ self.write(
+ ReasoningContent(
+ reasoning_content_id, "", Status.SUCCESS, node_info, Position(self.get_node_id())
+ )
+ )
+ self.write(
+ TextContent(text_content_id, content_chunk, Status.RUNNING, node_info, Position(self.get_node_id()))
+ )
+ if reasoning_content_chunk and model_setting.get("reasoning_content_enable", False):
+ self.write(
+ ReasoningContent(
+ reasoning_content_id,
+ reasoning_content_chunk,
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+
+ reasoning_end = reasoning.get_end_reasoning_content()
+ answer += reasoning_end.get("content")
+ reasoning_content_chunk = ""
+ if not response_reasoning_content:
+ reasoning_content_chunk = reasoning_end.get("reasoning_content")
+ if reasoning_end.get("content"):
+ self.write(
+ TextContent(
+ text_content_id,
+ reasoning_end.get("content"),
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+ if reasoning_content_chunk and model_setting.get("reasoning_content_enable", False):
+ self.write(
+ ReasoningContent(
+ reasoning_content_id,
+ reasoning_content_chunk,
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+
+ self._write_final_context(chat_model, message_list, question, answer, reasoning_content)
+
+ def _invoke_response(self, response, chat_model, message_list, question, is_result=False, text_content_id=None):
+ model_setting = self.get_context("model_setting") or {}
+ reasoning = Reasoning(
+ model_setting.get("reasoning_content_start", ""),
+ model_setting.get("reasoning_content_end", ""),
+ )
+ reasoning_result = reasoning.get_reasoning_content(response)
+ reasoning_result_end = reasoning.get_end_reasoning_content()
+ content = reasoning_result.get("content") + reasoning_result_end.get("content")
+ meta = {**response.response_metadata, **response.additional_kwargs}
+ if "reasoning_content" in meta:
+ reasoning_content = meta.get("reasoning_content", "") or ""
+ else:
+ reasoning_content = (reasoning_result.get("reasoning_content") or "") + (
+ reasoning_result_end.get("reasoning_content") or ""
+ )
+ self._write_final_context(chat_model, message_list, question, content, reasoning_content)
+ if is_result:
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(TextContent(text_content_id, content, Status.SUCCESS, node_info, Position(self.get_node_id())))
+
+ def _write_final_context(self, chat_model, message_list, question, answer, reasoning_content):
+ message_tokens = chat_model.get_num_tokens_from_messages(message_list)
+ answer_tokens = chat_model.get_num_tokens(answer)
+ self.data["message_tokens"] = message_tokens
+ self.data["answer_tokens"] = answer_tokens
+ self.write_context("answer", answer)
+ self.data["reasoning_content"] = reasoning_content
+
+ def _handle_mcp(
+ self,
+ mcp_source,
+ mcp_servers,
+ mcp_tool_id,
+ mcp_tool_ids,
+ tool_ids,
+ application_ids,
+ skill_tool_ids,
+ mcp_output_enable,
+ chat_model,
+ system_prompt,
+ message_list,
+ question,
+ chat_id,
+ workspace_id,
+ workflow_type,
+ text_content_id,
+ is_result=False,
+ ):
+ # 工具记录来源(source_type / source_id)
+ if workflow_type == WorkflowType.KNOWLEDGE:
+ source_id = self.get_workflow_parameters().get("knowledge_id")
+ source_type = "KNOWLEDGE"
+ elif workflow_type == WorkflowType.TOOL:
+ source_id = self.get_workflow_parameters().get("tool_id")
+ source_type = "TOOL"
+ else:
+ source_id = self.get_workflow_parameters().get("application_id")
+ source_type = "APPLICATION"
+
+ # 工具(workflow/custom) + 智能体(子应用) → LangChain tools;
+ # MCP(自定义/库内) → mcp_servers 配置;技能 → 交给引擎侧 init_skills 初始化
+ tools = get_tool_tools(
+ source_type, source_id, tool_ids, workspace_id, self.get_workflow_parameters()
+ ) + get_application_tools(source_type, source_id, application_ids, workspace_id, self.get_workflow_parameters())
+ mcp_servers_config = get_mcp_servers(mcp_source, mcp_servers, mcp_tool_id, mcp_tool_ids, self._handle_variables)
+ ToolExecutor().validate_mcp_transport(json.dumps(mcp_servers_config))
+
+ if tools or mcp_servers_config or skill_tool_ids:
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.RUNNING)
+ # 使用可变状态在回调间共享(answer 累积、当前文本 content id、工具 content id 映射)
+ state = {"answer": "", "text_id": text_content_id, "tool_id_map": {}}
+ tool_stream = ToolCallStreamManagement()
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.RUNNING)
+
+ def on_next(chunk):
+ self._check_cancelled()
+ if mcp_output_enable and isinstance(chunk, AIMessageChunk):
+ if chunk.tool_call_chunks:
+ for tc in chunk.tool_call_chunks:
+ tool_id = tool_stream.get_tool_id(tc.get("index"), tc.get("id"))
+ if not tool_id:
+ continue
+ tool_stream.add_tool_id(tool_id)
+ if tc.get("name") or tc.get("args"):
+ tool_stream.set_tool_id_name(tool_id, tc.get("name"))
+ self.write(
+ ToolContent(
+ tool_stream.get_tool_uuid(tool_id),
+ tc.get("name"),
+ tc.get("args"),
+ "",
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+ else:
+ for index, raw_id, name, args in tool_stream.get_fallback_tool_calls(chunk):
+ tool_id = tool_stream.get_tool_id(index, raw_id)
+ if not tool_id or not tool_stream.add_tool_id(tool_id):
+ continue
+ self.write(
+ ToolContent(
+ tool_stream.get_tool_uuid(tool_id),
+ name,
+ args,
+ "",
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+
+ if mcp_output_enable and isinstance(chunk, ToolMessage):
+ tool_id = _get_tool_call_id(chunk.tool_call_id) or chunk.tool_call_id
+ chunk.name = tool_stream.get_tool_name(tool_id, chunk.name)
+ try:
+ if isinstance(chunk.content, str):
+ tool_result = json.loads(chunk.content)
+ elif isinstance(chunk.content, dict):
+ tool_result = chunk.content
+ elif isinstance(chunk.content, list):
+ tool_result = chunk.content[0] if len(chunk.content) > 0 else {}
+ else:
+ tool_result = {}
+ text = tool_result.get("text") if "text" in tool_result else None
+ text_result = json.loads(text) if text else tool_result
+ tool_result = (
+ text_result if isinstance(text_result, str) else json.dumps(text_result, ensure_ascii=False)
+ )
+ except Exception:
+ tool_result = chunk.content
+ result = (
+ tool_result if isinstance(tool_result, str) else json.dumps(tool_result, ensure_ascii=False)
+ )
+ self.write(
+ ToolContent(
+ tool_stream.get_tool_uuid(tool_id),
+ "",
+ "",
+ result,
+ Status.SUCCESS,
+ NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS),
+ Position(self.get_node_id()),
+ )
+ )
+ else:
+ if is_result and chunk.content:
+ self.write(
+ TextContent(
+ tool_stream.get_tool_uuid(chunk.id),
+ chunk.content,
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+
+ def on_complete(error):
+ if error:
+ raise error
+ self._write_final_context(chat_model, message_list, question.content, state["answer"], "")
+ self.write(
+ TextContent(
+ tool_stream.get_tool_uuid("text"),
+ "",
+ Status.SUCCESS,
+ NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS),
+ Position(self.get_node_id()),
+ )
+ )
+
+ create_agent(
+ chat_model,
+ system_prompt,
+ message_list,
+ json.dumps(mcp_servers_config),
+ AgentCallBack(on_next, on_complete),
+ chat_id,
+ skill_tool_ids,
+ tools,
+ )
+ return True
+
+ return False
+
+ def _handle_variables(self, tool_params):
+ for k, v in tool_params.items():
+ if isinstance(v, str):
+ tool_params[k] = self.workflow_manage.generate_prompt(v)
+ elif isinstance(v, dict):
+ self._handle_variables(v)
+ elif isinstance(v, list) and len(v) > 0 and isinstance(v[0], str):
+ tool_params[k] = self._get_reference_content(v)
+ return tool_params
+
+ def _get_reference_content(self, fields):
+ if fields:
+ return str(self.workflow_manage.get_reference_field(fields[0], fields[1:]))
+ return ""
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ aggregation = AggregationManager()
+ for m in self.data.get("messages") or []:
+ aggregation.aggregate(m)
+ messages = aggregation.get_contents()
+ details.update(
+ {
+ "question": self.data.get("question"),
+ "answer": self.get_context("answer"),
+ "reasoning_content": self.get_context("reasoning_content"),
+ "message_tokens": self.get_context("message_tokens"),
+ "answer_tokens": self.get_context("answer_tokens"),
+ "history_message": self.get_context("history_message"),
+ "messages": messages,
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/ai_chat_node/tools/__init__.py b/apps/application/workflow/nodes/ai_chat_node/tools/__init__.py
new file mode 100644
index 00000000000..03482beb2cd
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/tools/__init__.py
@@ -0,0 +1,15 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@date: 2026/9/15 16:58
+@desc:
+"""
+
+from .application import get_application_tools
+from .mcp import get_mcp_servers
+from .skill import init_skills
+from .tool import get_tool_tools
+
+__all__ = ["get_tool_tools", "get_application_tools", "get_mcp_servers", "init_skills"]
diff --git a/apps/application/workflow/nodes/ai_chat_node/tools/application.py b/apps/application/workflow/nodes/ai_chat_node/tools/application.py
new file mode 100644
index 00000000000..272b1f70220
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/tools/application.py
@@ -0,0 +1,191 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: application.py
+@date: 2026/9/15 16:10
+@desc:
+"""
+
+import re
+import threading
+
+import uuid_utils.compat as uuid
+from django.db.models import QuerySet
+from langchain_core.tools import StructuredTool
+from pydantic import Field
+
+from .base import build_schema
+
+
+def _application_string_to_uuid(input_str):
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, input_str))
+
+
+def get_application_args():
+ """
+ 应用(Agent)工具对模型暴露的入参:固定单个必填 message。
+
+ 与 chat.mcp.tools.MCPToolHandler.list_tools 的 inputSchema 保持一致,
+ 这样从远端 MCP 代理切换为进程内直调时,模型侧契约不变。
+ """
+ return build_schema(
+ {
+ "message": (str, Field(..., required=True, description="The message to send to the AI.")),
+ }
+ )
+
+
+def get_application_func(source_type, source_id, application, workflow_params, workspace_id):
+ """
+ 构建应用(Agent)工具的执行函数。
+
+ 工具调用是同步的,不支持子应用表单中断,命中表单时返回已累积文本。
+ """
+ application_id = str(application.id)
+
+ def inner(message: str = ""):
+ from application.models import Application, ApplicationVersion, Chat, ChatRecord, ChatSourceChoices
+ from application.workflow.common import WorkflowType, new_instance
+ from application.workflow.content_type import ContentType
+ from application.workflow.nodes import get_start_node
+ from application.workflow.workflow_manage import CallBack, WorkflowManage
+ from chat.serializers.chat import get_work_flow
+ from chat.serializers.chat_history import ChatHistory
+
+ question = str(message or "")
+ chat_id = workflow_params.get("chat_id")
+ chat_user_id = workflow_params.get("chat_user_id")
+ chat_user_type = workflow_params.get("chat_user_type")
+ ip_address = workflow_params.get("ip_address") or "-"
+ source = workflow_params.get("source") or {"type": ChatSourceChoices.ONLINE.value}
+ debug = workflow_params.get("debug", False)
+
+ # 自引用守卫:子应用不能是当前应用本身
+ if application_id == str(workflow_params.get("application_id") or ""):
+ raise Exception("The sub application cannot use the current agent")
+
+ # 派生子应用聊天 id(父对话 + 子应用稳定映射),与 application_node 一致
+ current_chat_id = _application_string_to_uuid(str(chat_id) + application_id)
+ asker = workflow_params.get("chat_user")
+ Chat.objects.get_or_create(
+ id=current_chat_id,
+ defaults={
+ "application_id": application_id,
+ "abstract": question[0:1024],
+ "chat_user_id": chat_user_id,
+ "chat_user_type": chat_user_type,
+ "ip_address": ip_address,
+ "source": source,
+ "asker": asker,
+ },
+ )
+
+ # 解析子应用工作流(debug 取本体,否则取最新发布版本)
+ if debug:
+ sub_application = QuerySet(Application).filter(id=application_id).first()
+ else:
+ sub_application = (
+ QuerySet(ApplicationVersion).filter(application_id=application_id).order_by("-create_time")[0:1].first()
+ )
+ if sub_application is None:
+ raise Exception("The application has not been published. Please use it after publishing.")
+
+ sub_workflow = new_instance(get_work_flow(sub_application), WorkflowType.APPLICATION)
+
+ # 生成子应用记录 id 并建 ChatRecord(子对话可追溯)
+ sub_chat_record_id = str(uuid.uuid7())
+ QuerySet(ChatRecord).create(
+ id=sub_chat_record_id,
+ chat_id=current_chat_id,
+ problem_text=question[0:1024],
+ answer_text="",
+ details={},
+ message_tokens=0,
+ answer_tokens=0,
+ answer_text_list=[[]],
+ index=0,
+ ip_address=ip_address or "",
+ source=source,
+ workflow_context={},
+ question={"content": question},
+ messages=[],
+ )
+
+ # 组装子应用参数(复制父工作流参数并覆盖子应用相关字段)
+ sub_parameters = dict(workflow_params)
+ sub_parameters.update(
+ {
+ "chat_id": current_chat_id,
+ "chat_record_id": sub_chat_record_id,
+ "application_id": application_id,
+ "question": question,
+ "stream": True,
+ "form_data": {},
+ "position": None,
+ "history_chat_record": ChatHistory(current_chat_id).load(exclude_record_id=sub_chat_record_id),
+ "image_list": [],
+ "document_list": [],
+ "audio_list": [],
+ "video_list": [],
+ "default_model_setting": sub_application.default_model_setting or {},
+ }
+ )
+
+ done_event = threading.Event()
+ result_holder = {"answer": "", "error": None}
+
+ def on_next(wf_manage, content):
+ # 逐块聚合子应用文本回答(不直接转发给上游,作为工具结果一次性返回)
+ if content.type == ContentType.TEXT:
+ result_holder["answer"] += content.content or ""
+
+ def on_complete(wf_manage, error):
+ try:
+ # 持久化子应用上下文,供后续追溯
+ QuerySet(ChatRecord).filter(id=sub_chat_record_id).update(workflow_context=wf_manage.context)
+ finally:
+ result_holder["error"] = error
+ done_event.set()
+
+ call_back = CallBack(on_next, on_complete)
+
+ def get_start_node_fn(wf, wm):
+ return get_start_node(wf, wm, WorkflowType.APPLICATION, None)
+
+ sub_manage = WorkflowManage(
+ sub_workflow, sub_parameters, WorkflowType.APPLICATION, call_back, get_start_node_fn
+ )
+ sub_manage.start_node.workflow_manage = sub_manage
+ sub_manage.run()
+ done_event.wait()
+ if result_holder["error"]:
+ raise result_holder["error"]
+
+ answer = result_holder["answer"]
+ # 去除 标签(与 MCPToolHandler.call_tool 一致)
+ answer = re.sub(r".*?", "", answer, flags=re.DOTALL)
+ return answer
+
+ return inner
+
+
+def get_application_tools(source_type, source_id, application_ids, workspace_id, workflow_params):
+ if not application_ids:
+ return []
+ from application.models import Application
+
+ applications = QuerySet(Application).filter(id__in=application_ids, is_publish=True)
+ results = []
+ for application in applications:
+ func = get_application_func(source_type, source_id, application, workflow_params, workspace_id)
+ args = get_application_args()
+ structured_tool = StructuredTool.from_function(
+ func=func,
+ name=application.name,
+ description=f"{application.name} {application.desc or ''}".strip(),
+ args_schema=args,
+ )
+ results.append(structured_tool)
+
+ return results
diff --git a/apps/application/workflow/nodes/ai_chat_node/tools/base.py b/apps/application/workflow/nodes/ai_chat_node/tools/base.py
new file mode 100644
index 00000000000..ac13933f716
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/tools/base.py
@@ -0,0 +1,30 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: base.py
+@date: 2026/9/15
+@desc:
+"""
+
+from pydantic import create_model
+
+
+def build_schema(fields: dict):
+ return create_model("dynamicSchema", **fields)
+
+
+def get_type(_type: str):
+ if _type == "float":
+ return float
+ if _type == "string":
+ return str
+ if _type == "int":
+ return int
+ if _type == "dict":
+ return dict
+ if _type == "array":
+ return list
+ if _type == "boolean":
+ return bool
+ return object
diff --git a/apps/application/workflow/nodes/ai_chat_node/tools/mcp.py b/apps/application/workflow/nodes/ai_chat_node/tools/mcp.py
new file mode 100644
index 00000000000..9f3c44e4708
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/tools/mcp.py
@@ -0,0 +1,37 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: mcp.py
+@date: 2026/9/15
+@desc:
+"""
+
+import json
+
+from django.db.models import QuerySet
+
+from tools.models import Tool
+
+
+def get_mcp_servers(mcp_source, mcp_servers, mcp_tool_id, mcp_tool_ids, handle_variables):
+ """
+ tool-mcp-custom:mcp_source == "custom" 时用节点传入的自定义 MCP JSON。
+ tool-mcp:否则用库内 MCP 工具(Tool.code 存 MCP server 配置)。
+ """
+ if mcp_source is None:
+ mcp_source = "custom"
+ if not mcp_tool_ids:
+ mcp_tool_ids = []
+ if mcp_tool_id:
+ mcp_tool_ids = list(set(mcp_tool_ids + [mcp_tool_id]))
+
+ mcp_servers_config = {}
+ if mcp_source == "custom" and mcp_servers:
+ mcp_servers_config = handle_variables(json.loads(mcp_servers))
+ elif mcp_tool_ids:
+ mcp_tools = QuerySet(Tool).filter(id__in=mcp_tool_ids).values()
+ for mcp_tool in mcp_tools:
+ if mcp_tool and mcp_tool["is_active"]:
+ mcp_servers_config = handle_variables({**mcp_servers_config, **json.loads(mcp_tool["code"])})
+ return mcp_servers_config
diff --git a/apps/application/workflow/nodes/ai_chat_node/tools/skill.py b/apps/application/workflow/nodes/ai_chat_node/tools/skill.py
new file mode 100644
index 00000000000..42d362ed48d
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/tools/skill.py
@@ -0,0 +1,66 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: skill.py
+@date: 2026/9/15
+@desc:
+"""
+
+import io
+import json
+import os
+import zipfile
+
+from asgiref.sync import sync_to_async
+from django.db.models import QuerySet
+
+from common.utils.rsa_util import rsa_long_decrypt
+from knowledge.models import File
+from tools.models import Tool
+
+
+async def init_skills(skill_tool_ids, temp_dir):
+ if not skill_tool_ids:
+ return
+ skills_dir = os.path.join(temp_dir, "skills")
+ tools = await sync_to_async(lambda: list(QuerySet(Tool).filter(id__in=skill_tool_ids, is_active=True)))()
+ if not tools:
+ return
+
+ for tool in tools:
+ init_params_default_value = {i["field"]: i.get("default_value") for i in (tool.init_field_list or [])}
+ if tool.init_params is not None:
+ params = init_params_default_value | json.loads(rsa_long_decrypt(tool.init_params))
+ else:
+ params = init_params_default_value
+
+ file = await sync_to_async(lambda t=tool: QuerySet(File).filter(id=t.code).first())()
+ if not file:
+ continue
+ file_bytes = await sync_to_async(file.get_bytes)()
+
+ with zipfile.ZipFile(io.BytesIO(file_bytes), "r") as zip_ref:
+ members = [m for m in zip_ref.namelist() if not m.startswith("__MACOSX/") and "__MACOSX" not in m]
+ for member in members:
+ if ".." in member or member.startswith("/"):
+ raise ValueError(f"非法路径: {member}")
+ zip_ref.extractall(skills_dir, members=members)
+
+ # 获取技能解压后的顶级目录名
+ top_level_dirs = set()
+ for member in members:
+ parts = member.split("/")
+ if parts[0]:
+ top_level_dirs.add(parts[0])
+
+ # 将 params 写入每个顶级目录下的 .env 文件
+ if params:
+ env_lines = [f"{key}={value}" for key, value in params.items()]
+ env_content = "\n".join(env_lines) + "\n"
+ for top_dir in top_level_dirs:
+ env_path = os.path.join(skills_dir, top_dir, ".env")
+ with open(env_path, "w", encoding="utf-8") as f:
+ f.write(env_content)
+
+ os.system("chmod -R g+rx " + temp_dir) # 确保技能目录可访问
diff --git a/apps/application/workflow/nodes/ai_chat_node/tools/tool/__init__.py b/apps/application/workflow/nodes/ai_chat_node/tools/tool/__init__.py
new file mode 100644
index 00000000000..0138298dbd0
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/tools/tool/__init__.py
@@ -0,0 +1,26 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@date: 2026/9/15
+@desc:
+"""
+
+from .custom import get_custom_tools
+from .workflow import get_workflow_tools
+
+__all__ = ["get_tool_tools", "get_workflow_tools", "get_custom_tools"]
+
+
+def get_tool_tools(source_type, source_id, tool_ids, workspace_id, workflow_params=None):
+ """
+ 构建工具(Tool)类工具:内部按 tool_type 拆分 workflow / custom,合并返回 LangChain tools。
+
+ 节点只需传入混合的 tool_ids,各构建器各自按 tool_type 过滤。
+ """
+ if not tool_ids:
+ return []
+ return get_workflow_tools(source_type, source_id, tool_ids, workspace_id, workflow_params) + get_custom_tools(
+ source_type, source_id, tool_ids, workspace_id
+ )
diff --git a/apps/application/workflow/nodes/ai_chat_node/tools/tool/custom.py b/apps/application/workflow/nodes/ai_chat_node/tools/tool/custom.py
new file mode 100644
index 00000000000..a665f4b132a
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/tools/tool/custom.py
@@ -0,0 +1,117 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: custom.py
+@date: 2026/9/15
+@desc:
+"""
+
+import json
+import time
+
+import uuid_utils.compat as uuid
+from django.db.models import QuerySet
+from langchain_core.tools import StructuredTool
+from pydantic import Field
+
+from knowledge.models.knowledge_action import State
+from tools.models import Tool, ToolRecord, ToolType
+
+from ..base import build_schema, get_type
+
+
+def get_custom_args(tool):
+ """
+ 从 CUSTOM 工具的 input_field_list 显式构建给模型的 args_schema。
+
+ input_field_list 项结构:{name, is_required, type(string|int|dict|array|float), source}
+ """
+ input_field_list = tool.input_field_list or []
+ return build_schema(
+ {
+ field.get("name"): (
+ get_type(field.get("type")),
+ Field(..., required=True, description=field.get("desc"))
+ if field.get("is_required")
+ else Field(default=None, required=False, description=field.get("desc")),
+ )
+ for field in input_field_list
+ }
+ )
+
+
+def _save_custom_tool_record(tool_id, workspace_id, source_type, source_id, input_params, output, start_time, error):
+ """
+ CUSTOM 工具执行结束后落库执行记录(替代原 MCP 路径的 save_tool_record)。
+
+ input 仅记录业务入参(不含 init 参数),避免密文/密钥进入记录。
+ """
+ state = State.FAILURE if error else State.SUCCESS
+ ToolRecord(
+ id=uuid.uuid7(),
+ tool_id=tool_id,
+ workspace_id=workspace_id,
+ source_type=source_type,
+ source_id=source_id,
+ state=state,
+ run_time=time.time() - start_time,
+ meta={
+ "input": input_params,
+ "output": str(error) if error else output,
+ },
+ ).save()
+
+
+def get_custom_func(source_type, source_id, tool, workspace_id):
+ tool_id = tool.id
+ code = tool.code
+ init_field_list = tool.init_field_list or []
+ init_params_ciphertext = tool.init_params
+
+ def inner(**kwargs):
+ # 在进程内直接跑沙箱代码(无 MCP 子进程),方式与工具调试执行 ToolExecutor.exec_code 一致。
+ from common.utils.rsa_util import rsa_long_decrypt
+ from common.utils.tool_code import ToolExecutor
+
+ start_time = time.time()
+ # 合并初始化参数(默认值 → 已保存的启动参数),服务端注入,模型不可见
+ init_params_default_value = {i["field"]: i.get("default_value") for i in init_field_list}
+ if init_params_ciphertext is not None:
+ init_params = init_params_default_value | json.loads(rsa_long_decrypt(init_params_ciphertext))
+ else:
+ init_params = init_params_default_value
+ all_params = init_params | kwargs
+
+ error = None
+ result = None
+ try:
+ result = ToolExecutor().exec_code(code, all_params)
+ except Exception as e:
+ error = e
+ finally:
+ _save_custom_tool_record(tool_id, workspace_id, source_type, source_id, kwargs, result, start_time, error)
+ if error:
+ raise error
+ return result
+
+ return inner
+
+
+def get_custom_tools(source_type, source_id, tool_ids, workspace_id):
+ if not tool_ids:
+ return []
+ tools = QuerySet(Tool).filter(id__in=tool_ids, is_active=True, tool_type=ToolType.CUSTOM)
+ results = []
+ for tool in tools:
+ func = get_custom_func(source_type, source_id, tool, workspace_id)
+ args = get_custom_args(tool)
+ structured_tool = StructuredTool.from_function(
+ func=func,
+ name=tool.name,
+ description=tool.desc,
+ args_schema=args,
+ )
+ results.append(structured_tool)
+
+ return results
diff --git a/apps/application/workflow/nodes/ai_chat_node/tools/tool/workflow.py b/apps/application/workflow/nodes/ai_chat_node/tools/tool/workflow.py
new file mode 100644
index 00000000000..e70fc25e0fe
--- /dev/null
+++ b/apps/application/workflow/nodes/ai_chat_node/tools/tool/workflow.py
@@ -0,0 +1,186 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: workflow.py
+@date: 2026/9/15
+@desc:
+"""
+
+import threading
+import time
+
+import uuid_utils.compat as uuid
+from django.db.models import OuterRef, QuerySet, Subquery
+from langchain_core.tools import StructuredTool
+from pydantic import Field
+
+from application.workflow.message.aggregator import AggregationManager
+from application.workflow.status import Status
+from knowledge.models.knowledge_action import State
+from tools.models import Tool, ToolRecord, ToolType, ToolWorkflowVersion
+
+from ..base import build_schema, get_type
+
+
+def get_workflow_args(tool, qv):
+ for node in qv.work_flow.get("nodes"):
+ if node.get("type") == "tool-base-node":
+ input_field_list = node.get("properties").get("user_input_field_list")
+ return build_schema(
+ {
+ field.get("field"): (
+ get_type(field.get("type")),
+ Field(..., required=True, description=field.get("desc"))
+ if field.get("is_required")
+ else Field(default=None, required=False, description=field.get("desc")),
+ )
+ for field in input_field_list
+ }
+ )
+
+ return build_schema({})
+
+
+def _save_workflow_tool_record(
+ tool_record_id, tool_id, workspace_id, source_type, source_id, wf_manage, aggregation, parameters, start_time, error
+):
+ """
+ 工具工作流执行结束后落库执行记录(替代旧引擎 ToolWorkflowPostHandler.handler)。
+ 实实行(非调试)直接插入 ToolRecord,字段与工具记录查询端点保持一致。
+ """
+ workflow = wf_manage.workflow
+ base_node = workflow.get_node("tool-base-node")
+ input_field_list = base_node.properties.get("user_input_field_list", []) if base_node else []
+ output_field_list = base_node.properties.get("user_output_field_list", []) if base_node else []
+ input_data = {f.get("field"): parameters.get(f.get("field")) for f in input_field_list}
+ # 新引擎工具输出统一收口于全局 output 上下文(tool-start-node 初始化、变量赋值节点写入)
+ output = wf_manage.context.get("output", {})
+ details = wf_manage.get_details()
+ if error:
+ state = State.FAILURE
+ else:
+ has_fail = any((d or {}).get("status") == Status.FAIL.value for d in (details or []))
+ state = State.FAILURE if has_fail else State.SUCCESS
+ ToolRecord(
+ id=tool_record_id,
+ tool_id=tool_id,
+ workspace_id=workspace_id,
+ source_type=source_type,
+ source_id=source_id,
+ state=state,
+ run_time=time.time() - start_time,
+ meta={
+ "input_field_list": input_field_list,
+ "output_field_list": output_field_list,
+ "input": input_data,
+ "output": output,
+ "details": details,
+ "answer_text_list": aggregation.get_contents(),
+ },
+ ).save()
+
+
+def get_workflow_func(source_type, source_id, tool, qv, workspace_id, workflow_params=None):
+ from knowledge.services.retrieval_access import inherited_retrieval_context
+
+ tool_id = tool.id
+
+ def inner(**kwargs):
+ # 使用新工作流引擎执行工具工作流,方式与 tool_workflow_lib_node 保持一致。
+ from application.workflow.common import WorkflowType, new_instance
+ from application.workflow.nodes import get_node_class
+ from application.workflow.workflow_manage import CallBack, WorkflowManage
+
+ tool_record_id = str(uuid.uuid7())
+ sub_workflow = new_instance(qv.work_flow, WorkflowType.TOOL)
+ start_time = time.time()
+ sub_parameters = {
+ "chat_record_id": tool_record_id,
+ "tool_id": str(tool_id),
+ "stream": True,
+ "workspace_id": workspace_id,
+ "default_model_setting": qv.default_model_setting or {},
+ **kwargs,
+ **inherited_retrieval_context({"workspace_id": workspace_id, **(workflow_params or {})}),
+ }
+
+ # WorkflowManage.run() 在后台线程异步执行节点,完成时机由 on_complete 回调驱动,
+ # 而 inner 作为 LangChain 同步工具函数必须阻塞到子工作流结束再返回其输出。
+ aggregation = AggregationManager()
+ done_event = threading.Event()
+ result_holder = {"output": {}, "error": None}
+
+ def on_next(wf_manage, content):
+ # 逐块聚合,用于执行记录的 answer_text_list(不直接转发给上游)
+ aggregation.aggregate(content)
+
+ def on_complete(wf_manage, error):
+ try:
+ # 工具工作流输出统一写入 context['output']
+ result_holder["output"] = dict(wf_manage.context.get("output", {}) or {})
+ # 执行结束落库工具执行记录
+ _save_workflow_tool_record(
+ tool_record_id,
+ tool_id,
+ workspace_id,
+ source_type,
+ source_id,
+ wf_manage,
+ aggregation,
+ sub_parameters,
+ start_time,
+ error,
+ )
+ finally:
+ result_holder["error"] = error
+ done_event.set()
+
+ call_back = CallBack(on_next, on_complete)
+
+ def get_start_node_fn(wf, wm):
+ start_node = wf.get_node("tool-start-node")
+ node_class = get_node_class("tool-start-node", WorkflowType.TOOL)
+ return node_class(start_node, wm, lambda n: n.properties.get("node_data", {}))
+
+ sub_manage = WorkflowManage(
+ workflow=sub_workflow,
+ parameters=sub_parameters,
+ workflow_type=WorkflowType.TOOL,
+ call_back=call_back,
+ get_start_node=get_start_node_fn,
+ )
+ sub_manage.start_node.workflow_manage = sub_manage
+ sub_manage.run()
+ done_event.wait()
+ if result_holder["error"]:
+ raise result_holder["error"]
+ return result_holder["output"]
+
+ return inner
+
+
+def get_workflow_tools(source_type, source_id, tool_workflow_ids, workspace_id, workflow_params=None):
+ tools = QuerySet(Tool).filter(
+ id__in=tool_workflow_ids, is_active=True, tool_type=ToolType.WORKFLOW, workspace_id=workspace_id
+ )
+ latest_subquery = ToolWorkflowVersion.objects.filter(tool_id=OuterRef("tool_id")).order_by("-create_time")
+
+ qs = ToolWorkflowVersion.objects.filter(
+ tool_id__in=[t.id for t in tools], id=Subquery(latest_subquery.values("id")[:1])
+ )
+ qd = {q.tool_id: q for q in qs}
+ results = []
+ for tool in tools:
+ qv = qd.get(tool.id)
+ func = get_workflow_func(source_type, source_id, tool, qv, workspace_id, workflow_params)
+ args = get_workflow_args(tool, qv)
+ tool = StructuredTool.from_function(
+ func=func,
+ name=tool.name,
+ description=tool.desc,
+ args_schema=args,
+ )
+ results.append(tool)
+
+ return results
diff --git a/apps/application/workflow/nodes/application_node/__init__.py b/apps/application/workflow/nodes/application_node/__init__.py
new file mode 100644
index 00000000000..fb099b7fed4
--- /dev/null
+++ b/apps/application/workflow/nodes/application_node/__init__.py
@@ -0,0 +1,4 @@
+# coding=utf-8
+from .application_node import ApplicationNode
+
+__all__ = ["ApplicationNode"]
diff --git a/apps/application/workflow/nodes/application_node/application_node.py b/apps/application/workflow/nodes/application_node/application_node.py
new file mode 100644
index 00000000000..e94f1d8186d
--- /dev/null
+++ b/apps/application/workflow/nodes/application_node/application_node.py
@@ -0,0 +1,295 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: application_node.py
+@date:2026/9/3
+@desc: 智能体节点
+"""
+
+import uuid_utils.compat as uuid
+from django.db import connection
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.models import Application, ApplicationVersion, Chat, ChatRecord, ChatSourceChoices
+from application.workflow.common import WorkflowType, new_instance
+from application.workflow.content_type import ContentType
+from application.workflow.i_node import INode, Signal
+from application.workflow.message.struct.content import Position
+from application.workflow.status import Status
+from application.workflow.workflow_manage import WorkflowManage, CallBack
+from chat.serializers.chat_history import ChatHistory
+from common.exception.app_exception import AppApiException
+
+
+def string_to_uuid(input_str):
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, input_str))
+
+
+class ApplicationNodeSerializer(serializers.Serializer):
+ application_id = serializers.CharField(required=True, label=_("Application ID"))
+ question_reference_address = serializers.ListField(required=True, label=_("User Questions"))
+ api_input_field_list = serializers.ListField(required=False, label=_("API Input Fields"))
+ user_input_field_list = serializers.ListField(required=False, label=_("User Input Fields"))
+ image_list = serializers.ListField(required=False, label=_("picture"))
+ document_list = serializers.ListField(required=False, label=_("document"))
+ audio_list = serializers.ListField(required=False, label=_("Audio"))
+ video_list = serializers.ListField(required=False, label=_("Video"))
+ node_data = serializers.DictField(required=False, allow_null=True, label=_("Form Data"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ application_id = self.data.get("application_id")
+ f_app = QuerySet(Application).filter(id=application_id).first()
+ if f_app is None:
+ raise AppApiException(500, _("The application has been deleted"))
+
+
+class ApplicationNode(INode):
+ serializer_class = ApplicationNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION]
+ type = "application-node"
+
+ def _run(self):
+ # 完成时机由子应用 on_complete 回调驱动,这里不自动 complete
+ self.execute()
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+
+ application_id = node_params.get("application_id")
+ chat_id = workflow_params.get("chat_id")
+ chat_user_id = workflow_params.get("chat_user_id")
+ chat_user_type = workflow_params.get("chat_user_type")
+ ip_address = workflow_params.get("ip_address") or "-"
+ source = workflow_params.get("source") or {"type": ChatSourceChoices.ONLINE.value}
+ debug = workflow_params.get("debug", False)
+
+ # 父工作流 position 指向本节点 → 子工作流表单提交,需恢复续跑
+ position = workflow_params.get("position") or {}
+ is_submit = position.get("id") == self.get_node_id()
+ sub_chat_record_id = self.get_context("sub_chat_record_id")
+ # 表单暂停点:前端回传的 position 是一个嵌套链(id=本节点, children=子工作流暂停点)。
+ # 恢复时用 position.children 逐级下传,才能把深层锚点(子子工作流表单)完整带下去。
+ sub_position = position.get("children") or self.get_context("sub_position")
+
+ # 自引用守卫
+ if application_id == workflow_params.get("application_id"):
+ raise Exception(_("The sub application cannot use the current node"))
+
+ # 解析用户问题
+ question_address = node_params.get("question_reference_address") or []
+ if question_address:
+ question = self.workflow_manage.get_reference_field(question_address[0], question_address[1:])
+ else:
+ question = ""
+ question = str(question or "")
+ self.write_context("question", question)
+
+ # 解析 api 输入 / 用户输入 → form_data
+ form_data = {}
+ for api_input_field in node_params.get("api_input_field_list", []):
+ value = api_input_field.get("value", [""])[0] if api_input_field.get("value") else ""
+ form_data[api_input_field["variable"]] = (
+ self.workflow_manage.get_reference_field(value, api_input_field["value"][1:]) if value != "" else ""
+ )
+ for user_input_field in node_params.get("user_input_field_list", []):
+ value = user_input_field.get("value", [""])[0] if user_input_field.get("value") else ""
+ form_data[user_input_field["field"]] = (
+ self.workflow_manage.get_reference_field(value, user_input_field["value"][1:]) if value != "" else ""
+ )
+
+ # 解析文件列表(校验 file_id)
+ app_document_list = self._resolve_file_list(node_params.get("document_list", []), "document")
+ app_image_list = self._resolve_file_list(node_params.get("image_list", []), "image")
+ app_audio_list = self._resolve_file_list(node_params.get("audio_list", []), "audio")
+ app_video_list = self._resolve_file_list(node_params.get("video_list", []), "video")
+
+ # 派生子应用聊天 id
+ current_chat_id = string_to_uuid(chat_id + application_id)
+ Chat.objects.get_or_create(
+ id=current_chat_id,
+ defaults={
+ "application_id": application_id,
+ "abstract": question[0:1024],
+ "chat_user_id": chat_user_id,
+ "chat_user_type": chat_user_type,
+ "ip_address": ip_address,
+ "source": source,
+ "asker": self._get_chat_asker(workflow_params),
+ },
+ )
+
+ # 解析子应用工作流(debug 取本体,否则取最新发布版本),与 chat_work_flow 的 get_application 一致
+ if debug:
+ sub_application = QuerySet(Application).filter(id=application_id).first()
+ else:
+ sub_application = (
+ QuerySet(ApplicationVersion).filter(application_id=application_id).order_by("-create_time")[0:1].first()
+ )
+ if sub_application is None:
+ raise Exception(_("The application has not been published. Please use it after publishing."))
+ from chat.serializers.chat import get_work_flow
+
+ sub_workflow = new_instance(get_work_flow(sub_application), WorkflowType.APPLICATION)
+
+ # 首次运行:生成子应用记录 id 并建 ChatRecord;恢复时沿用已持久化的 id
+ if not is_submit:
+ sub_chat_record_id = str(uuid.uuid7())
+ self.write_context("sub_chat_record_id", sub_chat_record_id)
+ self.write_context("sub_position", sub_position)
+ QuerySet(ChatRecord).create(
+ id=sub_chat_record_id,
+ chat_id=current_chat_id,
+ problem_text=question[0:1024],
+ answer_text="",
+ details={},
+ message_tokens=0,
+ answer_tokens=0,
+ answer_text_list=[[]],
+ index=0,
+ ip_address=ip_address or "",
+ source=source,
+ workflow_context={},
+ question={"content": question},
+ messages=[],
+ )
+ sub_position = None
+
+ # 组装子应用参数(复制父工作流参数并覆盖子应用相关字段)
+ sub_parameters = dict(workflow_params)
+ sub_parameters.update(
+ {
+ "chat_id": current_chat_id,
+ "chat_record_id": sub_chat_record_id,
+ "application_id": application_id,
+ "question": question,
+ "stream": True,
+ "form_data": workflow_params.get("form_data") if is_submit else form_data,
+ "position": sub_position if is_submit else None,
+ "chunk_id": workflow_params.get("chunk_id"),
+ "history_chat_record": ChatHistory(current_chat_id).load(exclude_record_id=sub_chat_record_id),
+ "image_list": app_image_list,
+ "document_list": app_document_list,
+ "audio_list": app_audio_list,
+ "video_list": app_video_list,
+ "default_model_setting": sub_application.default_model_setting or {},
+ }
+ )
+
+ # 内联回调:转发子应用输出、嵌套 position、传播表单暂停信号
+ self._answer = ""
+ self._reasoning_content = ""
+
+ def on_next(wf_manage, content):
+ if content.type == ContentType.FORM:
+ # 已提交表单的回显块不转发,避免前端出现重复的已填表单
+ if content.is_submit:
+ return
+ # 记录子工作流表单节点位置(保留嵌套链),供父工作流恢复时透传回子工作流续跑
+ self.write_context("sub_position", content.position.to_dict())
+ content.position = Position(self.get_node_id(), None, content.position)
+ self.write(content)
+ return
+ content.position = Position(self.get_node_id(), None, content.position)
+ if content.type == ContentType.TEXT:
+ self._answer += content.content
+ elif content.type == ContentType.REASONING:
+ self._reasoning_content += content.content
+ self.write(content)
+
+ def on_complete(wf_manage, error):
+ # 持久化子应用上下文,供后续 resume 的 from_context 读取
+ QuerySet(ChatRecord).filter(id=sub_chat_record_id).update(workflow_context=wf_manage.context)
+ usage = self._usage_from_context(wf_manage.context)
+ self._write_final_context(self._answer, self._reasoning_content, usage)
+ if error:
+ self.complete(Status.FAIL, error=error)
+ return
+ # 子应用命中表单(Signal.FORM):向上传播中断,暂停父工作流,等用户提交后恢复
+ if wf_manage.signal == Signal.FORM:
+ self.complete(Status.SUCCESS, signal=Signal.FORM)
+ return
+ self.complete(Status.SUCCESS)
+
+ call_back = CallBack(on_next, on_complete)
+
+ def get_start_node_fn(wf, wm):
+ from application.workflow.nodes import get_start_node
+
+ return get_start_node(wf, wm, WorkflowType.APPLICATION, sub_position if is_submit else None)
+
+ # 表单提交:从历史 context 恢复子应用;否则全新运行
+ if is_submit:
+ from application.serializers.common import load_debug_workflow_context
+
+ sub_manage = WorkflowManage.from_context(
+ get_context=lambda: load_debug_workflow_context(sub_chat_record_id),
+ workflow=sub_workflow,
+ parameters=sub_parameters,
+ workflow_type=WorkflowType.APPLICATION,
+ call_back=call_back,
+ get_start_node=get_start_node_fn,
+ )
+ if sub_manage is None:
+ sub_manage = WorkflowManage(
+ sub_workflow, sub_parameters, WorkflowType.APPLICATION, call_back, get_start_node_fn
+ )
+ else:
+ sub_manage = WorkflowManage(
+ sub_workflow, sub_parameters, WorkflowType.APPLICATION, call_back, get_start_node_fn
+ )
+
+ sub_manage.start_node.workflow_manage = sub_manage
+ sub_manage.run()
+
+ def _resolve_file_list(self, field_list, name):
+ if not field_list or len(field_list) == 0:
+ return []
+ values = self.workflow_manage.get_reference_field(field_list[0], field_list[1:]) or []
+ for item in values:
+ if "file_id" not in item:
+ raise ValueError(
+ _("Parameter value error: The uploaded {name} lacks file_id, and the {name} upload fails").format(
+ name=name
+ )
+ )
+ return list(values)
+
+ def _get_chat_asker(self, workflow_params):
+ asker = (workflow_params.get("form_data") or {}).get("asker")
+ if asker:
+ return asker if isinstance(asker, dict) else {"username": asker}
+ return workflow_params.get("chat_user")
+
+ @staticmethod
+ def _usage_from_context(workflow_context):
+ prompt_tokens = 0
+ completion_tokens = 0
+ for node_context in (workflow_context or {}).values():
+ if isinstance(node_context, dict):
+ prompt_tokens += node_context.get("message_tokens", 0) or 0
+ completion_tokens += node_context.get("answer_tokens", 0) or 0
+ return {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}
+
+ def _write_final_context(self, answer, reasoning_content, usage):
+ self.write_context("answer", answer)
+ self.write_context("result", answer)
+ self.write_context("reasoning_content", reasoning_content)
+ self.write_context("message_tokens", usage.get("prompt_tokens", 0))
+ self.write_context("answer_tokens", usage.get("completion_tokens", 0))
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "answer": self.get_context("answer"),
+ "reasoning_content": self.get_context("reasoning_content"),
+ "message_tokens": self.get_context("message_tokens"),
+ "answer_tokens": self.get_context("answer_tokens"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/condition_node/__init__.py b/apps/application/workflow/nodes/condition_node/__init__.py
new file mode 100644
index 00000000000..c1c44f4f7ca
--- /dev/null
+++ b/apps/application/workflow/nodes/condition_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/7/2 10:00
+ @desc:
+"""
+from .condition_node import ConditionNode
diff --git a/apps/application/workflow/nodes/condition_node/condition_node.py b/apps/application/workflow/nodes/condition_node/condition_node.py
new file mode 100644
index 00000000000..d04b1fddcd1
--- /dev/null
+++ b/apps/application/workflow/nodes/condition_node/condition_node.py
@@ -0,0 +1,72 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: condition_node.py
+@date:2026/7/2 10:00
+@desc:
+"""
+
+from typing import List
+
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.compare import do_assertion
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.status import Status
+
+
+class ConditionSerializer(serializers.Serializer):
+ compare = serializers.CharField(required=True, label=_("Comparator"))
+ value = serializers.CharField(required=True, label=_("value"))
+ field = serializers.ListField(required=True, label=_("Fields"))
+
+
+class ConditionBranchSerializer(serializers.Serializer):
+ id = serializers.CharField(required=True, label=_("Branch id"))
+ type = serializers.CharField(required=True, label=_("Branch Type"))
+ condition = serializers.CharField(required=True, label=_("Condition or|and"))
+ conditions = ConditionSerializer(many=True)
+
+
+class ConditionNodeSerializer(serializers.Serializer):
+ branch = ConditionBranchSerializer(many=True)
+
+
+class ConditionNode(INode):
+ serializer_class = ConditionNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "condition-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ branch_list = node_params.get("branch", [])
+ branch = self._evaluate_branches(branch_list)
+ branch_id = branch.get("id")
+ branch_name = branch.get("type")
+
+ self.write_context("branch_id", branch_id)
+ self.write_context("branch_name", branch_name)
+
+ self.complete(Status.SUCCESS, [self.branch_anchor(branch_id)])
+
+ def _evaluate_branches(self, branch_list: List):
+ for branch in branch_list:
+ if self._branch_assertion(branch):
+ return branch
+ return branch_list[-1] if branch_list else {}
+
+ def _branch_assertion(self, branch):
+ return do_assertion(self.workflow_manage, branch.get("condition"), branch.get("conditions"))
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "branch_id": self.get_context("branch_id"),
+ "branch_name": self.get_context("branch_name"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/data_source_local_node/__init__.py b/apps/application/workflow/nodes/data_source_local_node/__init__.py
new file mode 100644
index 00000000000..dc72f858db7
--- /dev/null
+++ b/apps/application/workflow/nodes/data_source_local_node/__init__.py
@@ -0,0 +1,10 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@date: 2026/9/11
+@desc: 本地文件数据源节点(知识库工作流起始节点之一)
+"""
+
+from .data_source_local_node import DataSourceLocalNode
diff --git a/apps/application/workflow/nodes/data_source_local_node/data_source_local_node.py b/apps/application/workflow/nodes/data_source_local_node/data_source_local_node.py
new file mode 100644
index 00000000000..8d55aff2f27
--- /dev/null
+++ b/apps/application/workflow/nodes/data_source_local_node/data_source_local_node.py
@@ -0,0 +1,59 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: data_source_local_node.py
+@date: 2026/9/11
+@desc: 本地文件数据源节点:知识库工作流的起始节点之一,把上传的文件列表写入节点输出供下游读取
+"""
+
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+
+
+class DataSourceLocalNodeParamsSerializer(serializers.Serializer):
+ file_type_list = serializers.ListField(child=serializers.CharField(label=_("")), label=_(""))
+ file_size_limit = serializers.IntegerField(required=True, label=_("Upload file size"))
+ file_count_limit = serializers.IntegerField(required=True, label=_("Number of uploaded files"))
+
+
+class DataSourceLocalNode(INode):
+ serializer_class = DataSourceLocalNodeParamsSerializer
+ supported_workflow_type_list = [WorkflowType.KNOWLEDGE]
+ type = "data-source-local-node"
+
+ @staticmethod
+ def get_form_list(node):
+ node_data = node.get("properties").get("node_data")
+ return [
+ {
+ "field": "file_list",
+ "input_type": "LocalFileUpload",
+ "attrs": {
+ "file_count_limit": node_data.get("file_count_limit") or 10,
+ "file_size_limit": node_data.get("file_size_limit") or 100,
+ "file_type_list": node_data.get("file_type_list"),
+ },
+ "label": "",
+ }
+ ]
+
+ def execute(self):
+ # 文件列表来自工作流入参 data_source.file_list,写入本节点输出供下游节点引用
+ workflow_params = self.get_workflow_parameters()
+ file_list = (workflow_params.get("data_source") or {}).get("file_list")
+ self.write_context("file_list", file_list)
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "file_list": self.get_context("file_list"),
+ "knowledge_base": self.get_workflow_parameters().get("knowledge_base"),
+ "enableException": self.node.properties.get("enableException"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/data_source_web_node/__init__.py b/apps/application/workflow/nodes/data_source_web_node/__init__.py
new file mode 100644
index 00000000000..2887a81f640
--- /dev/null
+++ b/apps/application/workflow/nodes/data_source_web_node/__init__.py
@@ -0,0 +1,10 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@date: 2026/9/16
+@desc: Web 站点数据源节点(知识库工作流起始节点之一)
+"""
+
+from .data_source_web_node import DataSourceWebNode
diff --git a/apps/application/workflow/nodes/data_source_web_node/data_source_web_node.py b/apps/application/workflow/nodes/data_source_web_node/data_source_web_node.py
new file mode 100644
index 00000000000..f6c7ab0a444
--- /dev/null
+++ b/apps/application/workflow/nodes/data_source_web_node/data_source_web_node.py
@@ -0,0 +1,107 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: data_source_web_node.py
+@date: 2026/9/16
+@desc: Web 站点数据源节点:知识库工作流的起始节点之一,按根地址抓取站点内容写入 document_list 供下游读取
+"""
+
+import traceback
+
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import CancelledException, INode
+from common.utils.fork import ChildLink, Fork, ForkManage
+from common.utils.logger import maxkb_logger
+
+
+class DataSourceWebNodeParamsSerializer(serializers.Serializer):
+ source_url = serializers.CharField(required=True, label=_("Web source url"))
+ selector = serializers.CharField(
+ required=False, allow_blank=True, allow_null=True, label=_("Web knowledge selector")
+ )
+
+
+class DataSourceWebNode(INode):
+ serializer_class = DataSourceWebNodeParamsSerializer
+ supported_workflow_type_list = [WorkflowType.KNOWLEDGE]
+ type = "data-source-web-node"
+
+ @staticmethod
+ def get_form_list(node):
+ return [
+ {
+ "field": "source_url",
+ "input_type": "TextInput",
+ "attrs": {"placeholder": _("Please enter the Web root address")},
+ "label": _("Web source url"),
+ "required": True,
+ },
+ {
+ "field": "selector",
+ "input_type": "TextInput",
+ "attrs": {"placeholder": _("The default is body, you can enter .classname/#idname/tagname")},
+ "label": _("Web knowledge selector"),
+ "required": False,
+ },
+ ]
+
+ def _get_collect_handler(self, document_list):
+ def handler(child_link: ChildLink, response: Fork.Response):
+ if response.status == 200:
+ try:
+ document_name = (
+ child_link.tag.text
+ if child_link.tag is not None and len(child_link.tag.text.strip()) > 0
+ else child_link.url
+ )
+ document_list.append({"name": document_name.strip(), "content": response.content})
+ except Exception as e:
+ maxkb_logger.error(f"{str(e)}:{traceback.format_exc()}")
+ # 已取消则抛出 CancelledException,由引擎结束流程
+ self._check_cancelled()
+
+ return handler
+
+ def execute(self):
+ workflow_params = self.get_workflow_parameters()
+ data_source = workflow_params.get("data_source") or {}
+
+ serializer = self.serializer_class(data=data_source)
+ serializer.is_valid(raise_exception=True)
+ source_url = serializer.validated_data.get("source_url")
+ selector = serializer.validated_data.get("selector") or "body"
+
+ document_list = []
+ collect_handler = self._get_collect_handler(document_list)
+
+ try:
+ ForkManage(source_url, selector.split(" ") if selector else []).fork(3, set(), collect_handler)
+ except CancelledException:
+ raise
+ except Exception as e:
+ maxkb_logger.error(
+ _("data source web node:{node_id} error{error}{traceback}").format(
+ node_id=self.get_node_id(), error=str(e), traceback=traceback.format_exc()
+ )
+ )
+
+ self.write_context("document_list", document_list)
+ self.write_context("source_url", source_url)
+ self.write_context("selector", selector)
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "document_list": self.get_context("document_list"),
+ "source_url": self.get_context("source_url"),
+ "selector": self.get_context("selector"),
+ "knowledge_base": self.get_workflow_parameters().get("knowledge_base"),
+ "enableException": self.node.properties.get("enableException"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/document_extract_node/__init__.py b/apps/application/workflow/nodes/document_extract_node/__init__.py
new file mode 100644
index 00000000000..ec08b854e4d
--- /dev/null
+++ b/apps/application/workflow/nodes/document_extract_node/__init__.py
@@ -0,0 +1,10 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@date: 2026/9/11
+@desc: 文档内容提取节点
+"""
+
+from .document_extract_node import DocumentExtractNode
diff --git a/apps/application/workflow/nodes/document_extract_node/document_extract_node.py b/apps/application/workflow/nodes/document_extract_node/document_extract_node.py
new file mode 100644
index 00000000000..225fdb7e18a
--- /dev/null
+++ b/apps/application/workflow/nodes/document_extract_node/document_extract_node.py
@@ -0,0 +1,119 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: document_extract_node.py
+@date: 2026/9/11
+@desc: 文档内容提取节点:把引用到的文件解析为文本内容,并保存文档内嵌图片
+"""
+
+import io
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from knowledge.models import File, FileSourceType
+from knowledge.serializers.document import FileBufferHandle, parse_table_handle_list, split_handles
+
+splitter = "\n`-----------------------------------`\n"
+
+
+class DocumentExtractNodeSerializer(serializers.Serializer):
+ document_list = serializers.ListField(required=False, label=_("document"))
+
+
+class DocumentExtractNode(INode):
+ serializer_class = DocumentExtractNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "document-extract-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+ document_reference = node_params.get("document_list") or []
+ document = (
+ self.workflow_manage.get_reference_field(document_reference[0], document_reference[1:])
+ if document_reference
+ else None
+ )
+ chat_id = workflow_params.get("chat_id")
+
+ self.write_context("document_list", document)
+ if document is None or not isinstance(document, list):
+ self.write_context("content", "")
+ self.write_context("document_list", [])
+ return
+
+ # 按工作流类型确定归属资源 id(知识库/应用/工具),均取自工作流入参
+ application_id = None
+ tool_id = None
+ knowledge_id = None
+ workflow_type = self.get_workflow_type()
+ if workflow_type == WorkflowType.KNOWLEDGE:
+ knowledge_id = workflow_params.get("knowledge_id")
+ elif workflow_type == WorkflowType.APPLICATION:
+ application_id = workflow_params.get("application_id")
+ elif workflow_type == WorkflowType.TOOL:
+ tool_id = workflow_params.get("tool_id")
+
+ # doc 文件中内嵌的图片另存为文件
+ def save_image(image_list):
+ for image in image_list:
+ meta = {
+ "debug": False if (application_id or knowledge_id or tool_id) else True,
+ "chat_id": chat_id,
+ "application_id": str(application_id) if application_id else None,
+ "knowledge_id": str(knowledge_id) if knowledge_id else None,
+ "tool_id": str(tool_id) if tool_id else None,
+ "file_id": str(image.id),
+ }
+ file_bytes = image.meta.pop("content")
+ new_file = File(
+ id=meta["file_id"],
+ file_name=image.file_name,
+ file_size=len(file_bytes),
+ source_type=FileSourceType.APPLICATION.value
+ if application_id
+ else FileSourceType.KNOWLEDGE.value
+ if knowledge_id
+ else FileSourceType.TOOL.value,
+ source_id=application_id or knowledge_id or tool_id,
+ meta=meta,
+ )
+ if not QuerySet(File).filter(id=new_file.id).exists():
+ new_file.save(file_bytes)
+
+ get_buffer = FileBufferHandle().get_buffer
+ content = []
+ document_list = []
+ for doc in document:
+ file = QuerySet(File).filter(id=doc["file_id"]).first()
+ buffer = io.BytesIO(file.get_bytes())
+ buffer.name = doc["name"] # this is the important line
+
+ for split_handle in parse_table_handle_list + split_handles:
+ if split_handle.support(buffer, get_buffer):
+ buffer.seek(0)
+ file_content = split_handle.get_content(buffer, save_image)
+ content.append("### " + doc["name"] + "\n" + file_content)
+ document_list.append({"id": str(file.id), "name": doc["name"], "content": file_content})
+ break
+
+ self.write_context("content", splitter.join(content))
+ self.write_context("document_list", document_list)
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ content = (self.get_context("content") or "").split(splitter)
+ details.update(
+ {
+ # 不保存 content 全部内容,因为 content 可能非常大
+ "content": [file_content[:500] for file_content in content],
+ "document_list": self.get_context("document_list"),
+ "enableException": self.node.properties.get("enableException"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/document_split_node/__init__.py b/apps/application/workflow/nodes/document_split_node/__init__.py
new file mode 100644
index 00000000000..fb048472596
--- /dev/null
+++ b/apps/application/workflow/nodes/document_split_node/__init__.py
@@ -0,0 +1,10 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@date: 2026/9/11
+@desc: 文档分段节点
+"""
+
+from .document_split_node import DocumentSplitNode
diff --git a/apps/application/workflow/nodes/document_split_node/document_split_node.py b/apps/application/workflow/nodes/document_split_node/document_split_node.py
new file mode 100644
index 00000000000..89d11721ac6
--- /dev/null
+++ b/apps/application/workflow/nodes/document_split_node/document_split_node.py
@@ -0,0 +1,311 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: document_split_node.py
+@date: 2026/9/11
+@desc: 文档分段节点:把提取出的文档内容按策略切分为段落,供知识库写入
+"""
+
+import io
+import mimetypes
+from typing import List
+
+from django.core.files.uploadedfile import InMemoryUploadedFile
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from common.chunk import text_to_chunk
+from knowledge.serializers.document import FileBufferHandle, default_split_handle, md_qa_split_handle
+
+
+class DocumentSplitNodeSerializer(serializers.Serializer):
+ document_list = serializers.ListField(required=False, label=_("document list"))
+ split_strategy = serializers.ChoiceField(
+ choices=["auto", "custom", "qa"], required=False, label=_("split strategy"), default="auto"
+ )
+ paragraph_title_relate_problem_type = serializers.ChoiceField(
+ choices=["custom", "referencing"],
+ required=False,
+ label=_("paragraph title relate problem type"),
+ default="custom",
+ )
+ paragraph_title_relate_problem = serializers.BooleanField(
+ required=False, label=_("paragraph title relate problem"), default=False
+ )
+ paragraph_title_relate_problem_reference = serializers.ListField(
+ required=False, label=_("paragraph title relate problem reference"), child=serializers.CharField(), default=[]
+ )
+ document_name_relate_problem_type = serializers.ChoiceField(
+ choices=["custom", "referencing"],
+ required=False,
+ label=_("document name relate problem type"),
+ default="custom",
+ )
+ document_name_relate_problem = serializers.BooleanField(
+ required=False, label=_("document name relate problem"), default=False
+ )
+ document_name_relate_problem_reference = serializers.ListField(
+ required=False, label=_("document name relate problem reference"), child=serializers.CharField(), default=[]
+ )
+ limit = serializers.IntegerField(required=False, label=_("limit"), default=4096)
+ limit_type = serializers.ChoiceField(
+ choices=["custom", "referencing"],
+ required=False,
+ label=_("document name relate problem type"),
+ default="custom",
+ )
+ limit_reference = serializers.ListField(
+ required=False, label=_("limit reference"), child=serializers.CharField(), default=[]
+ )
+ chunk_size = serializers.IntegerField(required=False, label=_("chunk size"), default=256)
+ chunk_size_type = serializers.ChoiceField(
+ choices=["custom", "referencing"], required=False, label=_("chunk size type"), default="custom"
+ )
+ chunk_size_reference = serializers.ListField(
+ required=False, label=_("chunk size reference"), child=serializers.CharField(), default=[]
+ )
+ patterns = serializers.ListField(required=False, label=_("patterns"), child=serializers.CharField(), default=[])
+ patterns_type = serializers.ChoiceField(
+ choices=["custom", "referencing"], required=False, label=_("patterns type"), default="custom"
+ )
+ patterns_reference = serializers.ListField(
+ required=False, label=_("patterns reference"), child=serializers.CharField(), default=[]
+ )
+ with_filter = serializers.BooleanField(required=False, label=_("with filter"), default=False)
+ with_filter_type = serializers.ChoiceField(
+ choices=["custom", "referencing"], required=False, label=_("with filter type"), default="custom"
+ )
+ with_filter_reference = serializers.ListField(
+ required=False, label=_("with filter reference"), child=serializers.CharField(), default=[]
+ )
+
+
+def bytes_to_uploaded_file(file_bytes, file_name="file.txt"):
+ if file_name.startswith("http"):
+ file_name = "file.txt"
+ content_type, _unused = mimetypes.guess_type(file_name)
+ if content_type is None:
+ # 如果未能识别,设置为默认的二进制文件类型
+ content_type = "application/octet-stream"
+ # 创建一个内存中的字节流对象
+ file_stream = io.BytesIO(file_bytes)
+ # 获取文件大小
+ file_size = len(file_bytes)
+ # 创建 InMemoryUploadedFile 对象
+ uploaded_file = InMemoryUploadedFile(
+ file=file_stream,
+ field_name=None,
+ name=file_name,
+ content_type=content_type,
+ size=file_size,
+ charset=None,
+ )
+ return uploaded_file
+
+
+class DocumentSplitNode(INode):
+ serializer_class = DocumentSplitNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "document-split-node"
+
+ def get_reference_content(self, fields: List[str]):
+ return self.workflow_manage.get_reference_field(fields[0], fields[1:]) if fields else None
+
+ def execute(self):
+ # 通过 serializer 应用默认值(新引擎不会对 node_data 自动校验/填默认)
+ serializer = DocumentSplitNodeSerializer(data=self.get_parameters())
+ serializer.is_valid(raise_exception=True)
+ params = serializer.data
+
+ knowledge_id = (
+ self.get_workflow_parameters().get("knowledge_id")
+ if self.get_workflow_type() == WorkflowType.KNOWLEDGE
+ else None
+ )
+
+ document_list = params.get("document_list")
+ split_strategy = params.get("split_strategy")
+ paragraph_title_relate_problem_type = params.get("paragraph_title_relate_problem_type")
+ paragraph_title_relate_problem = params.get("paragraph_title_relate_problem")
+ paragraph_title_relate_problem_reference = params.get("paragraph_title_relate_problem_reference")
+ document_name_relate_problem_type = params.get("document_name_relate_problem_type")
+ document_name_relate_problem = params.get("document_name_relate_problem")
+ document_name_relate_problem_reference = params.get("document_name_relate_problem_reference")
+ limit = params.get("limit")
+ limit_type = params.get("limit_type")
+ limit_reference = params.get("limit_reference")
+ chunk_size = params.get("chunk_size")
+ chunk_size_type = params.get("chunk_size_type")
+ chunk_size_reference = params.get("chunk_size_reference")
+ patterns = params.get("patterns")
+ patterns_type = params.get("patterns_type")
+ patterns_reference = params.get("patterns_reference")
+ with_filter = params.get("with_filter")
+ with_filter_type = params.get("with_filter_type")
+ with_filter_reference = params.get("with_filter_reference")
+
+ self.write_context("knowledge_id", knowledge_id)
+ file_list = self.get_reference_content(document_list)
+
+ # 处理引用类型的参数
+ if patterns_type == "referencing":
+ patterns = self.get_reference_content(patterns_reference)
+ if limit_type == "referencing":
+ limit = self.get_reference_content(limit_reference)
+ if chunk_size_type == "referencing":
+ chunk_size = self.get_reference_content(chunk_size_reference)
+ if with_filter_type == "referencing":
+ with_filter = self.get_reference_content(with_filter_reference)
+
+ paragraph_list = []
+ for doc in file_list:
+ get_buffer = FileBufferHandle().get_buffer
+
+ file_mem = bytes_to_uploaded_file(doc["content"].encode("utf-8"), doc["name"])
+ if split_strategy == "qa":
+ result = md_qa_split_handle.handle(file_mem, get_buffer, self._save_image)
+ else:
+ result = default_split_handle.handle(
+ file_mem, patterns, with_filter, limit, get_buffer, self._save_image
+ )
+ # 统一处理结果为列表
+ results = result if isinstance(result, list) else [result]
+
+ for item in results:
+ self._process_split_result(
+ item,
+ knowledge_id,
+ doc.get("id"),
+ doc.get("name"),
+ split_strategy,
+ paragraph_title_relate_problem_type,
+ paragraph_title_relate_problem,
+ paragraph_title_relate_problem_reference,
+ document_name_relate_problem_type,
+ document_name_relate_problem,
+ document_name_relate_problem_reference,
+ chunk_size,
+ )
+
+ paragraph_list += results
+
+ self.write_context("paragraph_list", paragraph_list)
+ self.write_context("document_list", file_list)
+ self.write_context("limit", limit)
+ self.write_context("chunk_size", chunk_size)
+ self.write_context("with_filter", with_filter)
+ self.write_context("patterns", patterns)
+ self.write_context("split_strategy", split_strategy)
+
+ def _save_image(self, image_list):
+ pass
+
+ def _process_split_result(
+ self,
+ item,
+ knowledge_id,
+ source_file_id,
+ file_name,
+ split_strategy,
+ paragraph_title_relate_problem_type,
+ paragraph_title_relate_problem,
+ paragraph_title_relate_problem_reference,
+ document_name_relate_problem_type,
+ document_name_relate_problem,
+ document_name_relate_problem_reference,
+ chunk_size,
+ ):
+ """处理文档分割结果"""
+ item["meta"] = {
+ "knowledge_id": knowledge_id,
+ "source_file_id": source_file_id,
+ "source_url": file_name,
+ }
+ if item.get("name", "file.txt") == "file.txt":
+ item["name"] = file_name
+ item["source_file_id"] = source_file_id
+ item["paragraphs"] = item.pop("content", item.get("paragraphs", []))
+
+ for paragraph in item["paragraphs"]:
+ paragraph["problem_list"] = self._generate_problem_list(
+ paragraph,
+ file_name,
+ split_strategy,
+ paragraph_title_relate_problem_type,
+ paragraph_title_relate_problem,
+ paragraph_title_relate_problem_reference,
+ document_name_relate_problem_type,
+ document_name_relate_problem,
+ document_name_relate_problem_reference,
+ )
+ paragraph["is_active"] = True
+ paragraph["chunks"] = text_to_chunk(paragraph["content"], chunk_size)
+
+ def _generate_problem_list(
+ self,
+ paragraph,
+ document_name,
+ split_strategy,
+ paragraph_title_relate_problem_type,
+ paragraph_title_relate_problem,
+ paragraph_title_relate_problem_reference,
+ document_name_relate_problem_type,
+ document_name_relate_problem,
+ document_name_relate_problem_reference,
+ ):
+ if paragraph_title_relate_problem_type == "referencing":
+ paragraph_title_relate_problem = self.get_reference_content(paragraph_title_relate_problem_reference)
+ if document_name_relate_problem_type == "referencing":
+ document_name_relate_problem = self.get_reference_content(document_name_relate_problem_reference)
+
+ problem_list = [
+ item
+ for p in paragraph.get("problem_list", [])
+ for item in p.get("content", "").split("
")
+ if item.strip()
+ ]
+
+ if split_strategy == "auto":
+ if paragraph_title_relate_problem and paragraph.get("title"):
+ problem_list.append(paragraph.get("title"))
+ if document_name_relate_problem and document_name:
+ problem_list.append(document_name)
+ elif split_strategy == "custom":
+ if paragraph_title_relate_problem and paragraph.get("title"):
+ problem_list.append(paragraph.get("title"))
+ if document_name_relate_problem and document_name:
+ problem_list.append(document_name)
+ elif split_strategy == "qa":
+ if document_name_relate_problem and document_name:
+ problem_list.append(document_name)
+
+ return list(set(problem_list))
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ paragraph_list = self.get_context("paragraph_list") or []
+ # 每个文档保留前 5 个分段
+ limited_paragraph_list = []
+ for doc in paragraph_list:
+ if doc.get("paragraphs"):
+ doc_copy = doc.copy()
+ doc_copy["paragraphs"] = doc["paragraphs"][:5]
+ limited_paragraph_list.append(doc_copy)
+ else:
+ limited_paragraph_list.append(doc)
+
+ details.update(
+ {
+ "paragraph_list": limited_paragraph_list,
+ "limit": self.get_context("limit"),
+ "chunk_size": self.get_context("chunk_size"),
+ "with_filter": self.get_context("with_filter"),
+ "patterns": self.get_context("patterns"),
+ "split_strategy": self.get_context("split_strategy"),
+ "enableException": self.node.properties.get("enableException"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/form_node/__init__.py b/apps/application/workflow/nodes/form_node/__init__.py
new file mode 100644
index 00000000000..261db05100c
--- /dev/null
+++ b/apps/application/workflow/nodes/form_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/7/6 15:30
+ @desc:
+"""
+from .form_node import FormNode
diff --git a/apps/application/workflow/nodes/form_node/form_node.py b/apps/application/workflow/nodes/form_node/form_node.py
new file mode 100644
index 00000000000..0ec16624e43
--- /dev/null
+++ b/apps/application/workflow/nodes/form_node/form_node.py
@@ -0,0 +1,199 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: form_node.py
+@date:2026/7/6 15:30
+@desc:
+"""
+
+import copy
+import re
+
+import uuid_utils.compat as uuid
+from rest_framework import serializers
+
+from django.utils.translation import gettext_lazy as _
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode, Signal
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.form_content import FormContent
+from application.workflow.status import Status
+
+_TEMPLATE_RE = re.compile(r"\{\{([^.\s}]+)\.([^.\s}]+)\}\}")
+
+_MULTI_SELECT_TYPES = {"MultiSelect", "MultiRow"}
+
+
+def _get_default_option(option_list, _type, value_field):
+ try:
+ if option_list and isinstance(option_list, list) and len(option_list) > 0:
+ default_value_list = [o.get(value_field) for o in option_list if o.get("default")]
+ if len(default_value_list) == 0:
+ return (
+ [option_list[0].get(value_field)]
+ if _type in _MULTI_SELECT_TYPES
+ else option_list[0].get(value_field)
+ )
+ else:
+ return default_value_list if _type in _MULTI_SELECT_TYPES else default_value_list[0]
+ except Exception:
+ pass
+ return []
+
+
+class FormNodeSerializer(serializers.Serializer):
+ form_field_list = serializers.ListField(required=True, label=_("Form Configuration"))
+ form_content_format = serializers.CharField(required=True, label=_("Form output content"))
+ form_data = serializers.DictField(required=False, allow_null=True, label=_("Form Data"))
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+
+
+class FormNode(INode):
+ serializer_class = FormNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "form-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.workflow_manage.get_parameters()
+
+ # 判断是否是表单提交
+ position = workflow_params.get("position") or {}
+ is_form_submit = position.get("id") == self.node.id
+
+ form_field_list = node_params.get("form_field_list", [])
+ form_content_format = node_params.get("form_content_format", "")
+
+ if is_form_submit:
+ # 表单提交:从 workflow_params 获取前端提交的 form_data
+ form_data = workflow_params.get("form_data") or {}
+ is_submit = True
+ # 复用前端传来的 chunk_id
+ chunk_id = workflow_params.get("chunk_id") or str(uuid.uuid7())
+ else:
+ # 首次执行:从节点参数获取
+ form_data = node_params.get("form_data")
+ is_submit = form_data is not None
+ # 生成新 chunk_id
+ chunk_id = str(uuid.uuid7())
+
+ # 写入 context
+ self.write_context("is_submit", is_submit)
+ self.write_context("form_content_format", form_content_format)
+
+ if is_submit:
+ self.write_context("form_data", form_data)
+ for key in form_data:
+ self.write_context(key, form_data.get(key))
+
+ form_field_list = [self._reset_field(field) for field in form_field_list]
+ self.write_context("form_field_list", form_field_list)
+
+ # 输出表单内容
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(
+ FormContent(
+ chunk_id,
+ form_field_list,
+ form_content_format,
+ is_submit,
+ Status.SUCCESS,
+ node_info,
+ Position(self.get_node_id()),
+ form_data=form_data,
+ )
+ )
+
+ # 如果未提交,中断工作流等待用户提交
+ if not is_submit:
+ self.complete(Status.SUCCESS, signal=Signal.FORM)
+ return
+
+ # 已提交,继续执行后续节点
+ self.complete(Status.SUCCESS)
+
+ def _generate_prompt(self, prompt):
+ try:
+ return self.workflow_manage.generate_prompt(prompt)
+ except Exception:
+ return prompt
+
+ def _reset_field(self, field):
+ field = copy.copy(field)
+ for f in ["field", "label", "default_value"]:
+ _value = field.get(f)
+ if _value is None:
+ continue
+ if isinstance(_value, str):
+ field[f] = self._generate_prompt(_value)
+ elif f == "label" and isinstance(_value, dict):
+ _label_value = _value.get("label")
+ _value["label"] = self._generate_prompt(_label_value)
+ tooltip = _value.get("attrs", {}).get("tooltip")
+ if tooltip is not None:
+ _value["attrs"]["tooltip"] = self._generate_prompt(tooltip)
+
+ input_type = field.get("input_type")
+ if input_type in {"SingleSelect", "MultiSelect", "RadioCard", "RadioRow", "MultiRow"}:
+ if field.get("assignment_method") == "ref_variables":
+ option_list_ref = field.get("option_list")
+ if option_list_ref and len(option_list_ref) >= 2:
+ option_list = self.workflow_manage.get_reference_field(option_list_ref[0], option_list_ref[1:])
+ option_list = option_list if isinstance(option_list, list) else []
+ field["option_list"] = option_list
+ field["default_value"] = _get_default_option(option_list, input_type, field.get("value_field"))
+
+ if input_type == "JsonInput":
+ if field.get("default_value_assignment_method") == "ref_variables":
+ default_ref = field.get("default_value")
+ if default_ref and isinstance(default_ref, list) and len(default_ref) >= 2:
+ field["default_value"] = self.workflow_manage.get_reference_field(default_ref[0], default_ref[1:])
+
+ self._reset_visibility_rules(field)
+ return field
+
+ def _reset_visibility_rules(self, field):
+ visibility_rules = field.get("visibility_rules")
+ if not visibility_rules or not isinstance(visibility_rules.get("conditions"), list):
+ return
+ for cond in visibility_rules["conditions"]:
+ cond_field = cond.get("field")
+ if not cond_field or len(cond_field) < 2 or not cond_field[0] or not cond_field[1]:
+ continue
+ if cond_field[0] != self.node.id:
+ cond["_left"] = self.workflow_manage.get_reference_field(cond_field[0], cond_field[1:])
+ cond_value = cond.get("value")
+ if isinstance(cond_value, str) and _TEMPLATE_RE.search(cond_value):
+ cond["value"] = self._render_cond_value(cond_value)
+
+ def _render_cond_value(self, value):
+ def replacer(match):
+ node_display = match.group(1)
+ field_name = match.group(2)
+ workflow = self.workflow_manage.workflow
+ for f in workflow.node_field_list:
+ if f.node_name == node_display and f.value == field_name:
+ if f.node_id == self.node.id:
+ return match.group(0)
+ ref = self.workflow_manage.get_reference_field(f.node_id, [field_name])
+ return str(ref) if ref is not None else ""
+ return match.group(0)
+
+ try:
+ return _TEMPLATE_RE.sub(replacer, value)
+ except Exception:
+ return value
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "form_field_list": self.get_context("form_field_list"),
+ "form_data": self.get_context("form_data"),
+ "is_submit": self.get_context("is_submit"),
+ "form_content_format": self.get_context("form_content_format"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/image_generate_node/__init__.py b/apps/application/workflow/nodes/image_generate_node/__init__.py
new file mode 100644
index 00000000000..b915a23112f
--- /dev/null
+++ b/apps/application/workflow/nodes/image_generate_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/7/6 16:00
+ @desc:
+"""
+from .image_generate_node import ImageGenerateNode
diff --git a/apps/application/workflow/nodes/image_generate_node/image_generate_node.py b/apps/application/workflow/nodes/image_generate_node/image_generate_node.py
new file mode 100644
index 00000000000..c2776a1c50d
--- /dev/null
+++ b/apps/application/workflow/nodes/image_generate_node/image_generate_node.py
@@ -0,0 +1,231 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: image_generate_node.py
+@date:2026/7/6 16:00
+@desc:
+"""
+
+import base64
+from functools import reduce
+
+import requests
+import uuid_utils.compat as uuid
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from langchain_core.messages import HumanMessage, AIMessage
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+from common.exception.app_exception import AppApiException
+from common.utils.common import bytes_to_uploaded_file
+from knowledge.models import FileSourceType
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id
+from oss.serializers.file import FileSerializer
+
+
+class ImageGenerateNodeSerializer(serializers.Serializer):
+ model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+ prompt = serializers.CharField(required=True, label=_("Prompt word (positive)"))
+ negative_prompt = serializers.CharField(
+ required=False, label=_("Prompt word (negative)"), allow_null=True, allow_blank=True
+ )
+ dialogue_number = serializers.IntegerField(
+ required=False, default=0, label=_("Number of multi-round conversations")
+ )
+ dialogue_type = serializers.CharField(required=False, default="NODE", label=_("Conversation storage type"))
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+ model_params_setting = serializers.JSONField(required=False, default=dict, label=_("Model parameter settings"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+class ImageGenerateNode(INode):
+ serializer_class = ImageGenerateNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "image-generate-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+
+ model_id = node_params.get("model_id")
+ model_id_type = node_params.get("model_id_type", "custom")
+ model_id_reference = node_params.get("model_id_reference")
+ prompt = node_params.get("prompt", "")
+ negative_prompt = node_params.get("negative_prompt", "")
+ dialogue_number = node_params.get("dialogue_number", 0)
+ dialogue_type = node_params.get("dialogue_type", "NODE")
+ is_result = node_params.get("is_result", False)
+ model_params_setting = node_params.get("model_params_setting")
+
+ workflow_type = self.get_workflow_type()
+ if workflow_type in (WorkflowType.KNOWLEDGE, WorkflowType.TOOL):
+ history_chat_record = []
+ else:
+ history_chat_record = workflow_params.get("history_chat_record", [])
+
+ if model_id_type == "reference" and model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ model_id_reference[0],
+ model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ model_id = reference_data.get("model_id", model_id)
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("TTI") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ model_id = default_model_setting.get("model_id", model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ workspace_id = workflow_params.get("workspace_id")
+ tti_model = get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {}))
+
+ history_message = self._get_history_message(history_chat_record, dialogue_number)
+ self.write_context("history_message", [{"content": m.content, "role": m.type} for m in (history_message or [])])
+
+ question = self.workflow_manage.generate_prompt(prompt)
+ self.write_context("question", question)
+ self.write_context("negative_prompt", self.workflow_manage.generate_prompt(negative_prompt or ""))
+ self.write_context("dialogue_type", dialogue_type)
+
+ self._check_cancelled()
+ image_urls = tti_model.generate_image(question, negative_prompt)
+
+ file_urls = []
+ for image_url in image_urls:
+ file_name = "generated_image.png"
+ if isinstance(image_url, str):
+ if image_url.startswith("http"):
+ image_url = requests.get(image_url).content
+ elif image_url.startswith("data:image"):
+ header, encoded = image_url.split(",", 1)
+ image_url = base64.b64decode(encoded)
+ else:
+ image_url = base64.b64decode(image_url)
+ file = bytes_to_uploaded_file(image_url, file_name)
+ file_url = self._upload_file(file, workflow_params, workflow_type)
+ file_urls.append(file_url)
+
+ image_list = [{"file_id": path.split("/")[-1], "url": path} for path in file_urls]
+ self.write_context("image_list", image_list)
+
+ answer = " ".join([f"" for path in file_urls])
+ self.write_context("answer", answer)
+
+ if is_result:
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(
+ TextContent(str(uuid.uuid7()), answer, Status.SUCCESS, node_info, position=Position(self.get_node_id()))
+ )
+
+ def _get_history_message(self, history_chat_record, dialogue_number):
+ start_index = len(history_chat_record) - dialogue_number
+ return reduce(
+ lambda x, y: [*x, *y],
+ [
+ [
+ self._generate_history_human_message(history_chat_record[index]),
+ *self._generate_history_ai_message(history_chat_record[index]),
+ ]
+ for index in range(max(start_index, 0), len(history_chat_record))
+ ],
+ [],
+ )
+
+ def _generate_history_human_message(self, chat_record):
+ for data in chat_record.details.values():
+ if self.node.id == data.get("node_id") and "image_list" in data:
+ image_list = data["image_list"]
+ if len(image_list) == 0 or data.get("dialogue_type") == "WORKFLOW":
+ return HumanMessage(content=chat_record.problem_text)
+ return HumanMessage(content=data.get("question", chat_record.problem_text))
+ return HumanMessage(content=chat_record.problem_text)
+
+ def _generate_history_ai_message(self, chat_record):
+ for val in chat_record.details.values():
+ if self.node.id == val.get("node_id") and "image_list" in val:
+ if val.get("dialogue_type") == "WORKFLOW":
+ return chat_record.get_ai_message()
+ image_list = val["image_list"]
+ return [
+ AIMessage(
+ content=[{"type": "image_url", "image_url": {"url": f"{file_url}"}} for file_url in image_list]
+ )
+ ]
+ return chat_record.get_ai_message()
+
+ def _upload_file(self, file, workflow_params, workflow_type):
+ if workflow_type == WorkflowType.KNOWLEDGE:
+ return self._upload_knowledge_file(file, workflow_params)
+ if workflow_type == WorkflowType.TOOL:
+ return self._upload_tool_file(file, workflow_params)
+ return self._upload_application_file(file, workflow_params)
+
+ def _upload_knowledge_file(self, file, workflow_params):
+ knowledge_id = workflow_params.get("knowledge_id")
+ return FileSerializer(
+ data={
+ "file": file,
+ "meta": {"debug": False, "knowledge_id": knowledge_id},
+ "source_id": knowledge_id,
+ "source_type": FileSourceType.KNOWLEDGE.value,
+ }
+ ).upload()
+
+ def _upload_tool_file(self, file, workflow_params):
+ tool_id = workflow_params.get("tool_id")
+ return FileSerializer(
+ data={
+ "file": file,
+ "meta": {"debug": False, "tool_id": tool_id},
+ "source_id": tool_id,
+ "source_type": FileSourceType.TOOL.value,
+ }
+ ).upload()
+
+ def _upload_application_file(self, file, workflow_params):
+ application_id = workflow_params.get("application_id")
+ chat_id = workflow_params.get("chat_id")
+ return FileSerializer(
+ data={
+ "file": file,
+ "meta": {"debug": False, "chat_id": chat_id, "application_id": application_id},
+ "source_id": application_id,
+ "source_type": FileSourceType.APPLICATION.value,
+ }
+ ).upload()
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "answer": self.get_context("answer"),
+ "image_list": self.get_context("image_list"),
+ "negative_prompt": self.get_context("negative_prompt"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/image_to_video_node/__init__.py b/apps/application/workflow/nodes/image_to_video_node/__init__.py
new file mode 100644
index 00000000000..2a6d404093b
--- /dev/null
+++ b/apps/application/workflow/nodes/image_to_video_node/__init__.py
@@ -0,0 +1,2 @@
+# coding=utf-8
+from .image_to_video_node import ImageToVideoNode
diff --git a/apps/application/workflow/nodes/image_to_video_node/image_to_video_node.py b/apps/application/workflow/nodes/image_to_video_node/image_to_video_node.py
new file mode 100644
index 00000000000..624a71ab0e9
--- /dev/null
+++ b/apps/application/workflow/nodes/image_to_video_node/image_to_video_node.py
@@ -0,0 +1,287 @@
+# coding=utf-8
+import base64
+import uuid_utils.compat as uuid
+import requests
+from functools import reduce
+from typing import List
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _, gettext
+from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+from common.utils.common import bytes_to_uploaded_file
+from knowledge.models import FileSourceType, File
+from oss.serializers.file import FileSerializer, mime_types
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id
+from common.exception.app_exception import AppApiException
+from common.utils.logger import maxkb_logger
+
+
+class ImageToVideoNodeSerializer(serializers.Serializer):
+ model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+ prompt = serializers.CharField(required=True, label=_("Prompt word (positive)"))
+ negative_prompt = serializers.CharField(
+ required=False, label=_("Prompt word (negative)"), allow_null=True, allow_blank=True
+ )
+ dialogue_number = serializers.IntegerField(
+ required=False, default=0, label=_("Number of multi-round conversations")
+ )
+ dialogue_type = serializers.CharField(required=False, default="NODE", label=_("Conversation storage type"))
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+ model_params_setting = serializers.JSONField(required=False, default=dict, label=_("Model parameter settings"))
+ first_frame_url = serializers.ListField(required=True, label=_("First frame url"))
+ last_frame_url = serializers.ListField(required=False, label=_("Last frame url"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+class ImageToVideoNode(INode):
+ serializer_class = ImageToVideoNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "image-to-video-node"
+
+ def execute(self):
+ maxkb_logger.info(f"[ImageToVideoNode] execute START, node_id={self.get_node_id()}")
+ workflow_params = self.get_workflow_parameters()
+ node_params = self.get_parameters()
+
+ model_id = node_params.get("model_id")
+ model_id_type = node_params.get("model_id_type", "custom")
+ model_id_reference = node_params.get("model_id_reference")
+ prompt = node_params.get("prompt", "")
+ negative_prompt = node_params.get("negative_prompt", "")
+ dialogue_number = node_params.get("dialogue_number", 0)
+ dialogue_type = node_params.get("dialogue_type", "NODE")
+ is_result = node_params.get("is_result", False)
+ model_params_setting = node_params.get("model_params_setting")
+ first_frame_url_ref = node_params.get("first_frame_url")
+ last_frame_url_ref = node_params.get("last_frame_url")
+
+ workflow_type = self.get_workflow_type()
+ if workflow_type in (WorkflowType.KNOWLEDGE, WorkflowType.TOOL):
+ history_chat_record = []
+ chat_id = None
+ chat_record_id = None
+ workspace_id = workflow_params.get("workspace_id")
+ else:
+ history_chat_record = workflow_params.get("history_chat_record", [])
+ chat_id = workflow_params.get("chat_id")
+ chat_record_id = workflow_params.get("chat_record_id")
+ workspace_id = workflow_params.get("workspace_id")
+
+ if model_id_type == "reference" and model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ model_id_reference[0],
+ model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ model_id = reference_data.get("model_id", model_id)
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("ITV") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ model_id = default_model_setting.get("model_id", model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ if first_frame_url_ref is None or first_frame_url_ref == []:
+ raise ValueError(_("First frame url cannot be empty"))
+
+ first_frame_url = self.workflow_manage.get_reference_field(first_frame_url_ref[0], first_frame_url_ref[1:])
+
+ last_frame_url = None
+ if last_frame_url_ref is not None and last_frame_url_ref != []:
+ last_frame_url = self.workflow_manage.get_reference_field(last_frame_url_ref[0], last_frame_url_ref[1:])
+
+ ttv_model = get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {}))
+
+ history_message = self._get_history_message(history_chat_record, dialogue_number)
+ self.write_context(
+ "history_message",
+ [{"content": message.content, "role": message.type} for message in (history_message or [])],
+ )
+
+ question = self.workflow_manage.generate_prompt(prompt)
+ self.write_context("question", question)
+
+ message_list = [*history_message, question]
+ self.write_context("message_list", message_list)
+ self.write_context("dialogue_type", dialogue_type)
+ self.write_context("negative_prompt", self.workflow_manage.generate_prompt(negative_prompt))
+ self.write_context("first_frame_url", first_frame_url)
+ self.write_context("last_frame_url", last_frame_url)
+
+ first_frame_url = self._get_file_base64(first_frame_url)
+ last_frame_url = self._get_file_base64(last_frame_url)
+
+ self._check_cancelled()
+ video_urls = ttv_model.generate_video(question, negative_prompt, first_frame_url, last_frame_url)
+ maxkb_logger.info(
+ f"[ImageToVideoNode] generate_video result: {video_urls is not None}, node_id={self.get_node_id()}"
+ )
+
+ if video_urls is None or video_urls == "":
+ raise Exception(gettext("Failed to generate video"))
+
+ file_name = "generated_video.mp4"
+ if isinstance(video_urls, str) and video_urls.startswith("http"):
+ video_urls = requests.get(video_urls).content
+
+ file = bytes_to_uploaded_file(video_urls, file_name)
+ file_url = self._upload_file(file, workflow_type, workflow_params)
+
+ video_label = f''
+ video_list = [{"file_id": file_url.split("/")[-1], "file_name": file_name, "url": file_url}]
+
+ self.write_context("answer", video_label)
+ self.write_context("video", video_list)
+ self.write_context("chat_model", ttv_model)
+
+ if is_result:
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(
+ TextContent(str(uuid.uuid7()), video_label, Status.SUCCESS, node_info, Position(self.get_node_id()))
+ )
+
+ def _get_file_base64(self, image_url):
+ try:
+ if isinstance(image_url, list):
+ image_url = image_url[0].get("file_id") if "file_id" in image_url[0] else image_url[0].get("url")
+ if isinstance(image_url, str) and not image_url.startswith("http"):
+ file = QuerySet(File).filter(id=image_url).first()
+ file_bytes = file.get_bytes()
+ file_type = file.file_name.split(".")[-1].lower()
+ content_type = mime_types.get(file_type, "application/octet-stream")
+ encoded_bytes = base64.b64encode(file_bytes)
+ return f"data:{content_type};base64,{encoded_bytes.decode()}"
+ return image_url
+ except Exception as e:
+ raise ValueError(gettext("Failed to obtain the image"))
+
+ def _upload_file(self, file, workflow_type, workflow_params):
+ if workflow_type == WorkflowType.KNOWLEDGE:
+ return self._upload_knowledge_file(file, workflow_params)
+ if workflow_type == WorkflowType.TOOL:
+ return self._upload_tool_file(file, workflow_params)
+ return self._upload_application_file(file, workflow_params)
+
+ def _upload_knowledge_file(self, file, workflow_params):
+ knowledge_id = workflow_params.get("knowledge_id")
+ meta = {"debug": False, "knowledge_id": knowledge_id}
+ file_url = FileSerializer(
+ data={"file": file, "meta": meta, "source_id": knowledge_id, "source_type": FileSourceType.KNOWLEDGE.value}
+ ).upload()
+ return file_url
+
+ def _upload_tool_file(self, file, workflow_params):
+ tool_id = workflow_params.get("tool_id")
+ meta = {
+ "debug": False,
+ "tool_id": tool_id,
+ }
+ file_url = FileSerializer(
+ data={"file": file, "meta": meta, "source_id": tool_id, "source_type": FileSourceType.TOOL.value}
+ ).upload()
+ return file_url
+
+ def _upload_application_file(self, file, workflow_params):
+ application_id = workflow_params.get("application_id")
+ chat_id = workflow_params.get("chat_id")
+ debug = workflow_params.get("debug", False)
+ meta = {
+ "debug": debug,
+ "chat_id": chat_id,
+ "application_id": application_id,
+ }
+ file_url = FileSerializer(
+ data={
+ "file": file,
+ "meta": meta,
+ "source_id": application_id,
+ "source_type": FileSourceType.APPLICATION.value,
+ }
+ ).upload()
+ return file_url
+
+ def _generate_history_ai_message(self, chat_record):
+ for val in chat_record.details.values():
+ if self.node.id == val["node_id"] and "image_list" in val:
+ if val["dialogue_type"] == "WORKFLOW":
+ return chat_record.get_ai_message()
+ image_list = val["image_list"]
+ return [
+ AIMessage(
+ content=[
+ *[{"type": "image_url", "image_url": {"url": f"{file_url}"}} for file_url in image_list]
+ ]
+ )
+ ]
+ return chat_record.get_ai_message()
+
+ def _get_history_message(self, history_chat_record, dialogue_number):
+ start_index = len(history_chat_record) - dialogue_number
+ history_message = reduce(
+ lambda x, y: [*x, *y],
+ [
+ [
+ self._generate_history_human_message(history_chat_record[index]),
+ *self._generate_history_ai_message(history_chat_record[index]),
+ ]
+ for index in range(start_index if start_index > 0 else 0, len(history_chat_record))
+ ],
+ [],
+ )
+ return history_message
+
+ def _generate_history_human_message(self, chat_record):
+ for data in chat_record.details.values():
+ if self.node.id == data["node_id"] and "image_list" in data:
+ image_list = data["image_list"]
+ if len(image_list) == 0 or data["dialogue_type"] == "WORKFLOW":
+ return HumanMessage(content=chat_record.problem_text)
+ return HumanMessage(content=data["question"])
+ return HumanMessage(content=chat_record.problem_text)
+
+ @staticmethod
+ def reset_message_list(message_list: List[BaseMessage], answer_text):
+ result = [
+ {"role": "user" if isinstance(message, HumanMessage) else "ai", "content": message.content}
+ for message in message_list
+ ]
+ result.append({"role": "ai", "content": answer_text})
+ return result
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "answer": self.get_context("answer"),
+ "video": self.get_context("video"),
+ "first_frame_url": self.get_context("first_frame_url"),
+ "last_frame_url": self.get_context("last_frame_url"),
+ "negative_prompt": self.get_context("negative_prompt"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/image_understand_node/__init__.py b/apps/application/workflow/nodes/image_understand_node/__init__.py
new file mode 100644
index 00000000000..d6242aeec4b
--- /dev/null
+++ b/apps/application/workflow/nodes/image_understand_node/__init__.py
@@ -0,0 +1,2 @@
+# coding=utf-8
+from .image_understand_node import ImageUnderstandNode
diff --git a/apps/application/workflow/nodes/image_understand_node/image_understand_node.py b/apps/application/workflow/nodes/image_understand_node/image_understand_node.py
new file mode 100644
index 00000000000..20df7753c4e
--- /dev/null
+++ b/apps/application/workflow/nodes/image_understand_node/image_understand_node.py
@@ -0,0 +1,397 @@
+# coding=utf-8
+import base64
+import uuid_utils.compat as uuid
+from functools import reduce
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.reasoning_content import ReasoningContent
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+from application.workflow.tools import Reasoning
+from common.utils.common import guess_image_format
+from common.exception.app_exception import AppApiException
+from knowledge.models import File
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id
+
+
+class ImageUnderstandNodeSerializer(serializers.Serializer):
+ model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+ system = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Role Setting"))
+ prompt = serializers.CharField(required=True, label=_("Prompt word"))
+ dialogue_number = serializers.IntegerField(required=True, label=_("Number of multi-round conversations"))
+ dialogue_type = serializers.CharField(required=True, label=_("Conversation storage type"))
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+ image_list = serializers.ListField(required=False, label=_("picture"))
+ model_params_setting = serializers.JSONField(required=False, default=dict, label=_("Model parameter settings"))
+ model_setting = serializers.DictField(required=False, label="Model settings")
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+class ImageUnderstandNode(INode):
+ serializer_class = ImageUnderstandNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "image-understand-node"
+
+ def execute(self):
+ workflow_params = self.get_workflow_parameters()
+ node_params = self.get_parameters()
+
+ model_id = node_params.get("model_id")
+ model_id_type = node_params.get("model_id_type", "custom")
+ model_id_reference = node_params.get("model_id_reference")
+ system = node_params.get("system", "")
+ prompt = node_params.get("prompt", "")
+ dialogue_number = node_params.get("dialogue_number", 0)
+ dialogue_type = node_params.get("dialogue_type", "WORKFLOW")
+ is_result = node_params.get("is_result", False)
+ image_list_ref = node_params.get("image_list")
+ model_params_setting = node_params.get("model_params_setting")
+ model_setting = node_params.get("model_setting")
+
+ workflow_type = self.get_workflow_type()
+ if workflow_type in (WorkflowType.KNOWLEDGE, WorkflowType.TOOL):
+ history_chat_record = []
+ chat_id = None
+ workspace_id = workflow_params.get("workspace_id")
+ else:
+ history_chat_record = workflow_params.get("history_chat_record", [])
+ chat_id = workflow_params.get("chat_id")
+ workspace_id = workflow_params.get("workspace_id")
+
+ if model_setting is None:
+ model_setting = {
+ "reasoning_content_enable": False,
+ "reasoning_content_end": "",
+ "reasoning_content_start": "",
+ }
+ self.write_context("model_setting", model_setting)
+
+ if model_id_type == "reference" and model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ model_id_reference[0],
+ model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ model_id = reference_data.get("model_id", model_id)
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("IMAGE") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ model_id = default_model_setting.get("model_id", model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ image = None
+ if image_list_ref:
+ image = self.workflow_manage.get_reference_field(image_list_ref[0], image_list_ref[1:])
+
+ chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {}))
+
+ history_message_for_details = self._get_history_message_for_details(history_chat_record, dialogue_number)
+ self.write_context(
+ "history_message",
+ [{"content": message.content, "role": message.type} for message in (history_message_for_details or [])],
+ )
+
+ question = self.workflow_manage.generate_prompt(prompt)
+ self.write_context("question", question)
+
+ system = self.workflow_manage.generate_prompt(system)
+ self.write_context("system", system)
+
+ history_message = self._get_history_message(history_chat_record, dialogue_number)
+ message_list = self._generate_message_list(chat_model, system, prompt, history_message, image)
+ self.write_context(
+ "message_list",
+ [{"content": m.content, "role": m.type} for m in message_list],
+ )
+
+ self._generate_context_image(image)
+ self.write_context("dialogue_type", dialogue_type)
+
+ reasoning_content_id = str(uuid.uuid7())
+ text_content_id = str(uuid.uuid7())
+
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.RUNNING)
+
+ r = chat_model.stream(message_list)
+ self._stream_response(
+ r, chat_model, message_list, question, reasoning_content_id, text_content_id, node_info, is_result
+ )
+
+ def _stream_response(
+ self, response, chat_model, message_list, question, reasoning_content_id, text_content_id, node_info, is_result
+ ):
+ model_setting = self.get_context("model_setting") or {}
+ reasoning = Reasoning(
+ model_setting.get("reasoning_content_start", ""),
+ model_setting.get("reasoning_content_end", ""),
+ )
+ answer = ""
+ reasoning_content = ""
+ response_reasoning_content = False
+
+ for chunk in response:
+ self._check_cancelled()
+ reasoning_chunk = reasoning.get_reasoning_content(chunk)
+ content_chunk = reasoning_chunk.get("content")
+ if "reasoning_content" in chunk.additional_kwargs:
+ response_reasoning_content = True
+ reasoning_content_chunk = chunk.additional_kwargs.get("reasoning_content", "")
+ else:
+ reasoning_content_chunk = reasoning_chunk.get("reasoning_content")
+ answer += content_chunk
+ if reasoning_content_chunk is None:
+ reasoning_content_chunk = ""
+ reasoning_content += reasoning_content_chunk
+
+ if is_result:
+ if isinstance(chunk.content, list):
+ for chunk_item in chunk.content:
+ text = chunk_item.get("text", "")
+ if text:
+ self.write(
+ TextContent(
+ text_content_id, text, Status.RUNNING, node_info, Position(self.get_node_id())
+ )
+ )
+ if reasoning_content_chunk and model_setting.get("reasoning_content_enable", False):
+ self.write(
+ ReasoningContent(
+ reasoning_content_id,
+ reasoning_content_chunk,
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+ else:
+ if content_chunk:
+ self.write(
+ TextContent(
+ text_content_id, content_chunk, Status.RUNNING, node_info, Position(self.get_node_id())
+ )
+ )
+ if reasoning_content_chunk and model_setting.get("reasoning_content_enable", False):
+ self.write(
+ ReasoningContent(
+ reasoning_content_id,
+ reasoning_content_chunk,
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+
+ reasoning_end = reasoning.get_end_reasoning_content()
+ answer += reasoning_end.get("content")
+ reasoning_content_chunk = ""
+ if not response_reasoning_content:
+ reasoning_content_chunk = reasoning_end.get("reasoning_content")
+ if is_result:
+ if reasoning_end.get("content"):
+ self.write(
+ TextContent(
+ text_content_id,
+ reasoning_end.get("content"),
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+ if reasoning_content_chunk and model_setting.get("reasoning_content_enable", False):
+ self.write(
+ ReasoningContent(
+ reasoning_content_id,
+ reasoning_content_chunk,
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+
+ self._write_final_context(chat_model, message_list, question, answer, reasoning_content)
+
+ def _write_final_context(self, chat_model, message_list, question, answer, reasoning_content):
+ message_tokens = chat_model.get_num_tokens_from_messages(message_list)
+ answer_tokens = chat_model.get_num_tokens(answer)
+ self.write_context("message_tokens", message_tokens)
+ self.write_context("answer_tokens", answer_tokens)
+ self.write_context("answer", answer)
+ self.write_context("question", question)
+ self.write_context("reasoning_content", reasoning_content)
+
+ def _generate_context_image(self, image):
+ if isinstance(image, str) and image.startswith("http"):
+ self.write_context("image_list", [{"url": image}])
+ elif image is not None and len(image) > 0:
+ self.write_context("image_list", image)
+
+ def _get_history_message_for_details(self, history_chat_record, dialogue_number):
+ start_index = len(history_chat_record) - dialogue_number
+ history_message = reduce(
+ lambda x, y: [*x, *y],
+ [
+ [
+ self._generate_history_human_message_for_details(history_chat_record[index]),
+ *self._generate_history_ai_message(history_chat_record[index]),
+ ]
+ for index in range(start_index if start_index > 0 else 0, len(history_chat_record))
+ ],
+ [],
+ )
+ return history_message
+
+ def _generate_history_ai_message(self, chat_record):
+ for val in chat_record.details.values():
+ if self.node.id == val["node_id"] and "image_list" in val:
+ if val["dialogue_type"] == "WORKFLOW":
+ return chat_record.get_ai_message()
+ return [AIMessage(content=val["answer"])]
+ return chat_record.get_ai_message()
+
+ def _generate_history_human_message_for_details(self, chat_record):
+ for data in chat_record.details.values():
+ if self.node.id == data["node_id"] and "image_list" in data:
+ image_list = data["image_list"] or []
+ if len(image_list) == 0 or data["dialogue_type"] == "WORKFLOW":
+ return HumanMessage(content=chat_record.problem_text)
+ file_id_list = []
+ url_list = []
+ for image in image_list:
+ if "file_id" in image:
+ file_id_list.append(image.get("file_id"))
+ elif "url" in image:
+ url_list.append(image.get("url"))
+ return HumanMessage(
+ content=[
+ {"type": "text", "text": data["question"]},
+ *[
+ {"type": "image_url", "image_url": {"url": f"./oss/file/{file_id}"}}
+ for file_id in file_id_list
+ ],
+ *[{"type": "image_url", "image_url": {"url": url}} for url in url_list],
+ ]
+ )
+ return HumanMessage(content=chat_record.problem_text)
+
+ def _get_history_message(self, history_chat_record, dialogue_number):
+ start_index = len(history_chat_record) - dialogue_number
+ history_message = reduce(
+ lambda x, y: [*x, *y],
+ [
+ [
+ self._generate_history_human_message(history_chat_record[index]),
+ *self._generate_history_ai_message(history_chat_record[index]),
+ ]
+ for index in range(start_index if start_index > 0 else 0, len(history_chat_record))
+ ],
+ [],
+ )
+ return history_message
+
+ def _generate_history_human_message(self, chat_record):
+ for data in chat_record.details.values():
+ if self.node.id == data["node_id"] and "image_list" in data:
+ image_list = data["image_list"] or []
+ if len(image_list) == 0 or data["dialogue_type"] == "WORKFLOW":
+ return HumanMessage(content=chat_record.problem_text)
+ file_id_list = []
+ url_list = []
+ for image in image_list:
+ if "file_id" in image:
+ file_id_list.append(image.get("file_id"))
+ elif "url" in image:
+ url_list.append(image.get("url"))
+ image_base64_list = [self._file_id_to_base64(file_id) for file_id in file_id_list]
+ return HumanMessage(
+ content=[
+ {"type": "text", "text": data["question"]},
+ *[
+ {
+ "type": "image_url",
+ "image_url": {"url": f"data:image/{base64_image[1]};base64,{base64_image[0]}"},
+ }
+ for base64_image in image_base64_list
+ ],
+ *[{"type": "image_url", "image_url": {"url": url}} for url in url_list],
+ ]
+ )
+ return HumanMessage(content=chat_record.problem_text)
+
+ @staticmethod
+ def _file_id_to_base64(file_id: str):
+ file = QuerySet(File).filter(id=file_id).first()
+ file_bytes = file.get_bytes()
+ base64_image = base64.b64encode(file_bytes).decode("utf-8")
+ return [base64_image, guess_image_format(file_bytes, file.file_name)]
+
+ def _process_images(self, image):
+ images = []
+ if isinstance(image, str) and image.startswith("http"):
+ images.append({"type": "image_url", "image_url": {"url": image}})
+ elif image is not None and len(image) > 0:
+ for img in image:
+ if "file_id" in img:
+ file_id = img["file_id"]
+ file = QuerySet(File).filter(id=file_id).first()
+ image_bytes = file.get_bytes()
+ base64_image = base64.b64encode(image_bytes).decode("utf-8")
+ image_format = guess_image_format(image_bytes, file.file_name)
+ images.append(
+ {"type": "image_url", "image_url": {"url": f"data:image/{image_format};base64,{base64_image}"}}
+ )
+ elif "url" in img and img["url"].startswith("http"):
+ images.append({"type": "image_url", "image_url": {"url": img["url"]}})
+ return images
+
+ def _generate_message_list(self, image_model, system: str, prompt: str, history_message, image):
+ prompt_text = self.workflow_manage.generate_prompt(prompt)
+ images = self._process_images(image)
+
+ if images:
+ messages = [HumanMessage(content=[{"type": "text", "text": prompt_text}, *images])]
+ else:
+ messages = [HumanMessage(prompt_text)]
+
+ if system is not None and len(system) > 0:
+ return [SystemMessage(system), *history_message, *messages]
+ else:
+ return [*history_message, *messages]
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "answer": self.get_context("answer"),
+ "image_list": self.get_context("image_list"),
+ "reasoning_content": self.get_context("reasoning_content"),
+ "message_tokens": self.get_context("message_tokens"),
+ "answer_tokens": self.get_context("answer_tokens"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/intent_node/__init__.py b/apps/application/workflow/nodes/intent_node/__init__.py
new file mode 100644
index 00000000000..87587caeb24
--- /dev/null
+++ b/apps/application/workflow/nodes/intent_node/__init__.py
@@ -0,0 +1,2 @@
+# coding=utf-8
+from .intent_node import IntentNode
diff --git a/apps/application/workflow/nodes/intent_node/intent_node.py b/apps/application/workflow/nodes/intent_node/intent_node.py
new file mode 100644
index 00000000000..bb0e884c2f6
--- /dev/null
+++ b/apps/application/workflow/nodes/intent_node/intent_node.py
@@ -0,0 +1,254 @@
+# coding=utf-8
+import json
+import re
+from typing import List, Dict, Any
+from functools import reduce
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from langchain_core.messages import HumanMessage, SystemMessage
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.status import Status
+from common.exception.app_exception import AppApiException
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id, get_model_credential
+from .prompt_template import PROMPT_TEMPLATE
+
+
+class IntentBranchSerializer(serializers.Serializer):
+ id = serializers.CharField(required=True, label=_("Branch id"))
+ content = serializers.CharField(required=True, label=_("content"))
+ isOther = serializers.BooleanField(required=True, label=_("Branch Type"))
+
+
+class IntentNodeSerializer(serializers.Serializer):
+ model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+ content_list = serializers.ListField(required=True, label=_("Text content"))
+ dialogue_number = serializers.IntegerField(required=True, label=_("Number of multi-round conversations"))
+ model_params_setting = serializers.DictField(required=False, label=_("Model parameter settings"))
+ branch = IntentBranchSerializer(many=True)
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+def _get_default_model_params_setting(model_id):
+ model = QuerySet(Model).filter(id=model_id).first()
+ credential = get_model_credential(model.provider, model.model_type, model.model_name)
+ model_params_setting = credential.get_model_params_setting_form(model.model_name).get_default_form_data()
+ return model_params_setting
+
+
+class IntentNode(INode):
+ serializer_class = IntentNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "intent-node"
+
+ def execute(self):
+ workflow_params = self.get_workflow_parameters()
+ node_params = self.get_parameters()
+
+ model_id = node_params.get("model_id")
+ model_id_type = node_params.get("model_id_type", "custom")
+ model_id_reference = node_params.get("model_id_reference")
+ content_list_ref = node_params.get("content_list")
+ dialogue_number = node_params.get("dialogue_number", 0)
+ model_params_setting = node_params.get("model_params_setting")
+ branch = node_params.get("branch", [])
+
+ workflow_type = self.get_workflow_type()
+ if workflow_type in (WorkflowType.KNOWLEDGE, WorkflowType.TOOL):
+ history_chat_record = []
+ workspace_id = workflow_params.get("workspace_id")
+ else:
+ history_chat_record = workflow_params.get("history_chat_record", [])
+ workspace_id = workflow_params.get("workspace_id")
+
+ if model_id_type == "reference" and model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ model_id_reference[0],
+ model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ model_id = reference_data.get("model_id", model_id)
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("LLM") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ model_id = default_model_setting.get("model_id", model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ if model_params_setting is None and model_id:
+ model_params_setting = _get_default_model_params_setting(model_id)
+
+ user_input = self.workflow_manage.get_reference_field(content_list_ref[0], content_list_ref[1:])
+
+ chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {}))
+
+ history_message = self._get_history_message(history_chat_record, dialogue_number)
+ self.write_context(
+ "history_message",
+ [{"content": message.content, "role": message.type} for message in (history_message or [])],
+ )
+
+ self.write_context("user_input", str(user_input))
+
+ prompt = self._build_classification_prompt(str(user_input), branch)
+ system = self._build_system_prompt()
+ message_list = self._generate_message_list(system, prompt, history_message)
+ self.write_context(
+ "message_list",
+ [{"content": m.content, "role": m.type} for m in message_list],
+ )
+
+ try:
+ self._check_cancelled()
+ r = chat_model.invoke(message_list)
+ classification_result = r.content.strip()
+ matched_branch = self._parse_classification_result(classification_result, branch)
+
+ message_tokens = chat_model.get_num_tokens_from_messages(message_list)
+ answer_tokens = chat_model.get_num_tokens(r.content)
+ self.write_context("message_tokens", message_tokens)
+ self.write_context("answer_tokens", answer_tokens)
+ self.write_context("answer", r.content)
+ self.write_context("branch_id", matched_branch["id"])
+ self.write_context("reason", self._parse_result_reason(r.content))
+ self.write_context("category", matched_branch.get("content", matched_branch["id"]))
+
+ self.complete(Status.SUCCESS, [self.branch_anchor(matched_branch["id"])])
+
+ except Exception as e:
+ other_branch = self._find_other_branch(branch)
+ if other_branch:
+ self.write_context("branch_id", other_branch["id"])
+ self.write_context("category", other_branch.get("content", other_branch["id"]))
+ self.write_context("error", str(e))
+ self.complete(Status.SUCCESS, [self.branch_anchor(other_branch["id"])])
+ else:
+ raise Exception(f"error: {str(e)}")
+
+ def _get_history_message(self, history_chat_record, dialogue_number):
+ start_index = len(history_chat_record) - dialogue_number
+ history_message = reduce(
+ lambda x, y: [*x, *y],
+ [
+ [*history_chat_record[index].get_human_message(), *history_chat_record[index].get_ai_message()]
+ for index in range(start_index if start_index > 0 else 0, len(history_chat_record))
+ ],
+ [],
+ )
+
+ for message in history_message:
+ if isinstance(message.content, str):
+ message.content = re.sub(r".*?", "", message.content, flags=re.DOTALL)
+ return history_message
+
+ def _build_system_prompt(self) -> str:
+ return "你是一个专业的意图识别助手,请根据用户输入和意图选项,准确识别用户的真实意图。"
+
+ def _build_classification_prompt(self, user_input: str, branch: List[Dict]) -> str:
+ classification_list = []
+ other_branch = self._find_other_branch(branch)
+ if other_branch:
+ classification_list.append({"classificationId": 0, "content": other_branch.get("content")})
+ classification_id = 1
+ for b in branch:
+ if not b.get("isOther"):
+ classification_list.append({"classificationId": classification_id, "content": b["content"]})
+ classification_id += 1
+
+ return PROMPT_TEMPLATE.format(classification_list=classification_list, user_input=user_input)
+
+ def _generate_message_list(self, system: str, prompt: str, history_message):
+ if system is None or len(system) == 0:
+ return [*history_message, HumanMessage(self.workflow_manage.generate_prompt(prompt))]
+ else:
+ return [
+ SystemMessage(self.workflow_manage.generate_prompt(system)),
+ *history_message,
+ HumanMessage(self.workflow_manage.generate_prompt(prompt)),
+ ]
+
+ def _parse_classification_result(self, result: str, branch: List[Dict]) -> Dict[str, Any]:
+ other_branch = self._find_other_branch(branch)
+ normal_intents = [b for b in branch if not b.get("isOther")]
+
+ def get_branch_by_id(category_id: int):
+ if category_id == 0:
+ return other_branch
+ elif 1 <= category_id <= len(normal_intents):
+ return normal_intents[category_id - 1]
+ return None
+
+ try:
+ result_json = json.loads(result)
+ classification_id = result_json.get("classificationId")
+ matched_branch = get_branch_by_id(classification_id)
+ if matched_branch:
+ return matched_branch
+ except Exception as e:
+ numbers = re.findall(r'"classificationId":\s*(\d+)', result)
+ if numbers:
+ classification_id = int(numbers[0])
+ matched_branch = get_branch_by_id(classification_id)
+ if matched_branch:
+ return matched_branch
+
+ return other_branch or (normal_intents[0] if normal_intents else {"id": "unknown", "content": "unknown"})
+
+ def _parse_result_reason(self, result: str):
+ try:
+ result_json = json.loads(result)
+ return result_json.get("reason", "")
+ except Exception as e:
+ reason_patterns = [
+ r'"reason":\s*"([^"]*)"',
+ r'"reason":\s*"([^"]*)',
+ r'"reason":\s*([^,}\n]*)',
+ ]
+ for pattern in reason_patterns:
+ match = re.search(pattern, result, re.DOTALL)
+ if match:
+ reason = match.group(1).strip()
+ reason = re.sub(r'["\s]*$', "", reason)
+ return reason
+ return ""
+
+ def _find_other_branch(self, branch: List[Dict]) -> Dict[str, Any] | None:
+ for b in branch:
+ if b.get("isOther"):
+ return b
+ return None
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "user_input": self.get_context("user_input"),
+ "answer": self.get_context("answer"),
+ "branch_id": self.get_context("branch_id"),
+ "category": self.get_context("category"),
+ "reason": self.get_context("reason"),
+ "message_tokens": self.get_context("message_tokens"),
+ "answer_tokens": self.get_context("answer_tokens"),
+ }
+ )
+ return details
diff --git a/apps/application/flow/step_node/intent_node/impl/prompt_template.py b/apps/application/workflow/nodes/intent_node/prompt_template.py
similarity index 100%
rename from apps/application/flow/step_node/intent_node/impl/prompt_template.py
rename to apps/application/workflow/nodes/intent_node/prompt_template.py
diff --git a/apps/application/workflow/nodes/knowledge_write_node/__init__.py b/apps/application/workflow/nodes/knowledge_write_node/__init__.py
new file mode 100644
index 00000000000..e81dda631fb
--- /dev/null
+++ b/apps/application/workflow/nodes/knowledge_write_node/__init__.py
@@ -0,0 +1,10 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@date: 2026/9/11
+@desc: 知识库写入节点
+"""
+
+from .knowledge_write_node import KnowledgeWriteNode
diff --git a/apps/application/workflow/nodes/knowledge_write_node/knowledge_write_node.py b/apps/application/workflow/nodes/knowledge_write_node/knowledge_write_node.py
new file mode 100644
index 00000000000..1a10e33558f
--- /dev/null
+++ b/apps/application/workflow/nodes/knowledge_write_node/knowledge_write_node.py
@@ -0,0 +1,392 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: knowledge_write_node.py
+@date: 2026/9/11
+@desc: 知识库写入节点:把上游产出的文档/段落写入知识库并触发向量化
+"""
+
+from functools import reduce
+from typing import Any, Dict, List
+
+import uuid_utils.compat as uuid
+from django.db.models import QuerySet
+from django.db.models.aggregates import Max
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from common.chunk import text_to_chunk
+from common.utils.common import bulk_create_in_batches, filter_special_character
+from knowledge.models import (
+ ContentOrigin,
+ Document,
+ DocumentResourceType,
+ DocumentTag,
+ File,
+ FileSourceType,
+ KnowledgeType,
+ Paragraph,
+ Problem,
+ ProblemParagraphMapping,
+ Tag,
+)
+from knowledge.serializers.common import ProblemParagraphManage, ProblemParagraphObject
+from knowledge.serializers.document import DocumentSerializers
+from knowledge.serializers.document_strategy import DocumentStrategySerializer
+from knowledge.services.document_strategy import (
+ document_source_hash,
+ normalize_document_strategy,
+ strategy_hashes,
+)
+from knowledge.services.incremental_sync import prepare_remote_paragraphs
+
+
+class ParagraphInstanceSerializer(serializers.Serializer):
+ content = serializers.CharField(
+ required=True, label=_("content"), max_length=102400, min_length=1, allow_null=True, allow_blank=True
+ )
+ title = serializers.CharField(
+ required=False, max_length=256, label=_("section title"), allow_null=True, allow_blank=True
+ )
+ problem_list = serializers.ListField(required=False, child=serializers.CharField(required=False, allow_blank=True))
+ is_active = serializers.BooleanField(required=False, label=_("Is active"))
+ chunks = serializers.ListField(required=False, child=serializers.CharField(required=True))
+
+
+class TagInstanceSerializer(serializers.Serializer):
+ key = serializers.CharField(required=True, max_length=64, label=_("Tag Key"))
+ value = serializers.CharField(required=True, max_length=128, label=_("Tag Value"))
+
+
+class KnowledgeWriteParamSerializer(serializers.Serializer):
+ name = serializers.CharField(
+ required=True, label=_("document name"), max_length=128, min_length=1, source=_("document name")
+ )
+ meta = serializers.DictField(required=False)
+ tags = serializers.ListField(required=False, label=_("Tags"), child=TagInstanceSerializer())
+ paragraphs = ParagraphInstanceSerializer(required=False, many=True, allow_null=True)
+ source_file_id = serializers.UUIDField(required=False, allow_null=True)
+ user_id = serializers.UUIDField(required=False, allow_null=True)
+ doc_strategy = DocumentStrategySerializer(required=False, allow_null=True)
+
+
+class KnowledgeWriteNodeParamSerializer(serializers.Serializer):
+ document_list = serializers.ListField(
+ required=True, child=serializers.CharField(required=True), allow_null=True, label=_("document list")
+ )
+
+
+def convert_uuid_to_str(obj):
+ if isinstance(obj, dict):
+ return {k: convert_uuid_to_str(v) for k, v in obj.items()}
+ elif isinstance(obj, list):
+ return [convert_uuid_to_str(i) for i in obj]
+ elif isinstance(obj, uuid.UUID):
+ return str(obj)
+ else:
+ return obj
+
+
+def link_file(source_file_id, document_id):
+ if source_file_id is None:
+ return
+ source_file = QuerySet(File).filter(id=source_file_id).first()
+ if source_file:
+ file_content = source_file.get_bytes()
+
+ new_file = File(
+ id=uuid.uuid7(),
+ file_name=source_file.file_name,
+ file_size=source_file.file_size,
+ source_type=FileSourceType.DOCUMENT,
+ source_id=document_id, # 更新为当前知识库ID
+ meta=source_file.meta.copy() if source_file.meta else {},
+ )
+
+ # 保存文件内容和元数据
+ new_file.save(file_content)
+
+
+def get_paragraph_problem_model(knowledge_id: str, document_id: str, instance: Dict):
+ content = filter_special_character(instance.get("content"))
+ paragraph = Paragraph(
+ id=uuid.uuid7(),
+ document_id=document_id,
+ content=content,
+ knowledge_id=knowledge_id,
+ title=instance.get("title") if "title" in instance else "",
+ chunks=[
+ filter_special_character(c)
+ for c in (
+ instance.get("chunks")
+ if "chunks" in instance
+ else text_to_chunk(content, instance.get("child_length", 256))
+ )
+ ],
+ origin=instance.get("origin", ContentOrigin.SYNCED),
+ source_key=instance.get("source_key", ""),
+ source_hash=instance.get("source_hash", ""),
+ source_snapshot=instance.get("source_snapshot")
+ or {
+ "title": instance.get("title") or "",
+ "content": content,
+ },
+ source_updated_at=instance.get("source_updated_at"),
+ )
+
+ problem_paragraph_object_list = [
+ ProblemParagraphObject(knowledge_id, document_id, str(paragraph.id), problem)
+ for problem in (instance.get("problem_list") if "problem_list" in instance else [])
+ ]
+
+ return {
+ "paragraph": paragraph,
+ "problem_paragraph_object_list": problem_paragraph_object_list,
+ }
+
+
+def get_paragraph_model(document_model, paragraph_list: List):
+ knowledge_id = document_model.knowledge_id
+ paragraph_model_dict_list = [
+ get_paragraph_problem_model(knowledge_id, document_model.id, paragraph) for paragraph in paragraph_list
+ ]
+
+ paragraph_model_list = []
+ problem_paragraph_object_list = []
+ for paragraphs in paragraph_model_dict_list:
+ paragraph = paragraphs.get("paragraph")
+ for problem_model in paragraphs.get("problem_paragraph_object_list"):
+ problem_paragraph_object_list.append(problem_model)
+ paragraph_model_list.append(paragraph)
+
+ return {
+ "document": document_model,
+ "paragraph_model_list": paragraph_model_list,
+ "problem_paragraph_object_list": problem_paragraph_object_list,
+ }
+
+
+def get_document_paragraph_model(knowledge_id: str, instance: Dict):
+ source_meta = {"source_file_id": instance.get("source_file_id")} if instance.get("source_file_id") else {}
+ meta = {**instance.get("meta"), **source_meta} if instance.get("meta") is not None else source_meta
+ meta = {**convert_uuid_to_str(meta), "allow_download": True}
+
+ strategy = normalize_document_strategy(instance.get("doc_strategy"))
+ normalized_paragraphs = prepare_remote_paragraphs(
+ [
+ {
+ **paragraph,
+ "content": filter_special_character(paragraph.get("content")),
+ "origin": ContentOrigin.SYNCED,
+ "child_length": strategy["split"]["child_length"],
+ }
+ for paragraph in instance.get("paragraphs", [])
+ ]
+ )
+ document_model = Document(
+ **{
+ "knowledge_id": knowledge_id,
+ "id": uuid.uuid7(),
+ "name": instance.get("name"),
+ "char_length": reduce(lambda x, y: x + y, [len(p.get("content")) for p in normalized_paragraphs], 0),
+ "meta": meta,
+ "type": instance.get("type") if instance.get("type") is not None else KnowledgeType.WORKFLOW,
+ "resource_type": DocumentResourceType.DOCUMENT,
+ "doc_strategy": strategy,
+ "source_hash": document_source_hash(normalized_paragraphs),
+ "user_id": instance.get("user_id"),
+ **strategy_hashes(strategy),
+ }
+ )
+
+ return get_paragraph_model(document_model, normalized_paragraphs)
+
+
+def save_knowledge_tags(knowledge_id: str, tags: List[Dict[str, Any]]):
+ existed_tags_dict = {
+ (key, value): str(tag_id)
+ for key, value, tag_id in QuerySet(Tag).filter(knowledge_id=knowledge_id).values_list("key", "value", "id")
+ }
+
+ tag_model_list = []
+ new_tag_dict = {}
+ for tag in tags:
+ key = tag.get("key")
+ value = tag.get("value")
+
+ if (key, value) not in existed_tags_dict:
+ tag_model = Tag(id=uuid.uuid7(), knowledge_id=knowledge_id, key=key, value=value)
+ tag_model_list.append(tag_model)
+ new_tag_dict[(key, value)] = str(tag_model.id)
+
+ if tag_model_list:
+ Tag.objects.bulk_create(tag_model_list)
+
+ all_tag_dict = {**existed_tags_dict, **new_tag_dict}
+
+ return all_tag_dict, new_tag_dict
+
+
+def batch_add_document_tag(document_tag_map: Dict[str, List[str]]):
+ """
+ 批量添加文档-标签关联
+ document_tag_map: {document_id: [tag_id1, tag_id2, ...]}
+ """
+ all_document_ids = list(document_tag_map.keys())
+ all_tag_ids = list(set(tag_id for tag_ids in document_tag_map.values() for tag_id in tag_ids))
+
+ # 查询已存在的文档-标签关联
+ existed_relations = set(
+ QuerySet(DocumentTag)
+ .filter(document_id__in=all_document_ids, tag_id__in=all_tag_ids)
+ .values_list("document_id", "tag_id")
+ )
+
+ new_relations = [
+ DocumentTag(
+ id=uuid.uuid7(),
+ document_id=doc_id,
+ tag_id=tag_id,
+ )
+ for doc_id, tag_ids in document_tag_map.items()
+ for tag_id in tag_ids
+ if (doc_id, tag_id) not in existed_relations
+ ]
+
+ if new_relations:
+ QuerySet(DocumentTag).bulk_create(new_relations)
+
+
+class KnowledgeWriteNode(INode):
+ serializer_class = KnowledgeWriteNodeParamSerializer
+ supported_workflow_type_list = [WorkflowType.KNOWLEDGE]
+ type = "knowledge-write-node"
+
+ def save(self, document_list, user_id):
+ serializer = KnowledgeWriteParamSerializer(data=document_list, many=True)
+ serializer.is_valid(raise_exception=True)
+ document_list = serializer.data
+
+ workflow_params = self.get_workflow_parameters()
+ knowledge_id = workflow_params.get("knowledge_id")
+ workspace_id = workflow_params.get("workspace_id")
+
+ document_model_list = []
+ paragraph_model_list = []
+ problem_paragraph_object_list = []
+ # 文档标签映射关系
+ document_tags_map = {}
+ knowledge_tag_dict = {}
+
+ for document in document_list:
+ document["user_id"] = user_id
+ document_paragraph_dict_model = get_document_paragraph_model(knowledge_id, document)
+ document_instance = document_paragraph_dict_model.get("document")
+ link_file(document.get("source_file_id"), document_instance.id)
+ document_model_list.append(document_instance)
+ # 收集标签
+ single_document_tag_list = document.get("tags", [])
+ # 去重传入的标签
+ for tag in single_document_tag_list:
+ tag_key = (tag["key"], tag["value"])
+ if tag_key not in knowledge_tag_dict:
+ knowledge_tag_dict[tag_key] = tag
+
+ if single_document_tag_list:
+ document_tags_map[str(document_instance.id)] = single_document_tag_list
+
+ for paragraph in document_paragraph_dict_model.get("paragraph_model_list"):
+ paragraph_model_list.append(paragraph)
+ for problem_paragraph_object in document_paragraph_dict_model.get("problem_paragraph_object_list"):
+ problem_paragraph_object_list.append(problem_paragraph_object)
+ knowledge_tag_list = list(knowledge_tag_dict.values())
+ # 保存所有文档中含有的标签到知识库
+ if knowledge_tag_list:
+ all_tag_dict, new_tag_dict = save_knowledge_tags(knowledge_id, knowledge_tag_list)
+ # 构建文档-标签ID映射
+ document_tag_id_map = {}
+ # 为每个文档添加其对应的标签
+ for doc_id, doc_tags in document_tags_map.items():
+ doc_tag_ids = [
+ all_tag_dict[(tag.get("key"), tag.get("value"))]
+ for tag in doc_tags
+ if (tag.get("key"), tag.get("value")) in all_tag_dict
+ ]
+ if doc_tag_ids:
+ document_tag_id_map[doc_id] = doc_tag_ids
+ if document_tag_id_map:
+ batch_add_document_tag(document_tag_id_map)
+
+ problem_model_list, problem_paragraph_mapping_list = ProblemParagraphManage(
+ problem_paragraph_object_list, knowledge_id
+ ).to_problem_model_list()
+
+ QuerySet(Document).bulk_create(document_model_list) if len(document_model_list) > 0 else None
+
+ if len(paragraph_model_list) > 0:
+ for document in document_model_list:
+ max_position = (
+ Paragraph.objects.filter(document_id=document.id).aggregate(max_position=Max("position"))[
+ "max_position"
+ ]
+ or 0
+ )
+ sub_list = [p for p in paragraph_model_list if p.document_id == document.id]
+ for i, paragraph in enumerate(sub_list):
+ paragraph.position = max_position + i + 1
+ QuerySet(Paragraph).bulk_create(sub_list if len(sub_list) > 0 else [])
+
+ bulk_create_in_batches(Problem, problem_model_list, batch_size=1000)
+
+ bulk_create_in_batches(ProblemParagraphMapping, problem_paragraph_mapping_list, batch_size=1000)
+
+ return document_model_list, knowledge_id, workspace_id
+
+ @staticmethod
+ def post_embedding(document_model_list, knowledge_id, workspace_id):
+ for document in document_model_list:
+ DocumentSerializers.Operate(
+ data={"knowledge_id": knowledge_id, "document_id": document.id, "workspace_id": workspace_id}
+ ).refresh()
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+ document_reference = node_params.get("document_list") or []
+ documents = (
+ self.workflow_manage.get_reference_field(document_reference[0], document_reference[1:])
+ if document_reference
+ else []
+ )
+ user_id = workflow_params.get("user_id")
+
+ document_model_list, knowledge_id, workspace_id = self.save(documents, user_id)
+ self.post_embedding(document_model_list, knowledge_id, workspace_id)
+
+ write_content_list = [
+ {
+ "name": document.get("name"),
+ "paragraphs": [
+ {
+ "title": p.get("title"),
+ "content": p.get("content"),
+ }
+ for p in document.get("paragraphs")[0:5]
+ ],
+ }
+ for document in documents
+ ]
+ self.write_context("write_content", write_content_list)
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "write_content": self.get_context("write_content"),
+ "enableException": self.node.properties.get("enableException"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/loop_break_node/__init__.py b/apps/application/workflow/nodes/loop_break_node/__init__.py
new file mode 100644
index 00000000000..8bc0177353d
--- /dev/null
+++ b/apps/application/workflow/nodes/loop_break_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/7/6 15:00
+ @desc:
+"""
+from .loop_break_node import LoopBreakNode
diff --git a/apps/application/workflow/nodes/loop_break_node/loop_break_node.py b/apps/application/workflow/nodes/loop_break_node/loop_break_node.py
new file mode 100644
index 00000000000..4e19ed8901b
--- /dev/null
+++ b/apps/application/workflow/nodes/loop_break_node/loop_break_node.py
@@ -0,0 +1,55 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: loop_break_node.py
+@date:2026/7/6 15:00
+@desc:
+"""
+
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.compare import do_assertion
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode, Signal
+from application.workflow.status import Status
+
+
+class ConditionSerializer(serializers.Serializer):
+ compare = serializers.CharField(required=True, label=_("Comparator"))
+ value = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("value"))
+ field = serializers.ListField(required=True, label=_("Fields"))
+
+
+class LoopBreakNodeSerializer(serializers.Serializer):
+ condition = serializers.CharField(required=True, label=_("Condition or|and"))
+ condition_list = ConditionSerializer(many=True)
+
+
+class LoopBreakNode(INode):
+ serializer_class = LoopBreakNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "loop-break-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ condition = node_params.get("condition")
+ condition_list = node_params.get("condition_list", [])
+
+ is_break = do_assertion(self.workflow_manage, condition, condition_list)
+ self.write_context("is_break", is_break)
+
+ if is_break:
+ self.complete(Status.SUCCESS, signal=Signal.BREAK)
+ return
+ self.complete(Status.SUCCESS)
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "is_break": self.get_context("is_break"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/loop_continue_node/__init__.py b/apps/application/workflow/nodes/loop_continue_node/__init__.py
new file mode 100644
index 00000000000..a7733ae87d4
--- /dev/null
+++ b/apps/application/workflow/nodes/loop_continue_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/7/6 15:10
+ @desc:
+"""
+from .loop_continue_node import LoopContinueNode
diff --git a/apps/application/workflow/nodes/loop_continue_node/loop_continue_node.py b/apps/application/workflow/nodes/loop_continue_node/loop_continue_node.py
new file mode 100644
index 00000000000..aabab104314
--- /dev/null
+++ b/apps/application/workflow/nodes/loop_continue_node/loop_continue_node.py
@@ -0,0 +1,55 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: loop_continue_node.py
+@date:2026/7/6 15:10
+@desc:
+"""
+
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.compare import do_assertion
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode, Signal
+from application.workflow.status import Status
+
+
+class ConditionSerializer(serializers.Serializer):
+ compare = serializers.CharField(required=True, label=_("Comparator"))
+ value = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("value"))
+ field = serializers.ListField(required=True, label=_("Fields"))
+
+
+class LoopContinueNodeSerializer(serializers.Serializer):
+ condition = serializers.CharField(required=True, label=_("Condition or|and"))
+ condition_list = ConditionSerializer(many=True)
+
+
+class LoopContinueNode(INode):
+ serializer_class = LoopContinueNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "loop-continue-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ condition = node_params.get("condition")
+ condition_list = node_params.get("condition_list", [])
+
+ is_continue = do_assertion(self.workflow_manage, condition, condition_list)
+ self.write_context("is_continue", is_continue)
+
+ if is_continue:
+ self.complete(Status.SUCCESS, signal=Signal.CONTINUE)
+ return
+ self.complete(Status.SUCCESS)
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "is_continue": self.get_context("is_continue"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/loop_node/__init__.py b/apps/application/workflow/nodes/loop_node/__init__.py
new file mode 100644
index 00000000000..826ece19728
--- /dev/null
+++ b/apps/application/workflow/nodes/loop_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/7/2 10:00
+ @desc:
+"""
+from .loop_node import LoopNode
diff --git a/apps/application/workflow/nodes/loop_node/loop_node.py b/apps/application/workflow/nodes/loop_node/loop_node.py
new file mode 100644
index 00000000000..c33e4449bcf
--- /dev/null
+++ b/apps/application/workflow/nodes/loop_node/loop_node.py
@@ -0,0 +1,226 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: loop_node.py
+@date:2026/7/2 10:00
+@desc:
+"""
+
+import time
+
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType, new_instance
+from application.workflow.i_node import INode, Signal
+from application.workflow.message.struct.content import Position
+from application.workflow.status import Status
+from common.exception.app_exception import AppApiException
+
+MAX_LOOP_COUNT = 500
+
+
+class LoopNodeSerializer(serializers.Serializer):
+ loop_type = serializers.CharField(required=True, label=_("loop_type"))
+ array = serializers.ListField(required=False, allow_null=True, label=_("array"))
+ number = serializers.IntegerField(required=False, allow_null=True, label=_("number"))
+ loop_body = serializers.DictField(required=True, label="循环体")
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ loop_type = self.data.get("loop_type")
+ if loop_type == "ARRAY":
+ array = self.data.get("array")
+ if array is None or len(array) == 0:
+ message = _("{field}, this field is required.", field="array")
+ raise AppApiException(500, message)
+ elif loop_type == "NUMBER":
+ number = self.data.get("number")
+ if number is None:
+ message = _("{field}, this field is required.", field="number")
+ raise AppApiException(500, message)
+
+
+def _generate_loop_number(number, start_index=0):
+ return iter([(i, i) for i in range(start_index, number)])
+
+
+def _generate_loop_array(array, start_index=0):
+ return iter([(item, i) for i, item in enumerate(array) if i >= start_index])
+
+
+def _generate_while_loop(number, start_index=0):
+ return iter([(i, i) for i in range(start_index, number)])
+
+
+class LoopNode(INode):
+ serializer_class = LoopNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "loop-node"
+ _workflow_params = None
+ _iterator = None
+
+ def _run(self):
+ self.execute()
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+
+ loop_type = node_params.get("loop_type")
+ array = node_params.get("array")
+ number = node_params.get("number")
+ loop_body = node_params.get("loop_body")
+
+ # 从 position 获取 start_index
+ position = workflow_params.get("position") or {}
+ start_index = position.get("index") or 0 if position.get("id") == self.node.id else 0
+
+ if loop_type == "ARRAY" and isinstance(array, list) and len(array) >= 2:
+ array = self.workflow_manage.get_reference_field(array[0], array[1:])
+
+ self.data["params"] = {"loop_type": loop_type, "array": array, "number": number}
+
+ # 根据 start_index 构建迭代器
+ if loop_type == "ARRAY":
+ iterator = _generate_loop_array(array, start_index=start_index)
+ elif loop_type == "LOOP":
+ iterator = _generate_while_loop(number or MAX_LOOP_COUNT, start_index=start_index)
+ else:
+ iterator = _generate_loop_number(number, start_index=start_index)
+ self._workflow_params = workflow_params
+ self.data["loop_body"] = loop_body
+ self._iterator = iterator
+
+ self._run_next()
+
+ def _run_next(self):
+ try:
+ item, index = next(self._iterator)
+ except StopIteration:
+ self.data["run_time"] = time.time() - self.data.get("start_time", time.time())
+ self.complete(Status.SUCCESS)
+ return
+ workflow = new_instance(self.data["loop_body"], self.get_workflow_type())
+
+ chunk_list = []
+
+ def on_next(wf_manage, content):
+ chunk_list.append(content)
+ content.position = Position(self.get_node_id(), index, content.position)
+ self.write(content)
+
+ def on_complete(wf_manage, error):
+ loop_details_list = self.data.setdefault("loop_details_list", [])
+ loop_details_list.append(wf_manage.get_details())
+ self.write_context("index", index)
+ self.write_context("item", item)
+ last_context = self.workflow_manage.get_context(self.node.id, "last_context")
+ if last_context:
+ self.write_context("last_context", {**last_context, **wf_manage.context})
+ else:
+ self.write_context("last_context", wf_manage.context)
+
+ if wf_manage.signal == Signal.BREAK or wf_manage.signal == Signal.FORM:
+ self.data["run_time"] = time.time() - self.data.get("start_time", time.time())
+ self.complete(Status.SUCCESS)
+ return
+
+ if wf_manage.signal == Signal.CONTINUE:
+ self._run_next()
+ return
+
+ if error:
+ self.write_context("error_message", str(error))
+ self.complete(Status.FAIL, error=error)
+ return
+
+ self._run_next()
+
+ from application.workflow.workflow_manage import CallBack
+ from application.workflow.loop_workflow_manage import LoopWorkFlowManage
+ from application.workflow.nodes import get_node_class
+
+ call_back = CallBack(on_next, on_complete)
+
+ loop_start_class = get_node_class("loop-start-node", self.get_workflow_type())
+
+ # 获取 position,当前 index 和 position.index 一致时传入 children
+ position = self._workflow_params.get("position") or {}
+ child_position = None
+ if position.get("id") == self.node.id and position.get("index") == index:
+ child_position = position.get("children")
+
+ def get_start_node_fn(wf, wf_manage):
+ # 如果有 child_position,从指定节点开始
+ if child_position and child_position.get("id"):
+ node_id = child_position.get("id")
+ node = wf.get_node(node_id)
+ if node:
+ node_class = get_node_class(node.type, self.get_workflow_type())
+ return node_class(node, wf_manage, lambda n: n.properties.get("node_data", {}))
+
+ # 默认从 loop-start-node 开始
+ start_node = wf.get_node("loop-start-node")
+ return loop_start_class(start_node, wf_manage, lambda n: n.properties.get("node_data", {}))
+
+ def get_context():
+ last_context = self.workflow_manage.get_context(self.node.id, "last_context") or {}
+ if last_context:
+ return last_context
+ return {}
+
+ # 构建子工作流参数,第一次迭代传入 child_position
+ loop_workflow_params = dict(self._workflow_params)
+ if child_position:
+ loop_workflow_params["position"] = child_position
+ else:
+ loop_workflow_params.pop("position", None)
+ loop_workflow_params["index"] = index
+ loop_workflow_params["item"] = item
+ loop_manage = LoopWorkFlowManage.from_context(
+ workflow=workflow,
+ parameters=loop_workflow_params,
+ workflow_type=self.get_workflow_type(),
+ call_back=call_back,
+ get_start_node=get_start_node_fn,
+ parent_workflow_manage=self.workflow_manage,
+ get_context=get_context,
+ )
+ loop_manage.run()
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {"params": self.data.get("params"), "index": self.get_context("index"), "item": self.get_context("item")}
+ )
+ loop_details = []
+ position_index = 0
+ loop_position_index = 0
+ if old_details and position:
+ for index, item in enumerate(old_details.get("children") or []):
+ loop_position_index = index
+ loop_details.append(item)
+ current_details = loop_details[loop_position_index]
+ for index, value in enumerate(current_details):
+ if position.get("children").get("id") == value.get("node_id"):
+ position_index = index
+
+ for index, _loop_details in enumerate(self.data.get("loop_details_list")):
+ if position and index == 0:
+ for inner_index, item in enumerate(_loop_details):
+ if position is not None and inner_index == 0 and index == 0:
+ loop_details[loop_position_index][position_index] = item
+ else:
+ _child = []
+ if len(loop_details) > loop_position_index:
+ _child = loop_details[loop_position_index]
+ else:
+ loop_details.insert(loop_position_index, _child)
+ _child.append(item)
+ else:
+ loop_details.append(_loop_details)
+
+ details["children"] = loop_details
+ return details
diff --git a/apps/application/workflow/nodes/loop_start_node/__init__.py b/apps/application/workflow/nodes/loop_start_node/__init__.py
new file mode 100644
index 00000000000..7d1dbb5f046
--- /dev/null
+++ b/apps/application/workflow/nodes/loop_start_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/7/2 10:00
+ @desc:
+"""
+from .loop_start_node import LoopStartNode
diff --git a/apps/application/workflow/nodes/loop_start_node/loop_start_node.py b/apps/application/workflow/nodes/loop_start_node/loop_start_node.py
new file mode 100644
index 00000000000..d0b25af9188
--- /dev/null
+++ b/apps/application/workflow/nodes/loop_start_node/loop_start_node.py
@@ -0,0 +1,47 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: loop_start_node.py
+@date:2026/7/2 10:00
+@desc:
+"""
+
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+
+
+class LoopStartNodeSerializer(serializers.Serializer):
+ pass
+
+
+class LoopStartNode(INode):
+ serializer_class = LoopStartNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "loop-start-node"
+
+ def execute(self):
+ loop = self.workflow_manage.context.get("loop")
+ if loop is None:
+ self.write_context("loop", {})
+ parameters = self.workflow_manage.get_parameters()
+ if parameters is not None:
+ index = parameters.get("index", 0)
+ item = parameters.get("item", 0)
+ self.write_context("index", index)
+ self.write_context("item", item)
+ else:
+ self.write_context("index", 0)
+ self.write_context("item", 0)
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "index": self.get_context("index"),
+ "item": self.get_context("item"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/mcp_node/__init__.py b/apps/application/workflow/nodes/mcp_node/__init__.py
new file mode 100644
index 00000000000..cd0b0387c40
--- /dev/null
+++ b/apps/application/workflow/nodes/mcp_node/__init__.py
@@ -0,0 +1,2 @@
+# coding=utf-8
+from .mcp_node import McpNode
diff --git a/apps/application/workflow/nodes/mcp_node/mcp_node.py b/apps/application/workflow/nodes/mcp_node/mcp_node.py
new file mode 100644
index 00000000000..0155d36ee98
--- /dev/null
+++ b/apps/application/workflow/nodes/mcp_node/mcp_node.py
@@ -0,0 +1,101 @@
+# coding=utf-8
+import asyncio
+import json
+from typing import List, Dict, Any
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from langchain_mcp_adapters.client import MultiServerMCPClient
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.status import Status
+from common.utils.tool_code import ToolExecutor
+from tools.models import Tool
+
+
+class McpNodeSerializer(serializers.Serializer):
+ mcp_servers = serializers.JSONField(required=True, label=_("Mcp servers"))
+ mcp_server = serializers.CharField(required=True, label=_("Mcp server"))
+ mcp_tool = serializers.CharField(required=True, label=_("Mcp tool"))
+ mcp_tool_id = serializers.CharField(required=False, label=_("Mcp tool"), allow_null=True, allow_blank=True)
+ mcp_source = serializers.CharField(required=False, label=_("Mcp source"), allow_blank=True, allow_null=True)
+ tool_params = serializers.DictField(required=True, label=_("Tool parameters"))
+
+
+class McpNode(INode):
+ serializer_class = McpNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "mcp-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+
+ mcp_servers = node_params.get("mcp_servers")
+ mcp_server = node_params.get("mcp_server")
+ mcp_tool = node_params.get("mcp_tool")
+ mcp_tool_id = node_params.get("mcp_tool_id")
+ mcp_source = node_params.get("mcp_source")
+ tool_params = node_params.get("tool_params", {})
+
+ if mcp_source == "referencing":
+ if not mcp_tool_id:
+ raise ValueError("MCP tool ID is required when mcp_source is 'referencing'.")
+ tool = QuerySet(Tool).filter(id=mcp_tool_id).first()
+ if not tool:
+ raise ValueError(f"Tool with ID {mcp_tool_id} not found.")
+ if not tool.is_active:
+ raise ValueError(f"Tool with ID {mcp_tool_id} is inactive.")
+ servers = json.loads(tool.code)
+ else:
+ servers = json.loads(mcp_servers) if isinstance(mcp_servers, str) else mcp_servers
+
+ servers = self._handle_variables(servers)
+ ToolExecutor().validate_mcp_transport(json.dumps(servers))
+
+ params = json.loads(json.dumps(tool_params))
+ params = self._handle_variables(params)
+
+ self._check_cancelled()
+
+ async def call_tool(t, a):
+ client = MultiServerMCPClient(servers)
+ async with client.session(mcp_server) as s:
+ return await s.call_tool(t, a)
+
+ res = asyncio.run(call_tool(mcp_tool, params))
+ result = [content.text for content in res.content]
+
+ self.write_context("result", result)
+ self.write_context("tool_params", params)
+ self.write_context("mcp_tool", mcp_tool)
+
+ def _handle_variables(self, tool_params: Any) -> Any:
+ if isinstance(tool_params, dict):
+ for k, v in tool_params.items():
+ tool_params[k] = self._handle_variables(v)
+ return tool_params
+ elif isinstance(tool_params, list):
+ if len(tool_params) > 0 and isinstance(tool_params[0], str):
+ return self._get_reference_content(tool_params)
+ return [self._handle_variables(item) for item in tool_params]
+ elif isinstance(tool_params, str):
+ return self.workflow_manage.generate_prompt(tool_params)
+ return tool_params
+
+ def _get_reference_content(self, fields: List[str]) -> Any:
+ if fields:
+ return self.workflow_manage.get_reference_field(fields[0], fields[1:])
+ return None
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "mcp_tool": self.get_context("mcp_tool"),
+ "tool_params": self.get_context("tool_params"),
+ "result": self.get_context("result"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/parameter_extraction_node/__init__.py b/apps/application/workflow/nodes/parameter_extraction_node/__init__.py
new file mode 100644
index 00000000000..e695c85c365
--- /dev/null
+++ b/apps/application/workflow/nodes/parameter_extraction_node/__init__.py
@@ -0,0 +1,7 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: __init__.py
+ @desc:
+"""
+from .parameter_extraction_node import ParameterExtractionNode
diff --git a/apps/application/workflow/nodes/parameter_extraction_node/parameter_extraction_node.py b/apps/application/workflow/nodes/parameter_extraction_node/parameter_extraction_node.py
new file mode 100644
index 00000000000..ce3ea8f63f6
--- /dev/null
+++ b/apps/application/workflow/nodes/parameter_extraction_node/parameter_extraction_node.py
@@ -0,0 +1,173 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: parameter_extraction_node.py
+@desc:
+"""
+
+import json
+import re
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from langchain_core.messages import HumanMessage
+from langchain_core.prompts import PromptTemplate
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id, get_model_credential
+from common.exception.app_exception import AppApiException
+
+prompt = """
+Please strictly process the text according to the following requirements:
+**Task**:
+Extract specified field information from given text
+
+**Enter text**:
+{{question}}
+
+**Extract configuration**:
+{{properties}}
+
+**Rule**:
+- Strictly follow the data and field of Extract configuration
+- If not found, use null value
+- Only return pure JSON without additional text
+- Keep the string format neat
+"""
+
+
+class ParameterExtractionNodeSerializer(serializers.Serializer):
+ input_variable = serializers.ListField(required=True, label=_("input variable"))
+ variable_list = serializers.ListField(required=True, label=_("Split variables"))
+ model_params_setting = serializers.DictField(required=False, label=_("Model parameter settings"))
+ model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+def _get_default_model_params_setting(model_id):
+ model = QuerySet(Model).filter(id=model_id).first()
+ credential = get_model_credential(model.provider, model.model_type, model.model_name)
+ model_params_setting = credential.get_model_params_setting_form(model.model_name).get_default_form_data()
+ return model_params_setting
+
+
+def _generate_properties(variable_list):
+ return {
+ variable["field"]: {
+ "type": variable["parameter_type"],
+ "description": (variable.get("desc") or ""),
+ "title": variable["label"],
+ }
+ for variable in variable_list
+ }
+
+
+def _generate_example(variable_list):
+ return {variable["field"]: None for variable in variable_list}
+
+
+def _generate_content(input_variable, variable_list):
+ properties = _generate_properties(variable_list)
+ prompt_template = PromptTemplate.from_template(prompt, template_format="jinja2")
+ value = prompt_template.format(properties=properties, question=input_variable)
+ return value
+
+
+def _json_loads(response, variable_list):
+ if not response or not isinstance(response, str):
+ return _generate_example(variable_list)
+
+ cleaned = response.strip()
+
+ extraction_strategies = [
+ lambda: json.loads(cleaned),
+ lambda: json.loads(re.search(r"```(?:json)?\s*(\{.*?\})\s*```", cleaned, re.DOTALL).group(1)),
+ lambda: json.loads(re.search(r"(\{.*\})", cleaned, flags=re.DOTALL).group(1)),
+ ]
+ for strategy in extraction_strategies:
+ try:
+ result = strategy()
+ return result
+ except:
+ continue
+ return _generate_example(variable_list)
+
+
+class ParameterExtractionNode(INode):
+ serializer_class = ParameterExtractionNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "parameter-extraction-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+
+ model_id = node_params.get("model_id")
+ model_id_type = node_params.get("model_id_type", "custom")
+ model_id_reference = node_params.get("model_id_reference")
+ model_params_setting = node_params.get("model_params_setting")
+ input_variable_ref = node_params.get("input_variable")
+ variable_list = node_params.get("variable_list")
+
+ if model_id_type == "reference" and model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ model_id_reference[0],
+ model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ model_id = reference_data.get("model_id", model_id)
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("LLM") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ model_id = default_model_setting.get("model_id", model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ if model_params_setting is None and model_id:
+ model_params_setting = _get_default_model_params_setting(model_id)
+
+ workspace_id = workflow_params.get("workspace_id")
+ chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {}))
+
+ input_variable = self.workflow_manage.get_reference_field(input_variable_ref[0], input_variable_ref[1:])
+
+ input_variable_str = str(input_variable)
+ self.write_context("request", input_variable_str)
+
+ content = _generate_content(input_variable_str, variable_list)
+ self._check_cancelled()
+ response = chat_model.invoke([HumanMessage(content=content)])
+ result = _json_loads(response.content, variable_list)
+
+ self.write_context("result", result)
+ for key, value in result.items():
+ self.write_context(key, value)
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "request": self.get_context("request"),
+ "result": self.get_context("result"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/question_node/__init__.py b/apps/application/workflow/nodes/question_node/__init__.py
new file mode 100644
index 00000000000..e8ddbf43989
--- /dev/null
+++ b/apps/application/workflow/nodes/question_node/__init__.py
@@ -0,0 +1,7 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: __init__.py
+ @desc:
+"""
+from .question_node import QuestionNode
diff --git a/apps/application/workflow/nodes/question_node/question_node.py b/apps/application/workflow/nodes/question_node/question_node.py
new file mode 100644
index 00000000000..7313a1a9f07
--- /dev/null
+++ b/apps/application/workflow/nodes/question_node/question_node.py
@@ -0,0 +1,170 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: question_node.py
+@desc:
+"""
+
+import re
+from functools import reduce
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from langchain_core.messages import HumanMessage, SystemMessage
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+from common.exception.app_exception import AppApiException
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id, get_model_credential
+
+
+class QuestionNodeSerializer(serializers.Serializer):
+ model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+ system = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Role Setting"))
+ prompt = serializers.CharField(required=True, label=_("Prompt word"))
+ dialogue_number = serializers.IntegerField(required=True, label=_("Number of multi-round conversations"))
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+ model_params_setting = serializers.DictField(required=False, label=_("Model parameter settings"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+def _get_default_model_params_setting(model_id):
+ model = QuerySet(Model).filter(id=model_id).first()
+ credential = get_model_credential(model.provider, model.model_type, model.model_name)
+ model_params_setting = credential.get_model_params_setting_form(model.model_name).get_default_form_data()
+ return model_params_setting
+
+
+def _get_history_message(history_chat_record, dialogue_number):
+ start_index = len(history_chat_record) - dialogue_number
+ history_message = reduce(
+ lambda x, y: [*x, *y],
+ [
+ [*history_chat_record[index].get_human_message(), *history_chat_record[index].get_ai_message()]
+ for index in range(start_index if start_index > 0 else 0, len(history_chat_record))
+ ],
+ [],
+ )
+ for message in history_message:
+ if isinstance(message.content, str):
+ message.content = re.sub(r".*?", "", message.content, flags=re.DOTALL)
+ return history_message
+
+
+class QuestionNode(INode):
+ serializer_class = QuestionNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "question-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+
+ model_id = node_params.get("model_id")
+ model_id_type = node_params.get("model_id_type", "custom")
+ model_id_reference = node_params.get("model_id_reference")
+ model_params_setting = node_params.get("model_params_setting")
+ system = node_params.get("system", "")
+ prompt = node_params.get("prompt", "")
+ dialogue_number = node_params.get("dialogue_number", 0)
+ is_result = node_params.get("is_result", False)
+
+ workflow_type = self.get_workflow_type()
+ if workflow_type in (WorkflowType.KNOWLEDGE, WorkflowType.TOOL):
+ history_chat_record = []
+ workspace_id = workflow_params.get("workspace_id")
+ else:
+ history_chat_record = workflow_params.get("history_chat_record", [])
+ workspace_id = workflow_params.get("workspace_id")
+
+ if model_id_type == "reference" and model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ model_id_reference[0],
+ model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ model_id = reference_data.get("model_id", model_id)
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("LLM") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ model_id = default_model_setting.get("model_id", model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ if model_params_setting is None and model_id:
+ model_params_setting = _get_default_model_params_setting(model_id)
+
+ chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {}))
+
+ history_message = _get_history_message(history_chat_record, dialogue_number)
+ self.write_context(
+ "history_message",
+ [{"content": message.content, "role": message.type} for message in (history_message or [])],
+ )
+
+ question = HumanMessage(self.workflow_manage.generate_prompt(prompt))
+ self.write_context("question", question.content)
+
+ system = self.workflow_manage.generate_prompt(system)
+ self.write_context("system", system)
+
+ if system and len(system) > 0:
+ message_list = [SystemMessage(system), *history_message, question]
+ else:
+ message_list = [*history_message, question]
+ self.write_context(
+ "message_list",
+ [{"content": m.content, "role": m.type} for m in message_list],
+ )
+
+ response = chat_model.stream(message_list)
+ answer = ""
+
+ for chunk in response:
+ self._check_cancelled()
+ answer += chunk.content
+
+ message_tokens = chat_model.get_num_tokens_from_messages(message_list)
+ answer_tokens = chat_model.get_num_tokens(answer)
+ self.write_context("message_tokens", message_tokens)
+ self.write_context("answer_tokens", answer_tokens)
+ self.write_context("answer", answer)
+
+ if is_result:
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(TextContent(self.get_node_id(), answer, Status.SUCCESS, node_info, Position(self.get_node_id())))
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "answer": self.get_context("answer"),
+ "system": self.get_context("system"),
+ "message_tokens": self.get_context("message_tokens"),
+ "answer_tokens": self.get_context("answer_tokens"),
+ "history_message": self.get_context("history_message"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/reply_node/__init__.py b/apps/application/workflow/nodes/reply_node/__init__.py
new file mode 100644
index 00000000000..5bdd7388bc7
--- /dev/null
+++ b/apps/application/workflow/nodes/reply_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/7/2 10:00
+ @desc:
+"""
+from .reply_node import ReplyNode
diff --git a/apps/application/workflow/nodes/reply_node/reply_node.py b/apps/application/workflow/nodes/reply_node/reply_node.py
new file mode 100644
index 00000000000..f40b8f53373
--- /dev/null
+++ b/apps/application/workflow/nodes/reply_node/reply_node.py
@@ -0,0 +1,71 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: reply_node.py
+@date:2026/7/2 10:00
+@desc:
+"""
+
+from typing import List
+import uuid_utils.compat as uuid
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+
+
+class ReplyNodeSerializer(serializers.Serializer):
+ reply_type = serializers.CharField(required=True, label=_("Response Type"))
+ fields = serializers.ListField(required=False, label=_("Reference Field"))
+ content = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Direct answer content"))
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+
+
+class ReplyNode(INode):
+ serializer_class = ReplyNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "reply-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ chunk_id = uuid.uuid7()
+
+ reply_type = node_params.get("reply_type")
+ fields = node_params.get("fields")
+ content = node_params.get("content")
+ is_result = node_params.get("is_result", False)
+
+ if reply_type == "referencing":
+ result = self._get_reference_content(fields)
+ else:
+ result = self._generate_reply_content(content)
+
+ self.write_context("answer", result)
+
+ if is_result:
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(TextContent(str(chunk_id), result, Status.SUCCESS, node_info, Position(self.get_node_id())))
+
+ def _generate_reply_content(self, prompt):
+ if prompt is None:
+ return ""
+ return self.workflow_manage.generate_prompt(prompt)
+
+ def _get_reference_content(self, fields: List[str]):
+ if fields and len(fields) >= 2:
+ return str(self.workflow_manage.get_reference_field(fields[0], fields[1:]))
+ return ""
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "answer": self.get_context("answer"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/reranker_node/__init__.py b/apps/application/workflow/nodes/reranker_node/__init__.py
new file mode 100644
index 00000000000..e6adc8ee183
--- /dev/null
+++ b/apps/application/workflow/nodes/reranker_node/__init__.py
@@ -0,0 +1,7 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: __init__.py
+ @desc:
+"""
+from .reranker_node import RerankerNode
diff --git a/apps/application/workflow/nodes/reranker_node/reranker_node.py b/apps/application/workflow/nodes/reranker_node/reranker_node.py
new file mode 100644
index 00000000000..87802a36941
--- /dev/null
+++ b/apps/application/workflow/nodes/reranker_node/reranker_node.py
@@ -0,0 +1,205 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: reranker_node.py
+@desc:
+"""
+
+from typing import List
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from langchain_core.documents import Document
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from common.exception.app_exception import AppApiException
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id
+
+
+class RerankerSettingSerializer(serializers.Serializer):
+ top_n = serializers.IntegerField(required=True, label=_("Reference segment number"))
+ similarity = serializers.FloatField(required=True, max_value=2, min_value=0, label=_("Reference segment number"))
+ max_paragraph_char_number = serializers.IntegerField(
+ required=True, label=_("Maximum number of words in a quoted segment")
+ )
+
+
+class RerankerNodeSerializer(serializers.Serializer):
+ reranker_setting = RerankerSettingSerializer(required=True)
+ question_reference_address = serializers.ListField(required=True)
+ reranker_model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True)
+ reranker_model_id_type = serializers.CharField(required=False, default="custom")
+ reranker_model_id_reference = serializers.ListField(required=False, child=serializers.CharField(), allow_empty=True)
+ reranker_reference_list = serializers.ListField(required=True, child=serializers.ListField(required=True))
+ show_knowledge = serializers.BooleanField(
+ required=True, label=_("The results are displayed in the knowledge sources")
+ )
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("reranker_model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("reranker_model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+def _merge_reranker_list(reranker_list, result=None):
+ if result is None:
+ result = []
+ for document in reranker_list:
+ if isinstance(document, list):
+ _merge_reranker_list(document, result)
+ elif isinstance(document, dict):
+ content = document.get("title", "") + document.get("content", "")
+ title = document.get("title")
+ result.append(
+ Document(
+ page_content=str(document) if len(content) == 0 else content, metadata={"title": title, **document}
+ )
+ )
+ else:
+ result.append(Document(page_content=str(document), metadata={}))
+ return result
+
+
+def _filter_result(document_list: List[Document], max_paragraph_char_number, top_n, similarity):
+ use_len = 0
+ result = []
+ for index in range(len(document_list)):
+ document = document_list[index]
+ if (
+ use_len >= max_paragraph_char_number
+ or index >= top_n
+ or document.metadata.get("relevance_score") < similarity
+ ):
+ break
+ content = document.page_content[0 : max_paragraph_char_number - use_len]
+ use_len = use_len + len(content)
+ result.append({"page_content": content, "metadata": document.metadata})
+ return result
+
+
+def _reset_result_list(result_list: List[Document], document_list: List[Document]):
+ r = []
+ document_list = document_list.copy()
+ for result in result_list:
+ filter_result_list = [document for document in document_list if document.page_content == result.page_content]
+ if len(filter_result_list) > 0:
+ item = filter_result_list[0]
+ document_list.remove(item)
+ r.append(
+ Document(
+ page_content=item.page_content,
+ metadata={**item.metadata, "relevance_score": result.metadata.get("relevance_score")},
+ )
+ )
+ else:
+ r.append(result)
+ return r
+
+
+def _reset_metadata(metadata):
+ meta = metadata.get("meta")
+ if isinstance(metadata.get("meta"), dict):
+ if not meta.get("allow_download", False):
+ metadata["meta"] = {"allow_download": False}
+ return metadata
+
+
+class RerankerNode(INode):
+ serializer_class = RerankerNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.TOOL]
+ type = "reranker-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+
+ question_ref = node_params.get("question_reference_address")
+ reranker_reference_list = node_params.get("reranker_reference_list")
+ reranker_setting = node_params.get("reranker_setting")
+ reranker_model_id = node_params.get("reranker_model_id")
+ reranker_model_id_type = node_params.get("reranker_model_id_type", "custom")
+ reranker_model_id_reference = node_params.get("reranker_model_id_reference")
+ show_knowledge = node_params.get("show_knowledge", False)
+
+ question = self.workflow_manage.get_reference_field(question_ref[0], question_ref[1:])
+ question = str(question)
+
+ reranker_list = [self.workflow_manage.get_reference_field(ref[0], ref[1:]) for ref in reranker_reference_list]
+
+ if reranker_model_id_type == "reference" and reranker_model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ reranker_model_id_reference[0],
+ reranker_model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ reranker_model_id = reference_data.get(
+ "reranker_model_id", reference_data.get("model_id", reranker_model_id)
+ )
+
+ if reranker_model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("RERANKER") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ reranker_model_id = default_model_setting.get("model_id", reranker_model_id)
+
+ if not reranker_model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ self.write_context("show_knowledge", show_knowledge)
+
+ documents = _merge_reranker_list(reranker_list)
+ documents = [d for d in documents if d.page_content and len(d.page_content) > 0]
+
+ if len(documents) == 0:
+ self.write_context("document_list", [])
+ self.write_context("question", question)
+ self.write_context("result_list", [])
+ self.write_context("result", "")
+ return
+
+ top_n = reranker_setting.get("top_n", 3)
+ self.write_context(
+ "document_list",
+ [
+ {"page_content": document.page_content, "metadata": _reset_metadata(document.metadata)}
+ for document in documents
+ ],
+ )
+ self.write_context("question", question)
+
+ workspace_id = workflow_params.get("workspace_id")
+ reranker_model = get_model_instance_by_model_workspace_id(reranker_model_id, workspace_id, top_n=top_n)
+
+ self._check_cancelled()
+ result = reranker_model.compress_documents(documents, question)
+
+ similarity = reranker_setting.get("similarity", 0.6)
+ max_paragraph_char_number = reranker_setting.get("max_paragraph_char_number", 5000)
+
+ result = _reset_result_list(result, documents)
+ r = _filter_result(result, max_paragraph_char_number, top_n, similarity)
+
+ self.write_context("result_list", r)
+ self.write_context("result", "".join([item.get("page_content") for item in r]))
+ self.write_context(
+ "is_hit_handling_method_list", [row for row in r if row.get("metadata").get("is_hit_handling_method")]
+ )
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "result_list": self.get_context("result_list"),
+ "result": self.get_context("result"),
+ "document_list": self.get_context("document_list"),
+ "show_knowledge": self.get_context("show_knowledge"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/search_document_node/__init__.py b/apps/application/workflow/nodes/search_document_node/__init__.py
new file mode 100644
index 00000000000..822ff4d9c8d
--- /dev/null
+++ b/apps/application/workflow/nodes/search_document_node/__init__.py
@@ -0,0 +1,7 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: __init__.py
+ @desc:
+"""
+from .search_document_node import SearchDocumentNode
diff --git a/apps/application/workflow/nodes/search_document_node/search_document_node.py b/apps/application/workflow/nodes/search_document_node/search_document_node.py
new file mode 100644
index 00000000000..2424e96390a
--- /dev/null
+++ b/apps/application/workflow/nodes/search_document_node/search_document_node.py
@@ -0,0 +1,265 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: search_document_node.py
+@desc:
+"""
+
+import jieba
+from django.db.models import Q
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from common.utils.shared_resource_auth import filter_authorized_ids
+from knowledge.models import Document, DocumentTag, Knowledge
+from knowledge.services.retrieval_access import filter_workflow_knowledge
+
+
+class SearchDocumentNodeSerializer(serializers.Serializer):
+ knowledge_id_list = serializers.ListField(
+ required=False, child=serializers.UUIDField(required=True), label=_("knowledge id list"), default=list
+ )
+ search_mode = serializers.ChoiceField(
+ required=False, choices=["auto", "custom"], label=_("search mode"), default="auto"
+ )
+ search_scope_type = serializers.ChoiceField(
+ required=False,
+ choices=["custom", "referencing"],
+ label=_("search scope type"),
+ allow_null=True,
+ default="custom",
+ )
+ search_scope_source = serializers.ChoiceField(
+ required=False, choices=["document", "knowledge"], label=_("search scope variable type"), default="knowledge"
+ )
+ search_scope_reference = serializers.ListField(required=False, label=_("search scope variable"), default=list)
+ question_reference = serializers.ListField(required=False, label=_("question reference address"), default=list)
+ search_condition_type = serializers.ChoiceField(
+ required=False, choices=["AND", "OR"], label=_("search condition type"), default="AND"
+ )
+ search_condition_list = serializers.ListField(required=False, label=_("search condition list"), default=list)
+
+
+def _to_jsonable(value):
+ """将 ORM values() 行中的非 JSON 可序列化类型转为可序列化值。"""
+ import datetime
+ import decimal
+ import uuid
+
+ if isinstance(value, (datetime.datetime, datetime.date)):
+ return value.strftime("%Y-%m-%d %H:%M:%S")
+ if isinstance(value, decimal.Decimal):
+ return float(value)
+ if isinstance(value, uuid.UUID):
+ return str(value)
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="ignore")
+ if isinstance(value, dict):
+ return {k: _to_jsonable(v) for k, v in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_to_jsonable(v) for v in value]
+ return value
+
+
+def _serialize_items(rows):
+ """把 ORM values() 列表转成干净的可 JSON 序列化列表。"""
+ return [_to_jsonable(row) for row in rows]
+
+
+def _handle_auto_tags(workflow_manage, document_id_list, question_reference):
+ question = (
+ workflow_manage.get_reference_field(question_reference[0], question_reference[1:]) if question_reference else ""
+ )
+ keywords = jieba.lcut(str(question))
+ if not keywords:
+ return set()
+
+ q_objects = Q()
+ for keyword in keywords:
+ q_objects |= Q(tag__value__icontains=keyword)
+
+ matched_doc_ids = set(
+ QuerySet(DocumentTag)
+ .filter(document_id__in=document_id_list)
+ .filter(q_objects)
+ .values_list("document_id", flat=True)
+ .distinct()
+ )
+ return matched_doc_ids
+
+
+def _handle_custom_tags(workflow_manage, document_id_list, search_condition_list, search_condition_type):
+ if not search_condition_list:
+ return set(document_id_list)
+
+ if search_condition_type == "AND":
+ matched_doc_ids = set(document_id_list)
+ for condition in search_condition_list:
+ tag_key = condition["key"]
+ field_value = workflow_manage.generate_prompt(condition["value"])
+ compare_type = condition["compare"]
+
+ if not field_value or field_value == "None" or len(field_value) == 0:
+ continue
+
+ if compare_type == "not_contain":
+ exclude_docs = set(
+ QuerySet(DocumentTag)
+ .filter(document_id__in=matched_doc_ids, tag__key=tag_key, tag__value__icontains=field_value)
+ .values_list("document_id", flat=True)
+ .distinct()
+ )
+ matched_doc_ids = matched_doc_ids - exclude_docs
+ else:
+ if compare_type == "contain":
+ q_filter = Q(tag__key=tag_key, tag__value__icontains=field_value)
+ elif compare_type == "eq":
+ q_filter = Q(tag__key=tag_key, tag__value=field_value)
+ else:
+ continue
+
+ tag_docs = set(
+ QuerySet(DocumentTag)
+ .filter(document_id__in=matched_doc_ids)
+ .filter(q_filter)
+ .values_list("document_id", flat=True)
+ .distinct()
+ )
+ matched_doc_ids = matched_doc_ids.intersection(tag_docs)
+
+ return matched_doc_ids
+ else:
+ matched_docs = set()
+ for condition in search_condition_list:
+ tag_key = condition["key"]
+ field_value = workflow_manage.generate_prompt(condition["value"])
+ compare_type = condition["compare"]
+
+ if not field_value or field_value == "None" or len(field_value) == 0:
+ continue
+
+ if compare_type == "not_contain":
+ exclude_docs = set(
+ QuerySet(DocumentTag)
+ .filter(document_id__in=document_id_list, tag__key=tag_key, tag__value__icontains=field_value)
+ .values_list("document_id", flat=True)
+ .distinct()
+ )
+ matched_docs = matched_docs.union(set(document_id_list) - exclude_docs)
+ else:
+ if compare_type == "contain":
+ q_filter = Q(tag__key=tag_key, tag__value__icontains=field_value)
+ elif compare_type == "eq":
+ q_filter = Q(tag__key=tag_key, tag__value=field_value)
+ else:
+ continue
+
+ docs = set(
+ QuerySet(DocumentTag)
+ .filter(document_id__in=document_id_list)
+ .filter(q_filter)
+ .values_list("document_id", flat=True)
+ .distinct()
+ )
+ matched_docs = matched_docs.union(docs)
+
+ return matched_docs
+
+
+class SearchDocumentNode(INode):
+ serializer_class = SearchDocumentNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.TOOL]
+ type = "search-document-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+
+ knowledge_id_list = node_params.get("knowledge_id_list", [])
+ search_mode = node_params.get("search_mode", "auto")
+ search_scope_type = node_params.get("search_scope_type", "custom")
+ search_scope_source = node_params.get("search_scope_source", "knowledge")
+ search_scope_reference = node_params.get("search_scope_reference", [])
+ question_reference = node_params.get("question_reference", [])
+ search_condition_type = node_params.get("search_condition_type", "AND")
+ search_condition_list = node_params.get("search_condition_list", [])
+
+ workspace_id = workflow_params.get("workspace_id")
+
+ if search_scope_type == "custom":
+ knowledge_id_list = filter_authorized_ids("knowledge", knowledge_id_list, workspace_id)
+ document_id_list = list(
+ QuerySet(Document).filter(knowledge_id__in=knowledge_id_list).values_list("id", flat=True)
+ )
+ else:
+ if search_scope_source == "document":
+ document_id_list = (
+ self.workflow_manage.get_reference_field(search_scope_reference[0], search_scope_reference[1:])
+ if search_scope_reference
+ else []
+ )
+ else:
+ ref_knowledge_ids = (
+ self.workflow_manage.get_reference_field(search_scope_reference[0], search_scope_reference[1:])
+ if search_scope_reference
+ else []
+ )
+ ref_knowledge_ids = filter_authorized_ids("knowledge", ref_knowledge_ids, workspace_id)
+ document_id_list = list(
+ QuerySet(Document).filter(knowledge_id__in=ref_knowledge_ids).values_list("id", flat=True)
+ )
+
+ actual_knowledge_ids = list(
+ QuerySet(Document)
+ .filter(id__in=document_id_list or [], is_active=True)
+ .values_list("knowledge_id", flat=True)
+ .distinct()
+ )
+ authorized_knowledge_ids = filter_workflow_knowledge(
+ filter_authorized_ids("knowledge", actual_knowledge_ids, workspace_id), workflow_params
+ )
+ document_id_list = list(
+ QuerySet(Document)
+ .filter(id__in=document_id_list or [], knowledge_id__in=authorized_knowledge_ids, is_active=True)
+ .values_list("id", flat=True)
+ )
+
+ if search_mode == "auto":
+ matched_doc_ids = _handle_auto_tags(self.workflow_manage, document_id_list, question_reference)
+ final_document_ids = list(matched_doc_ids)
+ else:
+ matched_document_ids = _handle_custom_tags(
+ self.workflow_manage, document_id_list, search_condition_list, search_condition_type
+ )
+ final_document_ids = list(matched_document_ids)
+
+ final_document_ids = [str(doc_id) for doc_id in final_document_ids]
+ authorized_knowledge_ids = filter_workflow_knowledge(authorized_knowledge_ids, workflow_params)
+ document_items = list(
+ QuerySet(Document)
+ .filter(id__in=final_document_ids, knowledge_id__in=authorized_knowledge_ids, is_active=True)
+ .values()
+ )
+ final_document_ids = [str(doc["id"]) for doc in document_items]
+ final_knowledge_ids = list(set(str(doc["knowledge_id"]) for doc in document_items))
+ knowledge_items = list(QuerySet(Knowledge).filter(id__in=final_knowledge_ids).values())
+
+ self.write_context("document_list", final_document_ids)
+ self.write_context("document_items", _serialize_items(document_items))
+ self.write_context("knowledge_list", final_knowledge_ids)
+ self.write_context("knowledge_items", _serialize_items(knowledge_items))
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "document_list": self.get_context("document_list"),
+ "document_items": self.get_context("document_items"),
+ "knowledge_list": self.get_context("knowledge_list"),
+ "knowledge_items": self.get_context("knowledge_items"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/search_knowledge_node/__init__.py b/apps/application/workflow/nodes/search_knowledge_node/__init__.py
new file mode 100644
index 00000000000..2ce5f1602b2
--- /dev/null
+++ b/apps/application/workflow/nodes/search_knowledge_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: __init__.py
+ @date:2026/7/2 10:00
+ @desc:
+"""
+from .search_knowledge_node import SearchKnowledgeNode
diff --git a/apps/application/workflow/nodes/search_knowledge_node/search_knowledge_node.py b/apps/application/workflow/nodes/search_knowledge_node/search_knowledge_node.py
new file mode 100644
index 00000000000..ebcadfd528b
--- /dev/null
+++ b/apps/application/workflow/nodes/search_knowledge_node/search_knowledge_node.py
@@ -0,0 +1,361 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: search_knowledge_node.py
+@date:2026/7/2 10:00
+@desc:
+"""
+
+import os
+import re
+from typing import List, Dict
+
+from django.core import validators
+from django.db import connection
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from common.config.embedding_config import VectorStore
+from common.db.search import native_search
+from common.utils.common import flat_map, get_file_content
+from common.utils.shared_resource_auth import filter_authorized_ids
+from knowledge.models import Document, Paragraph, Knowledge, SearchMode, SourceType
+from knowledge.services.multimodal_retrieval import get_hit_asset_map
+from knowledge.services.retrieval_stats import get_recall_tracker, record_recall_safely
+from knowledge.services.retrieval_access import filter_workflow_knowledge
+from maxkb.conf import PROJECT_DIR
+from models_provider.tools import get_model_instance_by_model_workspace_id
+
+
+class DatasetSettingSerializer(serializers.Serializer):
+ top_n = serializers.IntegerField(required=True, label=_("Reference segment number"))
+ similarity = serializers.FloatField(required=True, max_value=2, min_value=0, label=_("similarity"))
+ search_mode = serializers.CharField(
+ required=True,
+ validators=[
+ validators.RegexValidator(
+ regex=re.compile("^embedding|keywords|blend$"),
+ message=_("The type only supports embedding|keywords|blend"),
+ code=500,
+ )
+ ],
+ label=_("Retrieval Mode"),
+ )
+ max_paragraph_char_number = serializers.IntegerField(
+ required=True, label=_("Maximum number of words in a quoted segment")
+ )
+
+
+class SearchKnowledgeNodeSerializer(serializers.Serializer):
+ knowledge_id_list = serializers.ListField(
+ required=True, child=serializers.UUIDField(required=True), label=_("Dataset id list")
+ )
+ knowledge_setting = DatasetSettingSerializer(required=True)
+ question_reference_address = serializers.ListField(required=True)
+ show_knowledge = serializers.BooleanField(
+ required=True, label=_("The results are displayed in the knowledge sources")
+ )
+ search_scope_type = serializers.ChoiceField(
+ required=False,
+ choices=["custom", "referencing"],
+ label=_("search scope type"),
+ allow_null=True,
+ default="custom",
+ )
+ search_scope_source = serializers.ChoiceField(
+ required=False, choices=["document", "knowledge"], label=_("search scope variable type"), default="knowledge"
+ )
+ search_scope_reference = serializers.ListField(required=False, label=_("search scope variable"), default=list)
+
+
+def _get_paragraph_list(chat_record, node_id):
+ return flat_map(
+ [
+ chat_record.details[key].get("paragraph_list", [])
+ for key in chat_record.details
+ if (chat_record.details[key].get("type", "") == "search-dataset-node")
+ and chat_record.details[key].get("paragraph_list", []) is not None
+ and key == node_id
+ ]
+ )
+
+
+def _get_embedding_id(dataset_id_list):
+ dataset_list = QuerySet(Knowledge).filter(id__in=dataset_id_list)
+ if len(set([dataset.embedding_model_id for dataset in dataset_list])) > 1:
+ raise Exception("关联知识库的向量模型不一致,无法召回分段。")
+ if len(dataset_list) == 0:
+ raise Exception("知识库设置错误,请重新设置知识库")
+ return dataset_list[0].embedding_model_id
+
+
+def _reset_title(title):
+ if title is None or len(title.strip()) == 0:
+ return ""
+ else:
+ return f"#### {title}\n"
+
+
+def _reset_meta(meta):
+ if not meta.get("allow_download", False):
+ return {"allow_download": False}
+ return meta
+
+
+def _asset_retrieval_text(asset: Dict | None) -> str:
+ if not asset:
+ return ""
+ return "\n".join(
+ str(value).strip()
+ for value in (asset.get("caption"), asset.get("ocr_text"), asset.get("description"))
+ if value and str(value).strip()
+ )
+
+
+def _reset_paragraph(paragraph: Dict, embedding_list: List, hit_asset_map: Dict = None):
+ filter_embedding_list = [
+ embedding for embedding in embedding_list if str(embedding.get("paragraph_id")) == str(paragraph.get("id"))
+ ]
+ if filter_embedding_list is not None and len(filter_embedding_list) > 0:
+ find_embedding = filter_embedding_list[-1]
+ source_id = find_embedding.get("source_id")
+ source_type = find_embedding.get("source_type")
+ is_image_hit = str(source_type) == str(SourceType.IMAGE.value)
+ embedding_meta = find_embedding.get("meta") or {}
+ hit_unit_type = embedding_meta.get("unit_type") or ("image" if is_image_hit else "text")
+ hit_asset = (hit_asset_map or {}).get(str(source_id)) if is_image_hit else None
+ asset_text = _asset_retrieval_text(hit_asset)
+ retrieval_content = "\n".join(value for value in (paragraph.get("content") or "", asset_text) if value)
+ return {
+ **paragraph,
+ "similarity": find_embedding.get("similarity"),
+ "comprehensive_score": find_embedding.get("comprehensive_score"),
+ "source_id": source_id,
+ "source_type": source_type,
+ "hit_unit_type": hit_unit_type,
+ "query_unit_type": find_embedding.get("query_unit_type"),
+ "query_unit_index": find_embedding.get("query_unit_index"),
+ "hit_asset": hit_asset,
+ "retrieval_content": retrieval_content,
+ "is_hit_handling_method": find_embedding.get("similarity") > paragraph.get("directly_return_similarity")
+ and paragraph.get("hit_handling_method") == "directly_return",
+ "update_time": paragraph.get("update_time").strftime("%Y-%m-%d %H:%M:%S"),
+ "create_time": paragraph.get("create_time").strftime("%Y-%m-%d %H:%M:%S"),
+ "id": str(paragraph.get("id")),
+ "knowledge_id": str(paragraph.get("knowledge_id")),
+ "document_id": str(paragraph.get("document_id")),
+ "meta": _reset_meta(paragraph.get("meta")),
+ }
+
+
+def _get_recalled_image_list(paragraph_list: List[Dict]) -> List[Dict]:
+ image_list = []
+ seen_file_ids = set()
+ for paragraph in paragraph_list:
+ hit_asset = paragraph.get("hit_asset")
+ if not hit_asset:
+ continue
+ file_id = str(hit_asset.get("file_id") or "")
+ if not file_id or file_id in seen_file_ids:
+ continue
+ seen_file_ids.add(file_id)
+ image_list.append(hit_asset)
+ return image_list
+
+
+def _record_recalled_items(embedding_list: List[Dict], paragraph_list: List[Dict], workflow_manage, debug=False):
+ if debug:
+ return
+ recalled_paragraph_ids = {str(paragraph.get("id")) for paragraph in paragraph_list}
+ recalled_items = [
+ embedding for embedding in embedding_list if str(embedding.get("paragraph_id")) in recalled_paragraph_ids
+ ]
+ record_recall_safely(recalled_items, tracker=get_recall_tracker(workflow_manage))
+
+
+def _list_paragraph(embedding_list: List, vector, knowledge_ids=None, document_ids=None):
+ paragraph_id_list = [row.get("paragraph_id") for row in embedding_list]
+ if paragraph_id_list is None or len(paragraph_id_list) == 0:
+ return []
+ query = QuerySet(Paragraph).filter(id__in=paragraph_id_list)
+ if knowledge_ids is not None:
+ query = query.filter(
+ knowledge_id__in=knowledge_ids,
+ is_active=True,
+ document_id__in=QuerySet(Document).filter(knowledge_id__in=knowledge_ids, is_active=True).values("id"),
+ )
+ if document_ids is not None:
+ query = query.filter(document_id__in=document_ids)
+ paragraph_list = native_search(
+ query,
+ get_file_content(
+ os.path.join(PROJECT_DIR, "apps", "application", "sql", "list_knowledge_paragraph_by_paragraph_id.sql")
+ ),
+ with_table_name=True,
+ )
+ if knowledge_ids is None and len(paragraph_list) != len(paragraph_id_list):
+ exist_paragraph_list = [row.get("id") for row in paragraph_list]
+ for paragraph_id in paragraph_id_list:
+ if paragraph_id not in exist_paragraph_list:
+ vector.delete_by_paragraph_id(paragraph_id)
+ return paragraph_list
+
+
+class SearchKnowledgeNode(INode):
+ serializer_class = SearchKnowledgeNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.TOOL]
+ type = "search-knowledge-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+
+ knowledge_id_list = node_params.get("knowledge_id_list", [])
+ knowledge_setting = node_params.get("knowledge_setting", {})
+ question_reference_address = node_params.get("question_reference_address", [])
+ show_knowledge = node_params.get("show_knowledge", False)
+ search_scope_type = node_params.get("search_scope_type", "custom")
+ search_scope_source = node_params.get("search_scope_source", "knowledge")
+ search_scope_reference = node_params.get("search_scope_reference", [])
+
+ question = str(
+ self.workflow_manage.get_reference_field(question_reference_address[0], question_reference_address[1:])
+ )
+
+ exclude_paragraph_id_list = []
+ if workflow_params.get("re_chat", False):
+ history_chat_record = workflow_params.get("history_chat_record", [])
+ paragraph_id_list = [
+ p.get("id")
+ for p in flat_map(
+ [
+ _get_paragraph_list(chat_record, self.get_node_id())
+ for chat_record in history_chat_record
+ if chat_record.problem_text == question
+ ]
+ )
+ ]
+ exclude_paragraph_id_list = list(set(paragraph_id_list))
+
+ self.write_context("question", question)
+ self.write_context("show_knowledge", show_knowledge)
+
+ document_id_list = None
+ if search_scope_type == "referencing":
+ if search_scope_source == "knowledge":
+ knowledge_id_list = self._get_reference_content(search_scope_reference)
+ else:
+ document_id_list = self._get_reference_content(search_scope_reference)
+ knowledge_id_list = [
+ str(k)
+ for k in QuerySet(Document)
+ .filter(id__in=document_id_list)
+ .values_list("knowledge_id", flat=True)
+ .distinct()
+ ]
+
+ workspace_id = workflow_params.get("workspace_id")
+ knowledge_id_list = filter_authorized_ids("knowledge", knowledge_id_list, workspace_id)
+ knowledge_id_list = filter_workflow_knowledge(knowledge_id_list, workflow_params)
+
+ if len(knowledge_id_list) == 0 or document_id_list == []:
+ self._write_empty_result(question)
+ return
+
+ model_id = _get_embedding_id(knowledge_id_list)
+ self._check_cancelled()
+ embedding_model = get_model_instance_by_model_workspace_id(model_id, workspace_id)
+ embedding_value = embedding_model.embed_query(question)
+ vector = VectorStore.get_embedding_vector()
+
+ exclude_document_id_list = [
+ str(document.id)
+ for document in QuerySet(Document).filter(knowledge_id__in=knowledge_id_list, is_active=False)
+ ]
+
+ self._check_cancelled()
+ embedding_list = vector.query(
+ question,
+ embedding_value,
+ knowledge_id_list,
+ document_id_list,
+ exclude_document_id_list,
+ exclude_paragraph_id_list,
+ True,
+ knowledge_setting.get("top_n"),
+ knowledge_setting.get("similarity"),
+ SearchMode(knowledge_setting.get("search_mode")),
+ )
+
+ connection.close()
+
+ if embedding_list is None:
+ self._write_empty_result(question)
+ return
+
+ knowledge_id_list = filter_workflow_knowledge(knowledge_id_list, workflow_params)
+ paragraph_list = _list_paragraph(embedding_list, vector, knowledge_id_list, document_id_list)
+ hit_asset_map = get_hit_asset_map(embedding_list)
+ result = [
+ reset_paragraph
+ for paragraph in paragraph_list
+ if (reset_paragraph := _reset_paragraph(paragraph, embedding_list, hit_asset_map)) is not None
+ ]
+ result = sorted(result, key=lambda p: p.get("similarity"), reverse=True)
+ image_list = _get_recalled_image_list(result)
+
+ _record_recalled_items(embedding_list, result, self.workflow_manage, workflow_params.get("debug", False))
+
+ self.write_context("paragraph_list", result)
+ self.write_context("image_list", image_list)
+ self.write_context("is_hit_handling_method_list", [row for row in result if row.get("is_hit_handling_method")])
+ self.write_context(
+ "data",
+ "\n".join(
+ [
+ f"{_reset_title(paragraph.get('title', ''))}"
+ f"{paragraph.get('retrieval_content', paragraph.get('content'))}"
+ for paragraph in result
+ ]
+ )[0 : knowledge_setting.get("max_paragraph_char_number", 5000)],
+ )
+ self.write_context(
+ "directly_return",
+ "\n".join(
+ [
+ paragraph.get("retrieval_content", paragraph.get("content"))
+ for paragraph in result
+ if paragraph.get("is_hit_handling_method")
+ ]
+ ),
+ )
+
+ def _write_empty_result(self, question):
+ self.write_context("paragraph_list", [])
+ self.write_context("image_list", [])
+ self.write_context("is_hit_handling_method_list", [])
+ self.write_context("data", "")
+ self.write_context("directly_return", "")
+ self.write_context("question", question)
+
+ def _get_reference_content(self, fields: List[str]):
+ if fields:
+ return self.workflow_manage.get_reference_field(fields[0], fields[1:])
+ return None
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "paragraph_list": self.get_context("paragraph_list"),
+ "image_list": self.get_context("image_list"),
+ "data": self.get_context("data"),
+ "show_knowledge": self.get_context("show_knowledge"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/speech_to_text_node/__init__.py b/apps/application/workflow/nodes/speech_to_text_node/__init__.py
new file mode 100644
index 00000000000..ffbe9f1673b
--- /dev/null
+++ b/apps/application/workflow/nodes/speech_to_text_node/__init__.py
@@ -0,0 +1,7 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: __init__.py
+ @desc:
+"""
+from .speech_to_text_node import SpeechToTextNode
diff --git a/apps/application/workflow/nodes/speech_to_text_node/speech_to_text_node.py b/apps/application/workflow/nodes/speech_to_text_node/speech_to_text_node.py
new file mode 100644
index 00000000000..37b194d5535
--- /dev/null
+++ b/apps/application/workflow/nodes/speech_to_text_node/speech_to_text_node.py
@@ -0,0 +1,145 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: speech_to_text_node.py
+@desc:
+"""
+
+import os
+import tempfile
+from concurrent.futures import ThreadPoolExecutor
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+from common.utils.common import split_and_transcribe, any_to_mp3
+from common.exception.app_exception import AppApiException
+from knowledge.models import File
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id
+
+
+class SpeechToTextNodeSerializer(serializers.Serializer):
+ stt_model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ stt_model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ stt_model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+ audio_list = serializers.ListField(required=True, label=_("The audio file cannot be empty"))
+ model_params_setting = serializers.DictField(required=False, label=_("Model parameter settings"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("stt_model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("stt_model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+def _process_audio_item(audio_item, model):
+ file = QuerySet(File).filter(id=audio_item["file_id"]).first()
+ file_format = file.file_name.split(".")[-1]
+ with tempfile.NamedTemporaryFile(delete=False, suffix=f".{file_format}") as temp_file:
+ temp_file.write(file.get_bytes())
+ temp_file_path = temp_file.name
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as temp_amr_file:
+ temp_mp3_path = temp_amr_file.name
+ any_to_mp3(temp_file_path, temp_mp3_path)
+ try:
+ transcription = split_and_transcribe(temp_mp3_path, model)
+ return {file.file_name: transcription}
+ finally:
+ os.remove(temp_file_path)
+ os.remove(temp_mp3_path)
+
+
+def _process_audio_items(audio_list, model):
+ with ThreadPoolExecutor(max_workers=5) as executor:
+ results = list(executor.map(lambda item: _process_audio_item(item, model), audio_list))
+ return results
+
+
+class SpeechToTextNode(INode):
+ serializer_class = SpeechToTextNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "speech-to-text-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+
+ stt_model_id = node_params.get("stt_model_id")
+ stt_model_id_type = node_params.get("stt_model_id_type", "custom")
+ stt_model_id_reference = node_params.get("stt_model_id_reference")
+ model_params_setting = node_params.get("model_params_setting")
+ audio_list_ref = node_params.get("audio_list")
+ is_result = node_params.get("is_result", False)
+
+ audio_list = self.workflow_manage.get_reference_field(audio_list_ref[0], audio_list_ref[1:])
+ for audio in audio_list:
+ if "file_id" not in audio:
+ raise ValueError(
+ _("Parameter value error: The uploaded audio lacks file_id, and the audio upload fails")
+ )
+
+ if stt_model_id_type == "reference" and stt_model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ stt_model_id_reference[0],
+ stt_model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ stt_model_id = reference_data.get("stt_model_id", reference_data.get("model_id", stt_model_id))
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if stt_model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("STT") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ stt_model_id = default_model_setting.get("model_id", stt_model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not stt_model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ workspace_id = workflow_params.get("workspace_id")
+ stt_model = get_model_instance_by_model_workspace_id(stt_model_id, workspace_id, **(model_params_setting or {}))
+
+ self.write_context("audio_list", audio_list)
+
+ self._check_cancelled()
+ result = _process_audio_items(audio_list, stt_model)
+ content = []
+ result_content = []
+ for item in result:
+ for key, value in item.items():
+ content.append(f"### {key}\n{value}")
+ result_content.append(value)
+
+ answer = "\n".join(result_content)
+ self.write_context("answer", answer)
+ self.write_context("result", answer)
+ self.write_context("content", content)
+
+ if is_result:
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(TextContent(self.get_node_id(), answer, Status.SUCCESS, node_info, Position(self.get_node_id())))
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "answer": self.get_context("answer"),
+ "result": self.get_context("result"),
+ "content": self.get_context("content"),
+ "audio_list": self.get_context("audio_list"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/start_node/__init__.py b/apps/application/workflow/nodes/start_node/__init__.py
new file mode 100644
index 00000000000..21960a776ac
--- /dev/null
+++ b/apps/application/workflow/nodes/start_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎
+ @file: __init__.py.py
+ @date:2026/6/29 10:59
+ @desc:
+"""
+from .start_node import StarNode
diff --git a/apps/application/workflow/nodes/start_node/start_node.py b/apps/application/workflow/nodes/start_node/start_node.py
new file mode 100644
index 00000000000..e9a863aef76
--- /dev/null
+++ b/apps/application/workflow/nodes/start_node/start_node.py
@@ -0,0 +1,117 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: start_node.py
+@date:2026/7/1 16:59
+@desc:
+"""
+
+import time
+from typing import List
+
+from django.db.models import QuerySet
+from django.utils import timezone
+from rest_framework import serializers
+
+from application.models.application_chat import ApplicationLongTermMemory
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.status import Status
+
+
+def get_default_global_variable(input_field_list: List):
+ return {
+ item.get("variable") or item.get("field"): item.get("default_value")
+ for item in input_field_list
+ if item.get("default_value", None) is not None
+ }
+
+
+class ApplicationSerializer(serializers.Serializer):
+ chat_id = serializers.UUIDField(required=True, label="对话id")
+ user_id = serializers.UUIDField(required=True, label="用户id")
+ chat_record_id = serializers.UUIDField(required=True, label="对话记录id")
+ messages = serializers.ListField(required=True, label="上下文数据")
+
+
+class StarNode(INode):
+ supported_workflow_type_list = [WorkflowType.APPLICATION]
+ type = "start-node"
+
+ def execute(self):
+ workflow_params = self.get_workflow_parameters()
+ base_node = self.workflow_manage.workflow.get_node("base-node")
+
+ user_input_field_list = base_node.properties.get("user_input_field_list", []) if base_node else []
+ api_input_field_list = base_node.properties.get("api_input_field_list", []) if base_node else []
+ default_global = get_default_global_variable(user_input_field_list)
+ default_api_global = get_default_global_variable(api_input_field_list)
+
+ history_chat_record = workflow_params.get("history_chat_record", [])
+ history_context = [{"question": r.problem_text, "answer": r.answer_text} for r in history_chat_record]
+
+ chat_id = workflow_params.get("chat_id")
+ chat_user_id = workflow_params.get("chat_user_id")
+
+ memory = ""
+ if chat_user_id:
+ long_term_memory = (
+ QuerySet(ApplicationLongTermMemory)
+ .filter(chat_user_id=chat_user_id, application_id=workflow_params.get("application_id"))
+ .first()
+ )
+ if long_term_memory:
+ memory = long_term_memory.memory
+
+ workflow_variable = {
+ **default_global,
+ **default_api_global,
+ "time": timezone.localtime(timezone.now()).strftime("%Y-%m-%d %H:%M:%S"),
+ "start_time": time.time(),
+ "history_context": history_context,
+ "chat_id": str(chat_id) if chat_id else None,
+ "chat_user_id": chat_user_id,
+ "chat_user_type": workflow_params.get("chat_user_type"),
+ "chat_user": workflow_params.get("chat_user"),
+ "chat_user_group": workflow_params.get("chat_user_group"),
+ "memory": memory,
+ }
+
+ question = workflow_params.get("question", "")
+ node_variable = {
+ "question": question,
+ "image": workflow_params.get("image_list", []),
+ "document": workflow_params.get("document_list", []),
+ "audio": workflow_params.get("audio_list", []),
+ "video": workflow_params.get("video_list", []),
+ "other": workflow_params.get("other_list", []),
+ "memory": memory,
+ }
+
+ for key, value in node_variable.items():
+ self.write_context(key, value)
+
+ # 全局变量统一放进 context['global'],与 reset_variable / get_reference_field 的引用约定一致
+ for key, value in workflow_variable.items():
+ self.workflow_manage.write_context("global", key, value)
+
+ config = self.node.properties.get("config", {})
+ if config:
+ for field in config.get("globalFields", []):
+ key = field.get("value")
+ if key:
+ self.workflow_manage.write_context("global", key, workflow_variable.get(key, ""))
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "image": self.get_context("image"),
+ "document": self.get_context("document"),
+ "audio": self.get_context("audio"),
+ "video": self.get_context("video"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/text_to_speech_node/__init__.py b/apps/application/workflow/nodes/text_to_speech_node/__init__.py
new file mode 100644
index 00000000000..37c432059c0
--- /dev/null
+++ b/apps/application/workflow/nodes/text_to_speech_node/__init__.py
@@ -0,0 +1,7 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: __init__.py
+ @desc:
+"""
+from .text_to_speech_node import TextToSpeechNode
diff --git a/apps/application/workflow/nodes/text_to_speech_node/text_to_speech_node.py b/apps/application/workflow/nodes/text_to_speech_node/text_to_speech_node.py
new file mode 100644
index 00000000000..79e2ad9d8cc
--- /dev/null
+++ b/apps/application/workflow/nodes/text_to_speech_node/text_to_speech_node.py
@@ -0,0 +1,204 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@file: text_to_speech_node.py
+@desc:
+"""
+
+import io
+import mimetypes
+
+from django.core.files.uploadedfile import InMemoryUploadedFile
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from pydub import AudioSegment
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+from common.exception.app_exception import AppApiException
+from common.utils.common import _remove_empty_lines
+from knowledge.models import FileSourceType
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id
+from oss.serializers.file import FileSerializer
+
+
+class TextToSpeechNodeSerializer(serializers.Serializer):
+ tts_model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ tts_model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ tts_model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+ content_list = serializers.ListField(required=True, label=_("Text content"))
+ model_params_setting = serializers.DictField(required=False, label=_("Model parameter settings"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("tts_model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("tts_model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+def _bytes_to_uploaded_file(file_bytes, file_name="generated_audio.mp3"):
+ content_type, _ = mimetypes.guess_type(file_name)
+ if content_type is None:
+ content_type = "application/octet-stream"
+ file_stream = io.BytesIO(file_bytes)
+ file_size = len(file_bytes)
+ uploaded_file = InMemoryUploadedFile(
+ file=file_stream,
+ field_name=None,
+ name=file_name,
+ content_type=content_type,
+ size=file_size,
+ charset=None,
+ )
+ return uploaded_file
+
+
+class TextToSpeechNode(INode):
+ serializer_class = TextToSpeechNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "text-to-speech-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+ workflow_type = self.get_workflow_type()
+
+ tts_model_id = node_params.get("tts_model_id")
+ tts_model_id_type = node_params.get("tts_model_id_type", "custom")
+ tts_model_id_reference = node_params.get("tts_model_id_reference")
+ model_params_setting = node_params.get("model_params_setting")
+ content_list_ref = node_params.get("content_list")
+ is_result = node_params.get("is_result", False)
+
+ content = (
+ self.workflow_manage.get_reference_field(content_list_ref[0], content_list_ref[1:])
+ if content_list_ref
+ else ""
+ )
+
+ if tts_model_id_type == "reference" and tts_model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ tts_model_id_reference[0],
+ tts_model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ tts_model_id = reference_data.get("tts_model_id", reference_data.get("model_id", tts_model_id))
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if tts_model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("TTS") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ tts_model_id = default_model_setting.get("model_id", tts_model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not tts_model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ content = _remove_empty_lines(str(content))
+ max_length = 1024
+ content_chunks = [content[i : i + max_length] for i in range(0, len(content), max_length)]
+
+ audio_segments = []
+ temp_files = []
+
+ for chunk in content_chunks:
+ self._check_cancelled()
+ self.write_context("content", chunk)
+ workspace_id = workflow_params.get("workspace_id")
+ model = get_model_instance_by_model_workspace_id(tts_model_id, workspace_id, **(model_params_setting or {}))
+ audio_byte = model.text_to_speech(chunk)
+ temp_file = io.BytesIO(audio_byte)
+ audio_segment = AudioSegment.from_file(temp_file)
+ audio_segments.append(audio_segment)
+ temp_files.append(temp_file)
+
+ combined_audio = AudioSegment.empty()
+ for segment in audio_segments:
+ combined_audio += segment
+
+ output_buffer = io.BytesIO()
+ combined_audio.export(output_buffer, format="mp3")
+ combined_bytes = output_buffer.getvalue()
+ file_name = "combined_audio.mp3"
+ file = _bytes_to_uploaded_file(combined_bytes, file_name)
+ file_url = self._upload_file(file, workflow_params, workflow_type)
+
+ file_id = file_url.split("/")[-1]
+ audio_list = [{"file_id": file_id, "file_name": file_name, "url": file_url}]
+
+ for temp_file in temp_files:
+ temp_file.close()
+ output_buffer.close()
+
+ audio_label = f''
+ self.write_context("answer", audio_label)
+ self.write_context("result", audio_list)
+
+ if is_result:
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(
+ TextContent(self.get_node_id(), audio_label, Status.SUCCESS, node_info, Position(self.get_node_id()))
+ )
+
+ def _upload_file(self, file, workflow_params, workflow_type):
+ if workflow_type == WorkflowType.KNOWLEDGE:
+ return self._upload_knowledge_file(file, workflow_params)
+ if workflow_type == WorkflowType.TOOL:
+ return self._upload_tool_file(file, workflow_params)
+ return self._upload_application_file(file, workflow_params)
+
+ def _upload_knowledge_file(self, file, workflow_params):
+ knowledge_id = workflow_params.get("knowledge_id")
+ return FileSerializer(
+ data={
+ "file": file,
+ "meta": {"debug": False, "knowledge_id": knowledge_id},
+ "source_id": knowledge_id,
+ "source_type": FileSourceType.KNOWLEDGE.value,
+ }
+ ).upload()
+
+ def _upload_tool_file(self, file, workflow_params):
+ tool_id = workflow_params.get("tool_id")
+ return FileSerializer(
+ data={
+ "file": file,
+ "meta": {"debug": False, "tool_id": tool_id},
+ "source_id": tool_id,
+ "source_type": FileSourceType.TOOL.value,
+ }
+ ).upload()
+
+ def _upload_application_file(self, file, workflow_params):
+ application_id = workflow_params.get("application_id")
+ chat_id = workflow_params.get("chat_id")
+ return FileSerializer(
+ data={
+ "file": file,
+ "meta": {"debug": False, "chat_id": chat_id, "application_id": application_id},
+ "source_id": application_id,
+ "source_type": FileSourceType.APPLICATION.value,
+ }
+ ).upload()
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "content": self.get_context("content"),
+ "answer": self.get_context("answer"),
+ "result": self.get_context("result"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/text_to_video_node/__init__.py b/apps/application/workflow/nodes/text_to_video_node/__init__.py
new file mode 100644
index 00000000000..3c4dac4c599
--- /dev/null
+++ b/apps/application/workflow/nodes/text_to_video_node/__init__.py
@@ -0,0 +1,2 @@
+# coding=utf-8
+from .text_to_video_node import TextToVideoNode
diff --git a/apps/application/workflow/nodes/text_to_video_node/text_to_video_node.py b/apps/application/workflow/nodes/text_to_video_node/text_to_video_node.py
new file mode 100644
index 00000000000..c4ad6b989db
--- /dev/null
+++ b/apps/application/workflow/nodes/text_to_video_node/text_to_video_node.py
@@ -0,0 +1,251 @@
+# coding=utf-8
+import uuid_utils.compat as uuid
+import requests
+from functools import reduce
+from typing import List
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _, gettext
+from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+from common.exception.app_exception import AppApiException
+from common.utils.common import bytes_to_uploaded_file
+from knowledge.models import FileSourceType
+from oss.serializers.file import FileSerializer
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id
+from common.utils.logger import maxkb_logger
+
+
+class TextToVideoNodeSerializer(serializers.Serializer):
+ model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+ prompt = serializers.CharField(required=True, label=_("Prompt word (positive)"))
+ negative_prompt = serializers.CharField(
+ required=False, label=_("Prompt word (negative)"), allow_null=True, allow_blank=True
+ )
+ dialogue_number = serializers.IntegerField(
+ required=False, default=0, label=_("Number of multi-round conversations")
+ )
+ dialogue_type = serializers.CharField(required=False, default="NODE", label=_("Conversation storage type"))
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+ model_params_setting = serializers.JSONField(required=False, default=dict, label=_("Model parameter settings"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+class TextToVideoNode(INode):
+ serializer_class = TextToVideoNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "text-to-video-node"
+
+ def execute(self):
+ maxkb_logger.info(f"[TextToVideoNode] execute START, node_id={self.get_node_id()}")
+ workflow_params = self.get_workflow_parameters()
+ node_params = self.get_parameters()
+
+ model_id = node_params.get("model_id")
+ model_id_type = node_params.get("model_id_type", "custom")
+ model_id_reference = node_params.get("model_id_reference")
+ prompt = node_params.get("prompt", "")
+ negative_prompt = node_params.get("negative_prompt", "")
+ dialogue_number = node_params.get("dialogue_number", 0)
+ dialogue_type = node_params.get("dialogue_type", "NODE")
+ is_result = node_params.get("is_result", False)
+ model_params_setting = node_params.get("model_params_setting")
+
+ workflow_type = self.get_workflow_type()
+ if workflow_type in (WorkflowType.KNOWLEDGE, WorkflowType.TOOL):
+ history_chat_record = []
+ chat_id = None
+ chat_record_id = None
+ workspace_id = workflow_params.get("workspace_id")
+ else:
+ history_chat_record = workflow_params.get("history_chat_record", [])
+ chat_id = workflow_params.get("chat_id")
+ chat_record_id = workflow_params.get("chat_record_id")
+ workspace_id = workflow_params.get("workspace_id")
+
+ if model_id_type == "reference" and model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ model_id_reference[0],
+ model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ model_id = reference_data.get("model_id", model_id)
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("TTV") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ model_id = default_model_setting.get("model_id", model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ ttv_model = get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {}))
+
+ history_message = self._get_history_message(history_chat_record, dialogue_number)
+ self.write_context(
+ "history_message",
+ [{"content": message.content, "role": message.type} for message in (history_message or [])],
+ )
+
+ question = self.workflow_manage.generate_prompt(prompt)
+ self.write_context("question", question)
+
+ # message_list = [*history_message, question]
+ # self.write_context("message_list", [{"content": m.content, "role": m.type} for m in message_list],)
+ self.write_context("dialogue_type", dialogue_type)
+ self.write_context("negative_prompt", self.workflow_manage.generate_prompt(negative_prompt))
+
+ self._check_cancelled()
+ video_urls = ttv_model.generate_video(question, negative_prompt)
+ maxkb_logger.info(
+ f"[TextToVideoNode] generate_video result: {video_urls is not None}, node_id={self.get_node_id()}"
+ )
+
+ if video_urls is None or video_urls == "":
+ raise Exception(gettext("Failed to generate video"))
+
+ file_name = "generated_video.mp4"
+ if isinstance(video_urls, str) and video_urls.startswith("http"):
+ video_urls = requests.get(video_urls).content
+
+ file = bytes_to_uploaded_file(video_urls, file_name)
+ file_url = self._upload_file(file, workflow_type, workflow_params)
+
+ video_label = f''
+ video_list = [{"file_id": file_url.split("/")[-1], "file_name": file_name, "url": file_url}]
+
+ self.write_context("answer", video_label)
+ self.write_context("video", video_list)
+ # self.write_context("chat_model", ttv_model)
+
+ if is_result:
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(
+ TextContent(str(uuid.uuid7()), video_label, Status.SUCCESS, node_info, Position(self.get_node_id()))
+ )
+
+ def _upload_file(self, file, workflow_type, workflow_params):
+ if workflow_type == WorkflowType.KNOWLEDGE:
+ return self._upload_knowledge_file(file, workflow_params)
+ if workflow_type == WorkflowType.TOOL:
+ return self._upload_tool_file(file, workflow_params)
+ return self._upload_application_file(file, workflow_params)
+
+ def _upload_knowledge_file(self, file, workflow_params):
+ knowledge_id = workflow_params.get("knowledge_id")
+ meta = {"debug": False, "knowledge_id": knowledge_id}
+ file_url = FileSerializer(
+ data={"file": file, "meta": meta, "source_id": knowledge_id, "source_type": FileSourceType.KNOWLEDGE.value}
+ ).upload()
+ return file_url
+
+ def _upload_tool_file(self, file, workflow_params):
+ tool_id = workflow_params.get("tool_id")
+ meta = {
+ "debug": False,
+ "tool_id": tool_id,
+ }
+ file_url = FileSerializer(
+ data={"file": file, "meta": meta, "source_id": tool_id, "source_type": FileSourceType.TOOL.value}
+ ).upload()
+ return file_url
+
+ def _upload_application_file(self, file, workflow_params):
+ application_id = workflow_params.get("application_id")
+ chat_id = workflow_params.get("chat_id")
+ debug = workflow_params.get("debug", False)
+ meta = {
+ "debug": debug,
+ "chat_id": chat_id,
+ "application_id": application_id,
+ }
+ file_url = FileSerializer(
+ data={
+ "file": file,
+ "meta": meta,
+ "source_id": application_id,
+ "source_type": FileSourceType.APPLICATION.value,
+ }
+ ).upload()
+ return file_url
+
+ def _generate_history_ai_message(self, chat_record):
+ for val in chat_record.details.values():
+ if self.node.id == val["node_id"] and "image_list" in val:
+ if val["dialogue_type"] == "WORKFLOW":
+ return chat_record.get_ai_message()
+ image_list = val["image_list"]
+ return [
+ AIMessage(
+ content=[
+ *[{"type": "image_url", "image_url": {"url": f"{file_url}"}} for file_url in image_list]
+ ]
+ )
+ ]
+ return chat_record.get_ai_message()
+
+ def _get_history_message(self, history_chat_record, dialogue_number):
+ start_index = len(history_chat_record) - dialogue_number
+ history_message = reduce(
+ lambda x, y: [*x, *y],
+ [
+ [
+ self._generate_history_human_message(history_chat_record[index]),
+ *self._generate_history_ai_message(history_chat_record[index]),
+ ]
+ for index in range(start_index if start_index > 0 else 0, len(history_chat_record))
+ ],
+ [],
+ )
+ return history_message
+
+ def _generate_history_human_message(self, chat_record):
+ for data in chat_record.details.values():
+ if self.node.id == data["node_id"] and "image_list" in data:
+ image_list = data["image_list"]
+ if len(image_list) == 0 or data["dialogue_type"] == "WORKFLOW":
+ return HumanMessage(content=chat_record.problem_text)
+ return HumanMessage(content=data["question"])
+ return HumanMessage(content=chat_record.problem_text)
+
+ @staticmethod
+ def reset_message_list(message_list: List[BaseMessage], answer_text):
+ result = [
+ {"role": "user" if isinstance(message, HumanMessage) else "ai", "content": message.content}
+ for message in message_list
+ ]
+ result.append({"role": "ai", "content": answer_text})
+ return result
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "answer": self.get_context("answer"),
+ "video": self.get_context("video"),
+ "negative_prompt": self.get_context("negative_prompt"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/tool_lib_node/__init__.py b/apps/application/workflow/nodes/tool_lib_node/__init__.py
new file mode 100644
index 00000000000..09bdd257c52
--- /dev/null
+++ b/apps/application/workflow/nodes/tool_lib_node/__init__.py
@@ -0,0 +1,10 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎
+@file: __init__.py
+@date:2026/9/3 16:21
+@desc:
+"""
+
+from .tool_lib_node import ToolLibNode
diff --git a/apps/application/workflow/nodes/tool_lib_node/tool_lib_node.py b/apps/application/workflow/nodes/tool_lib_node/tool_lib_node.py
new file mode 100644
index 00000000000..c71d2bf0b3c
--- /dev/null
+++ b/apps/application/workflow/nodes/tool_lib_node/tool_lib_node.py
@@ -0,0 +1,339 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎
+@file: tool_lib_node.py
+@date:2026/9/3 16:21
+@desc:
+"""
+
+import base64
+import io
+import json
+import mimetypes
+import traceback
+
+import uuid_utils.compat as uuid
+from django.core.files.uploadedfile import InMemoryUploadedFile
+from django.db import connection
+from django.db.models import QuerySet
+from django.utils.translation import gettext
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+from common.database_model_manage.database_model_manage import DatabaseModelManage
+from common.exception.app_exception import AppApiException
+from common.field.common import ObjectField
+from common.utils.common import common_convert_value
+from common.utils.logger import maxkb_logger
+from common.utils.rsa_util import rsa_long_decrypt
+from common.utils.tool_code import ToolExecutor
+from knowledge.models import FileSourceType
+from knowledge.models.knowledge_action import State
+from oss.serializers.file import FileSerializer
+from tools.models import Tool, ToolRecord, ToolTaskTypeChoices
+
+function_executor = ToolExecutor()
+
+
+class InputField(serializers.Serializer):
+ name = serializers.CharField(required=True, label=_("Variable Name"))
+ value = ObjectField(required=True, label=_("Variable Value"), model_type_list=[str, list])
+
+
+class ToolLibNodeSerializer(serializers.Serializer):
+ tool_lib_id = serializers.UUIDField(required=True, label=_("Library ID"))
+ input_field_list = InputField(required=True, many=True)
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ f_lib = QuerySet(Tool).filter(id=self.data.get("tool_lib_id")).first()
+ # 归还链接到连接池
+ connection.close()
+ if f_lib is None:
+ raise AppApiException(500, _("Tool has been deleted"))
+ if not f_lib.is_active:
+ raise AppApiException(500, _("Tool is not active"))
+
+
+def get_field_value(debug_field_list, name, is_required):
+ result = [field for field in debug_field_list if field.get("name") == name]
+ if len(result) > 0:
+ return result[-1]["value"]
+ if is_required:
+ raise AppApiException(500, gettext("Field: {name} No value set").format(name=name))
+ return None
+
+
+def valid_reference_value(_type, value, name):
+ if _type == "int":
+ instance_type = int | float
+ elif _type == "boolean":
+ instance_type = bool
+ elif _type == "float":
+ instance_type = float | int
+ elif _type == "dict":
+ value = json.loads(value) if isinstance(value, str) else value
+ instance_type = dict
+ elif _type == "array":
+ value = json.loads(value) if isinstance(value, str) else value
+ instance_type = list
+ elif _type == "string":
+ instance_type = str
+ else:
+ maxkb_logger.error(
+ gettext("Field: {name} Type: {_type} Value: {value} Unsupported this type").format(
+ name=name, _type=_type, value=value
+ )
+ )
+ return value
+ if not isinstance(value, instance_type):
+ raise Exception(
+ gettext("Field: {name} Type: {_type} Value: {value} Type error").format(name=name, _type=_type, value=value)
+ )
+ return value
+
+
+def convert_value(name: str, value, _type, is_required, source, node):
+ if not is_required and (value is None or ((isinstance(value, str) or isinstance(value, list)) and len(value) == 0)):
+ return None
+ if source == "reference":
+ value = node.workflow_manage.get_reference_field(value[0], value[1:])
+ if value is None:
+ if not is_required:
+ return None
+ else:
+ raise Exception(gettext("Field: {name} Type: {_type} is required").format(name=name, _type=_type))
+ value = valid_reference_value(_type, value, name)
+ if _type == "int":
+ return int(value)
+ if _type == "float":
+ return float(value)
+ return value
+ try:
+ value = node.workflow_manage.generate_prompt(value)
+ return common_convert_value(_type, value)
+ except Exception:
+ raise Exception(
+ gettext("Field: {name} Type: {_type} Value: {value} Type error").format(name=name, _type=_type, value=value)
+ )
+
+
+def valid_function(tool_lib, workspace_id):
+ if tool_lib is None:
+ raise Exception(gettext("Tool does not exist"))
+ get_authorized_tool = DatabaseModelManage.get_model("get_authorized_tool")
+ if tool_lib and tool_lib.workspace_id != workspace_id and get_authorized_tool is not None:
+ tool_lib = get_authorized_tool(QuerySet(Tool).filter(id=tool_lib.id), workspace_id).first()
+ if tool_lib is None:
+ raise Exception(gettext("Tool does not exist"))
+ if not tool_lib.is_active:
+ raise Exception(gettext("Tool is not active"))
+
+
+def _filter_file_bytes(data):
+ """递归过滤掉所有层级的 file_bytes"""
+ if isinstance(data, dict):
+ return {k: _filter_file_bytes(v) for k, v in data.items() if k != "file_bytes"}
+ elif isinstance(data, list):
+ return [_filter_file_bytes(item) for item in data]
+ else:
+ return data
+
+
+def bytes_to_uploaded_file(file_bytes, file_name="unknown"):
+ content_type, _ = mimetypes.guess_type(file_name)
+ if content_type is None:
+ # 如果未能识别,设置为默认的二进制文件类型
+ content_type = "application/octet-stream"
+ # 创建一个内存中的字节流对象
+ file_stream = io.BytesIO(file_bytes)
+
+ # 获取文件大小
+ file_size = len(file_bytes)
+
+ uploaded_file = InMemoryUploadedFile(
+ file=file_stream,
+ field_name=None,
+ name=file_name,
+ content_type=content_type,
+ size=file_size,
+ charset=None,
+ )
+ return uploaded_file
+
+
+def _get_result_detail(result):
+ if isinstance(result, dict):
+ result_dict = {k: (str(v)[:500] if len(str(v)) > 500 else v) for k, v in result.items()}
+ elif isinstance(result, list):
+ result_dict = [str(item)[:500] if len(str(item)) > 500 else item for item in result]
+ elif isinstance(result, str):
+ result_dict = result[:500] if len(result) > 500 else result
+ else:
+ result_dict = result
+ return result_dict
+
+
+class ToolLibNode(INode):
+ serializer_class = ToolLibNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "tool-lib-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+ tool_lib_id = node_params.get("tool_lib_id")
+ input_field_list = node_params.get("input_field_list", [])
+ is_result = node_params.get("is_result", False)
+
+ workspace_id = workflow_params.get("workspace_id")
+ tool_lib = QuerySet(Tool).filter(id=tool_lib_id).first()
+ valid_function(tool_lib, workspace_id)
+ params = {
+ field.get("name"): convert_value(
+ field.get("name"),
+ field.get("value"),
+ field.get("type"),
+ field.get("is_required"),
+ field.get("source"),
+ self,
+ )
+ for field in [
+ {"value": get_field_value(input_field_list, field.get("name"), field.get("is_required")), **field}
+ for field in tool_lib.input_field_list
+ ]
+ }
+
+ self.write_context("params", params)
+ # 合并初始化参数
+ init_params_default_value = {i["field"]: i.get("default_value") for i in tool_lib.init_field_list}
+ if tool_lib.init_params is not None:
+ all_params = init_params_default_value | json.loads(rsa_long_decrypt(tool_lib.init_params)) | params
+ else:
+ all_params = init_params_default_value | params
+
+ if self.node.properties.get("kind") == "data-source":
+ exist = function_executor.exec_code(
+ f"{tool_lib.code}\ndef function_exist(function_name): return callable(globals().get(function_name))",
+ {"function_name": "get_download_file_list"},
+ )
+ all_params = {**all_params, **(workflow_params.get("data_source") or {})}
+ if exist:
+ download_file_list = []
+ download_list = function_executor.exec_code(
+ tool_lib.code, all_params, function_name="get_download_file_list"
+ )
+ for item in download_list:
+ self._check_cancelled()
+ file_result = function_executor.exec_code(
+ tool_lib.code, {**all_params, "download_item": item}, function_name="download"
+ )
+ file_bytes = file_result.get("file_bytes", [])
+ chunks = []
+ for chunk in file_bytes:
+ chunks.append(base64.b64decode(chunk))
+ file = bytes_to_uploaded_file(b"".join(chunks), file_result.get("name"))
+ file_url = self.upload_knowledge_file(file)
+ download_file_list.append({"file_id": file_url.split("/")[-1], "name": file_result.get("name")})
+ result = download_file_list
+ else:
+ result = function_executor.exec_code(tool_lib.code, all_params)
+ else:
+ result = self.tool_exec_record(tool_lib, all_params)
+
+ self.write_context("result", result)
+
+ if is_result:
+ chunk_id = str(uuid.uuid7())
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(TextContent(chunk_id, str(result), Status.SUCCESS, node_info, Position(self.get_node_id())))
+
+ def tool_exec_record(self, tool_lib, all_params):
+ import time
+
+ task_record_id = uuid.uuid7()
+ start_time = time.time()
+ filtered_args = all_params
+ try:
+ # 过滤掉 tool_init_params 中的参数
+ tool_init_params = json.loads(rsa_long_decrypt(tool_lib.init_params)) if tool_lib.init_params else {}
+ if tool_init_params:
+ filtered_args = {k: v for k, v in all_params.items() if k not in tool_init_params}
+ workflow_params = self.get_workflow_parameters()
+ workflow_type = self.get_workflow_type()
+ if workflow_type == WorkflowType.KNOWLEDGE:
+ source_id = workflow_params.get("knowledge_id")
+ source_type = ToolTaskTypeChoices.KNOWLEDGE.value
+ elif workflow_type == WorkflowType.TOOL:
+ source_id = workflow_params.get("tool_id")
+ source_type = ToolTaskTypeChoices.TOOL.value
+ else:
+ source_id = workflow_params.get("application_id")
+ source_type = ToolTaskTypeChoices.APPLICATION.value
+
+ ToolRecord(
+ id=task_record_id,
+ workspace_id=tool_lib.workspace_id,
+ tool_id=tool_lib.id,
+ source_type=source_type,
+ source_id=source_id,
+ meta={"input": filtered_args, "output": {}},
+ state=State.STARTED,
+ ).save()
+
+ result = function_executor.exec_code(tool_lib.code, all_params)
+ result_dict = _get_result_detail(result)
+ QuerySet(ToolRecord).filter(id=task_record_id).update(
+ state=State.SUCCESS,
+ run_time=time.time() - start_time,
+ meta={"input": filtered_args, "output": result_dict},
+ )
+
+ return result
+ except Exception as e:
+ maxkb_logger.error(f"Tool execution error: {traceback.format_exc()}")
+ QuerySet(ToolRecord).filter(id=task_record_id).update(
+ state=State.FAILURE,
+ run_time=time.time() - start_time,
+ meta={"input": filtered_args, "output": "Error: " + str(e)},
+ )
+ raise e
+
+ def upload_knowledge_file(self, file):
+ knowledge_id = self.get_workflow_parameters().get("knowledge_id")
+ meta = {
+ "debug": False,
+ "knowledge_id": knowledge_id,
+ }
+ file_url = (
+ FileSerializer(
+ data={
+ "file": file,
+ "meta": meta,
+ "source_id": knowledge_id,
+ "source_type": FileSourceType.KNOWLEDGE.value,
+ }
+ )
+ .upload()
+ .replace("./oss/file/", "")
+ )
+ file.close()
+ return file_url
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "result": _filter_file_bytes(self.get_context("result")),
+ "params": self.get_context("params"),
+ "enableException": self.node.properties.get("enableException"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/tool_node/__init__.py b/apps/application/workflow/nodes/tool_node/__init__.py
new file mode 100644
index 00000000000..20020915128
--- /dev/null
+++ b/apps/application/workflow/nodes/tool_node/__init__.py
@@ -0,0 +1,10 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎
+@file: __init__.py
+@date:2026/6/29 16:21
+@desc:
+"""
+
+from .tool_node import ToolNode
diff --git a/apps/application/workflow/nodes/tool_node/tool_node.py b/apps/application/workflow/nodes/tool_node/tool_node.py
new file mode 100644
index 00000000000..a43d561042c
--- /dev/null
+++ b/apps/application/workflow/nodes/tool_node/tool_node.py
@@ -0,0 +1,182 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎
+@file: tool_node.py
+@date:2026/9/3 15:09
+@desc:
+"""
+
+import json
+import re
+
+import uuid_utils.compat as uuid
+from django.core import validators
+from django.utils.translation import gettext_lazy as _
+from django.utils.translation import gettext
+from rest_framework import serializers
+from rest_framework.utils.formatting import lazy_format
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+from common.exception.app_exception import AppApiException
+from common.field.common import ObjectField
+from common.utils.common import common_convert_value
+from common.utils.logger import maxkb_logger
+from common.utils.tool_code import ToolExecutor
+
+function_executor = ToolExecutor()
+
+
+class InputField(serializers.Serializer):
+ name = serializers.CharField(required=True, label=_("Variable Name"))
+ is_required = serializers.BooleanField(required=True, label=_("Is this field required"))
+ type = serializers.CharField(
+ required=True,
+ label=_("type"),
+ validators=[
+ validators.RegexValidator(
+ regex=re.compile("^string|int|dict|array|float|boolean$"),
+ message=_("The field only supports string|int|dict|array|float"),
+ code=500,
+ )
+ ],
+ )
+ source = serializers.CharField(
+ required=True,
+ label=_("source"),
+ validators=[
+ validators.RegexValidator(
+ regex=re.compile("^custom|reference$"),
+ message=_("The field only supports custom|reference"),
+ code=500,
+ )
+ ],
+ )
+ value = ObjectField(required=True, label=_("Variable Value"), model_type_list=[str, list])
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ is_required = self.data.get("is_required")
+ if is_required and self.data.get("value") is None:
+ message = lazy_format(_("{field}, this field is required."), field=self.data.get("name"))
+ raise AppApiException(500, message)
+
+
+class ToolNodeSerializer(serializers.Serializer):
+ input_field_list = InputField(required=True, many=True)
+ code = serializers.CharField(required=True, label=_("function"))
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+
+
+def valid_reference_value(_type, value, name):
+ if _type == "int":
+ instance_type = int | float
+ elif _type == "boolean":
+ instance_type = bool
+ elif _type == "float":
+ instance_type = float | int
+ elif _type == "dict":
+ value = json.loads(value) if isinstance(value, str) else value
+ instance_type = dict
+ elif _type == "array":
+ value = json.loads(value) if isinstance(value, str) else value
+ instance_type = list
+ elif _type == "string":
+ instance_type = str
+ else:
+ maxkb_logger.error(
+ gettext("Field: {name} Type: {_type} Value: {value} Unsupported this type").format(
+ name=name, _type=_type, value=value
+ )
+ )
+ return value
+ if not isinstance(value, instance_type):
+ raise Exception(
+ gettext("Field: {name} Type: {_type} Value: {value} Type error").format(name=name, _type=_type, value=value)
+ )
+ return value
+
+
+def convert_value(name: str, value, _type, is_required, source, node):
+ if not is_required and (value is None or ((isinstance(value, str) or isinstance(value, list)) and len(value) == 0)):
+ return None
+ if source == "reference":
+ value = node.workflow_manage.get_reference_field(value[0], value[1:])
+ if value is None:
+ if not is_required:
+ return None
+ else:
+ raise Exception(gettext("Field: {name} Type: {_type} is required").format(name=name, _type=_type))
+ value = valid_reference_value(_type, value, name)
+ if _type == "int":
+ return int(value)
+ if _type == "float":
+ return float(value)
+ return value
+ try:
+ value = node.workflow_manage.generate_prompt(value)
+ return common_convert_value(_type, value)
+ except Exception:
+ raise Exception(
+ gettext("Field: {name} Type: {_type} Value: {value} Type error").format(name=name, _type=_type, value=value)
+ )
+
+
+class ToolNode(INode):
+ serializer_class = ToolNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "tool-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ input_field_list = node_params.get("input_field_list", [])
+ code = node_params.get("code")
+ is_result = node_params.get("is_result", False)
+
+ params = {
+ field.get("name"): convert_value(
+ field.get("name"),
+ field.get("value"),
+ field.get("type"),
+ field.get("is_required"),
+ field.get("source"),
+ self,
+ )
+ for field in input_field_list
+ }
+
+ # 合并启动参数默认值(如果有 init_field_list 定义)
+ init_field_list = node_params.get("init_field_list", [])
+ if init_field_list:
+ init_params_default_value = {i["field"]: i.get("default_value") for i in init_field_list}
+ init_params = self.get_workflow_parameters().get("init_params")
+ if init_params is not None:
+ all_params = init_params_default_value | init_params | params
+ else:
+ all_params = init_params_default_value | params
+ else:
+ all_params = params
+
+ result = function_executor.exec_code(code, all_params)
+ self.write_context("params", all_params)
+ self.write_context("result", result)
+
+ if is_result:
+ chunk_id = str(uuid.uuid7())
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.SUCCESS)
+ self.write(TextContent(chunk_id, str(result), Status.SUCCESS, node_info, Position(self.get_node_id())))
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "result": self.get_context("result"),
+ "params": self.get_context("params"),
+ "enableException": self.node.properties.get("enableException"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/tool_start_node/__init__.py b/apps/application/workflow/nodes/tool_start_node/__init__.py
new file mode 100644
index 00000000000..bb9f7dc8336
--- /dev/null
+++ b/apps/application/workflow/nodes/tool_start_node/__init__.py
@@ -0,0 +1,10 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@date: 2026/9/3 17:20
+@desc:
+"""
+
+from .tool_start_node import ToolStartNode
diff --git a/apps/application/workflow/nodes/tool_start_node/tool_start_node.py b/apps/application/workflow/nodes/tool_start_node/tool_start_node.py
new file mode 100644
index 00000000000..efb746f0211
--- /dev/null
+++ b/apps/application/workflow/nodes/tool_start_node/tool_start_node.py
@@ -0,0 +1,54 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: tool_start_node.py
+@date: 2026/9/3 17:20
+@desc: 工具工作流的起始节点,负责把工具入参写入全局变量、初始化输出字段
+"""
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+
+
+class ToolStartNode(INode):
+ supported_workflow_type_list = [WorkflowType.TOOL]
+ type = "tool-start-node"
+
+ def execute(self):
+ workflow_params = self.get_workflow_parameters()
+ base_node = self.workflow_manage.workflow.get_node("tool-base-node")
+ user_input_field_list = base_node.properties.get("user_input_field_list", []) if base_node else []
+ user_output_field_list = base_node.properties.get("user_output_field_list", []) if base_node else []
+
+ # 入参 -> 全局变量(引用约定 global.)
+ for item in user_input_field_list:
+ field = item.get("field")
+ self.workflow_manage.write_context("global", field, workflow_params.get(field))
+
+ # 初始化输出字段默认值 -> output(工作流内由变量赋值节点覆写)
+ for item in user_output_field_list:
+ if item.get("default_value", None) is not None:
+ self.workflow_manage.write_context("output", item.get("field"), item.get("default_value"))
+
+ self.write_context("question", workflow_params.get("question", ""))
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ global_fields = []
+ for field in (self.node.properties.get("config") or {}).get("globalFields", []) or []:
+ key = field.get("value")
+ global_fields.append(
+ {
+ "label": field.get("label"),
+ "key": key,
+ "value": self.workflow_manage.get_context("global", key) or "",
+ }
+ )
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "global_fields": global_fields,
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/tool_workflow_lib_node/__init__.py b/apps/application/workflow/nodes/tool_workflow_lib_node/__init__.py
new file mode 100644
index 00000000000..ddfc1fe47ac
--- /dev/null
+++ b/apps/application/workflow/nodes/tool_workflow_lib_node/__init__.py
@@ -0,0 +1,10 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@date: 2026/9/3 17:20
+@desc:
+"""
+
+from .tool_workflow_lib_node import ToolWorkflowLibNode
diff --git a/apps/application/workflow/nodes/tool_workflow_lib_node/tool_workflow_lib_node.py b/apps/application/workflow/nodes/tool_workflow_lib_node/tool_workflow_lib_node.py
new file mode 100644
index 00000000000..ac1d4d1967d
--- /dev/null
+++ b/apps/application/workflow/nodes/tool_workflow_lib_node/tool_workflow_lib_node.py
@@ -0,0 +1,215 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: tool_workflow_lib_node.py
+@date: 2026/9/3 17:20
+@desc:
+"""
+
+import uuid_utils.compat as uuid
+from django.db import connection
+from django.db.models import QuerySet
+from django.utils.translation import gettext
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType, new_instance
+from application.workflow.i_node import INode, Signal
+from application.workflow.message.struct.content import Position
+from application.workflow.status import Status
+from common.database_model_manage.database_model_manage import DatabaseModelManage
+from common.exception.app_exception import ChatException, AppApiException
+from common.field.common import ObjectField
+from tools.models import Tool, ToolType, ToolWorkflowVersion
+from knowledge.services.retrieval_access import inherited_retrieval_context
+
+
+class InputField(serializers.Serializer):
+ field = serializers.CharField(required=True, label=_("Variable Name"))
+ label = serializers.CharField(required=True, label=_("Variable Label"))
+ source = serializers.CharField(required=True, label=_("Variable Source"))
+ type = serializers.CharField(required=True, label=_("Variable Type"))
+ value = ObjectField(required=True, label=_("Variable Value"), model_type_list=[str, list, bool, dict, int, float])
+
+
+class ToolWorkflowLibNodeSerializer(serializers.Serializer):
+ tool_lib_id = serializers.UUIDField(required=True, label=_("Library ID"))
+ input_field_list = InputField(required=True, many=True)
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ f_lib = QuerySet(Tool).filter(id=self.data.get("tool_lib_id"), tool_type=ToolType.WORKFLOW).first()
+ # 归还链接到连接池
+ connection.close()
+ if f_lib is None:
+ raise AppApiException(500, _("Tool has been deleted"))
+ if not f_lib.is_active:
+ raise AppApiException(500, _("Tool is not active"))
+
+
+def valid_function(tool_lib, workspace_id):
+ if tool_lib is None:
+ raise Exception(gettext("Tool does not exist"))
+ get_authorized_tool = DatabaseModelManage.get_model("get_authorized_tool")
+ if tool_lib and tool_lib.workspace_id != workspace_id and get_authorized_tool is not None:
+ tool_lib = get_authorized_tool(QuerySet(Tool).filter(id=tool_lib.id), workspace_id).first()
+ if tool_lib is None:
+ raise Exception(gettext("Tool does not exist"))
+ if not tool_lib.is_active:
+ raise Exception(gettext("Tool is not active"))
+
+
+def _sum_tokens(context, key):
+ total = 0
+ for node_context in (context or {}).values():
+ if isinstance(node_context, dict) and isinstance(node_context.get(key), (int, float)):
+ total += node_context.get(key)
+ return total
+
+
+class ToolWorkflowLibNode(INode):
+ serializer_class = ToolWorkflowLibNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "tool-workflow-lib-node"
+
+ def _run(self):
+ # 完成时机由子工作流的 on_complete 回调驱动,这里不自动 complete
+ self.execute()
+
+ def execute(self):
+ node_params = self.get_parameters()
+ workflow_params = self.get_workflow_parameters()
+ tool_lib_id = node_params.get("tool_lib_id")
+ input_field_list = node_params.get("input_field_list", [])
+ workspace_id = workflow_params.get("workspace_id")
+ position = workflow_params.get("position")
+ tool_workflow_version = (
+ QuerySet(ToolWorkflowVersion).filter(tool_id=tool_lib_id).order_by("-create_time")[0:1].first()
+ )
+ if tool_workflow_version is None:
+ raise ChatException(500, _("The tool has not been published. Please use it after publishing."))
+ tool_lib = QuerySet(Tool).filter(id=tool_lib_id).first()
+ valid_function(tool_lib, workspace_id)
+
+ parameters = self._resolve_parameters(input_field_list)
+ # 入参映射属于调试数据,不需要给下游引用
+ self.data["params"] = parameters
+
+ sub_workflow = new_instance(tool_workflow_version.work_flow, WorkflowType.TOOL)
+ tool_record_id = str(uuid.uuid7())
+ sub_parameters = {
+ "chat_record_id": tool_record_id,
+ "tool_id": str(tool_lib_id),
+ "stream": True,
+ "workspace_id": workspace_id,
+ "position": position.get("children") if position else None,
+ "chunk_id": workflow_params.get("chunk_id"),
+ "form_data": workflow_params.get("form_data"),
+ "default_model_setting": tool_workflow_version.default_model_setting or {},
+ **parameters,
+ **inherited_retrieval_context(workflow_params),
+ }
+
+ node_id = self.get_node_id()
+
+ def on_next(wf_manage, content):
+ # 把子工作流的输出位置嵌套到当前节点下,再转发给父工作流
+ content.position = Position(node_id, None, content.position)
+ self.write(content)
+
+ def on_complete(wf_manage, error):
+ # 收集工具工作流输出(tool-start-node 初始化、变量赋值节点覆写)
+ output = dict(wf_manage.context.get("output", {}) or {})
+ # 只有需要给下游引用的数据才写 context:各输出字段
+ for key, value in output.items():
+ self.write_context(key, value)
+ # 调试/详情数据放 self.data,不进可引用 context(run_time 由基类 complete 写入)
+ self.data["output"] = output
+ self.data["details"] = wf_manage.get_details()
+ self.data["message_tokens"] = _sum_tokens(wf_manage.context, "message_tokens")
+ self.data["answer_tokens"] = _sum_tokens(wf_manage.context, "answer_tokens")
+
+ if error:
+ self.complete(Status.FAIL, error=error)
+ return
+ # 子工作流命中表单:向上传播中断,暂停父工作流
+ if wf_manage.signal == Signal.FORM:
+ self.complete(Status.SUCCESS, signal=Signal.FORM)
+ return
+ self.complete(Status.SUCCESS)
+
+ from application.workflow.nodes import get_node_class
+ from application.workflow.workflow_manage import CallBack, WorkflowManage
+
+ call_back = CallBack(on_next, on_complete)
+
+ def get_start_node_fn(wf, wm):
+ # 如果有 position,根据 position 确定开始节点
+ _position = wm.get_parameters().get("position")
+ if _position and _position.get("id"):
+ _node_id = _position.get("id")
+ node = wf.get_node(_node_id)
+ if node:
+ node_class = get_node_class(node.type, WorkflowType.TOOL)
+ return node_class(node, wm, lambda n: n.properties.get("node_data", {}))
+ # 默认返回工具工作流开始节点
+ start_node = wf.get_node("tool-start-node")
+ node_class = get_node_class("tool-start-node", WorkflowType.TOOL)
+ return node_class(start_node, wm, lambda n: n.properties.get("node_data", {}))
+
+ sub_manage = WorkflowManage(
+ workflow=sub_workflow,
+ parameters=sub_parameters,
+ workflow_type=WorkflowType.TOOL,
+ call_back=call_back,
+ get_start_node=get_start_node_fn,
+ )
+ sub_manage.start_node.workflow_manage = sub_manage
+ # 子工作流的输出已在 on_next 中逐块转发给父工作流,无需按 is_result 重复输出
+ sub_manage.run()
+
+ def _resolve_parameters(self, input_field_list):
+ result = {}
+ for item in input_field_list:
+ source = item.get("source")
+ value = item.get("value")
+ if source == "reference" and isinstance(value, list) and len(value) >= 2:
+ value = self.workflow_manage.get_reference_field(value[0], value[1:])
+ result[item.get("field")] = value
+ return result
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "params": self.data.get("params"),
+ "output": self.data.get("output"),
+ "message_tokens": self.data.get("message_tokens"),
+ "answer_tokens": self.data.get("answer_tokens"),
+ "enableException": self.node.properties.get("enableException"),
+ }
+ )
+
+ # 子工作流节点详情。工具工作流只运行一遍,children 是扁平的一层节点列表;
+ # 循环节点因每个迭代多一层,children 结构为 [[节点...], [节点...]]。
+ node_details = []
+ position_index = 0
+ if old_details and position:
+ # 用旧详情作为底,定位续跑点(子工作流表单节点)
+ old_node_list = old_details.get("children") or []
+ node_details = list(old_node_list)
+ for node_index, value in enumerate(node_details):
+ if position.get("children", {}).get("id") == value.get("node_id"):
+ position_index = node_index
+
+ for index, item in enumerate(self.data.get("details") or []):
+ if position is not None and node_details and index == 0:
+ # 续跑点:子工作流从表单节点恢复,当前运行的首个节点覆盖旧详情中的同一点
+ node_details[position_index] = item
+ else:
+ node_details.append(item)
+
+ details["children"] = node_details
+ return details
diff --git a/apps/application/workflow/nodes/variable_aggregation_node/__init__.py b/apps/application/workflow/nodes/variable_aggregation_node/__init__.py
new file mode 100644
index 00000000000..1ddb052c0d4
--- /dev/null
+++ b/apps/application/workflow/nodes/variable_aggregation_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@desc:
+"""
+
+from .variable_aggregation_node import VariableAggregationNode
diff --git a/apps/application/workflow/nodes/variable_aggregation_node/variable_aggregation_node.py b/apps/application/workflow/nodes/variable_aggregation_node/variable_aggregation_node.py
new file mode 100644
index 00000000000..5e02c9c27a3
--- /dev/null
+++ b/apps/application/workflow/nodes/variable_aggregation_node/variable_aggregation_node.py
@@ -0,0 +1,133 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: variable_aggregation_node.py
+@desc: 变量聚合节点
+"""
+
+from typing import Callable, List
+
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+
+
+class VariableListSerializer(serializers.Serializer):
+ v_id = serializers.CharField(required=True, label=_("Variable id"))
+ key = serializers.CharField(required=False, label=_("Key"), allow_null=True, allow_blank=True)
+ variable = serializers.ListField(required=True, label=_("Variable"))
+
+
+class VariableGroupSerializer(serializers.Serializer):
+ id = serializers.CharField(required=True, label=_("Group id"))
+ field = serializers.CharField(required=True, label=_("group_name"))
+ label = serializers.CharField(required=True)
+ variable_list = VariableListSerializer(many=True)
+
+
+class VariableAggregationNodeSerializer(serializers.Serializer):
+ strategy = serializers.CharField(required=True, label=_("Strategy"))
+ group_list = VariableGroupSerializer(many=True)
+
+
+def _filter_file_bytes(data):
+ """递归过滤掉所有层级的 file_bytes"""
+ if isinstance(data, dict):
+ return {k: _filter_file_bytes(v) for k, v in data.items() if k != "file_bytes"}
+ elif isinstance(data, list):
+ return [_filter_file_bytes(item) for item in data]
+ else:
+ return data
+
+
+class VariableAggregationNode(INode):
+ serializer_class = VariableAggregationNodeSerializer
+ supported_workflow_type_list = [
+ WorkflowType.APPLICATION,
+ WorkflowType.KNOWLEDGE,
+ WorkflowType.TOOL,
+ ]
+ type = "variable-aggregation-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ strategy = node_params.get("strategy")
+ group_list = node_params.get("group_list", [])
+
+ strategy_map = {
+ "first_non_null": self.get_first_non_null,
+ "variable_to_array": self.set_variable_to_array,
+ "variable_to_dict": self.set_variable_to_dict,
+ }
+
+ # 向下兼容
+ if strategy == "variable_to_json":
+ strategy = "variable_to_array"
+
+ result = {
+ item.get("field"): strategy_map[strategy](item.get("variable_list")) if item.get("variable_list") else []
+ for item in group_list
+ }
+
+ self.write_context("result", result)
+ self.write_context("strategy", strategy)
+ self.write_context("group_list", self.reset_group_list(group_list))
+ for key, value in result.items():
+ self.write_context(key, value)
+
+ def get_first_non_null(self, variable_list) -> Callable:
+ for variable in variable_list:
+ v = self.get_reference_content(variable.get("variable"))
+ if v is not None and not (isinstance(v, (str, list, dict)) and len(v) == 0):
+ return v
+ return None
+
+ def set_variable_to_array(self, variable_list) -> List:
+ return [self.get_reference_content(variable.get("variable")) for variable in variable_list]
+
+ def set_variable_to_dict(self, variable_list) -> dict:
+ return {
+ (variable.get("key") or variable.get("variable")[-1]): self.get_reference_content(variable.get("variable"))
+ for variable in variable_list
+ }
+
+ def reset_variable(self, variable):
+ value = self.get_reference_content(variable.get("variable"))
+ node_id = variable.get("variable")[0]
+ node = self.workflow_manage.workflow.get_node(node_id)
+ return {
+ "value": value,
+ "node_name": node.properties.get("stepName") if node is not None else node_id,
+ "field": variable.get("variable")[1],
+ }
+
+ def reset_group_list(self, group_list):
+ return [
+ {
+ "label": g.get("label"),
+ "variable_list": [self.reset_variable(variable) for variable in g.get("variable_list")],
+ }
+ for g in group_list
+ ]
+
+ def get_reference_content(self, variable):
+ return (
+ self.workflow_manage.get_reference_field(variable[0], variable[1:])
+ if variable and len(variable) >= 2
+ else None
+ )
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "result": _filter_file_bytes(self.get_context("result")),
+ "strategy": self.get_context("strategy"),
+ "group_list": _filter_file_bytes(self.get_context("group_list")),
+ "status": self.status.value if self.status else None,
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/variable_assign_node/__init__.py b/apps/application/workflow/nodes/variable_assign_node/__init__.py
new file mode 100644
index 00000000000..6f3c30b088a
--- /dev/null
+++ b/apps/application/workflow/nodes/variable_assign_node/__init__.py
@@ -0,0 +1,10 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@date: 2026/9/3
+@desc:
+"""
+
+from .variable_assign_node import VariableAssignNode
diff --git a/apps/application/workflow/nodes/variable_assign_node/variable_assign_node.py b/apps/application/workflow/nodes/variable_assign_node/variable_assign_node.py
new file mode 100644
index 00000000000..ea54bb8cd65
--- /dev/null
+++ b/apps/application/workflow/nodes/variable_assign_node/variable_assign_node.py
@@ -0,0 +1,129 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: variable_assign_node.py
+@desc: 变量赋值节点
+"""
+
+import json
+from typing import Callable, List
+
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.loop_workflow_manage import LoopWorkFlowManage
+
+
+class VariableAssignNodeParamsSerializer(serializers.Serializer):
+ variable_list = serializers.ListField(required=True, label=_("Reference Field"))
+
+
+class VariableAssignNode(INode):
+ serializer_class = VariableAssignNodeParamsSerializer
+ supported_workflow_type_list = [
+ WorkflowType.APPLICATION,
+ WorkflowType.KNOWLEDGE,
+ WorkflowType.TOOL,
+ ]
+ type = "variable-assign-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+ result_list = []
+ for variable in node_params.get("variable_list", []):
+ if not variable.get("fields"):
+ continue
+
+ field0 = variable["fields"][0]
+ if field0 == "global":
+ result = self.handle(variable, self.global_evaluation)
+ result_list.append(result)
+ elif field0 == "chat":
+ result = self.handle(variable, self.chat_evaluation)
+ result_list.append(result)
+ elif field0 == "loop":
+ result = self.handle(variable, self.loop_evaluation)
+ result_list.append(result)
+ elif field0 == "output":
+ result = self.handle(variable, self.output_evaluation)
+ result_list.append(result)
+
+ self.write_context("variable_list", node_params.get("variable_list", []))
+ self.write_context("result_list", result_list)
+
+ def _target_manage(self):
+ return (
+ self.workflow_manage.parent_workflow_manage
+ if isinstance(self.workflow_manage, LoopWorkFlowManage)
+ else self.workflow_manage
+ )
+
+ def global_evaluation(self, variable, value):
+ self._target_manage().write_context("global", variable["fields"][1], value)
+
+ def loop_evaluation(self, variable, value):
+ self.workflow_manage.write_context("loop", variable["fields"][1], value)
+
+ def chat_evaluation(self, variable, value):
+ self._target_manage().write_context("chat", variable["fields"][1], value)
+
+ def output_evaluation(self, variable, value):
+ self._target_manage().write_context("output", variable["fields"][1], value)
+
+ def handle(self, variable, evaluation: Callable):
+ result = {
+ "name": variable["name"],
+ "input_value": self.get_reference_content(variable["fields"]),
+ }
+ if variable["source"] == "custom":
+ if variable["type"] == "json":
+ if isinstance(variable["value"], dict) or isinstance(variable["value"], list):
+ val = variable["value"]
+ else:
+ val = json.loads(variable["value"])
+ evaluation(variable, val)
+ result["output_value"] = variable["value"] = val
+ elif variable["type"] == "string":
+ # 变量解析 例如:{{global.xxx}}
+ val = self.workflow_manage.generate_prompt(variable["value"])
+ evaluation(variable, val)
+ result["output_value"] = val
+ else:
+ val = variable["value"]
+ evaluation(variable, val)
+ result["output_value"] = val
+ elif variable["source"] == "referencing":
+ reference = self.get_reference_content(variable["reference"])
+ evaluation(variable, reference)
+ result["output_value"] = reference
+ else:
+ val = None
+ evaluation(variable, val)
+ result["output_value"] = val
+
+ # 获取输入输出值的类型,用于显示在执行详情页面中
+ result["input_type"] = (
+ type(result.get("input_value")).__name__ if result.get("input_value") is not None else "null"
+ )
+ result["output_type"] = (
+ type(result.get("output_value")).__name__ if result.get("output_value") is not None else "null"
+ )
+
+ return result
+
+ def get_reference_content(self, fields: List[str]):
+ return self.workflow_manage.get_reference_field(fields[0], fields[1:]) if fields else None
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "variable_list": self.get_context("variable_list"),
+ "result_list": self.get_context("result_list"),
+ "status": self.status.value if self.status else None,
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/variable_splitting_node/__init__.py b/apps/application/workflow/nodes/variable_splitting_node/__init__.py
new file mode 100644
index 00000000000..18bc38f55df
--- /dev/null
+++ b/apps/application/workflow/nodes/variable_splitting_node/__init__.py
@@ -0,0 +1,9 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: __init__.py
+@desc:
+"""
+
+from .variable_splitting_node import VariableSplittingNode
diff --git a/apps/application/workflow/nodes/variable_splitting_node/variable_splitting_node.py b/apps/application/workflow/nodes/variable_splitting_node/variable_splitting_node.py
new file mode 100644
index 00000000000..051cbb2c831
--- /dev/null
+++ b/apps/application/workflow/nodes/variable_splitting_node/variable_splitting_node.py
@@ -0,0 +1,101 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author: 虎虎虎
+@file: variable_splitting_node.py
+@desc: 变量拆分节点
+"""
+
+import json
+
+from django.utils.translation import gettext_lazy as _
+from jsonpath_ng.ext import parse
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from common.cache.mem_cache import MemCache
+
+jsonpath_expr_cache = MemCache(
+ "parse_path",
+ {
+ "TIMEOUT": 3600, # 缓存有效期为 1 小时
+ "OPTIONS": {
+ "MAX_ENTRIES": 1000, # 最多缓存 1000 个条目
+ "CULL_FREQUENCY": 10, # 达到上限时,删除约 1/10 的缓存
+ },
+ },
+)
+
+
+class VariableSplittingNodeParamsSerializer(serializers.Serializer):
+ input_variable = serializers.ListField(required=True, label=_("input variable"))
+ variable_list = serializers.ListField(required=True, label=_("Split variables"))
+
+
+def parse_and_cache(path):
+ jsonpath_expr = jsonpath_expr_cache.get(path)
+ if not jsonpath_expr:
+ jsonpath_expr = parse(path)
+ jsonpath_expr_cache.set(path, jsonpath_expr)
+ return jsonpath_expr
+
+
+def smart_jsonpath_search(data: dict, path: str):
+ """智能 JSON Path 搜索。
+
+ - 单个匹配: 直接返回值
+ - 多个匹配: 返回值的列表
+ - 无匹配: 返回 None
+ """
+ jsonpath_expr = parse_and_cache(path)
+ matches = jsonpath_expr.find(data)
+
+ if not matches:
+ return None
+ elif len(matches) == 1:
+ return matches[0].value
+ else:
+ return [match.value for match in matches]
+
+
+class VariableSplittingNode(INode):
+ serializer_class = VariableSplittingNodeParamsSerializer
+ supported_workflow_type_list = [
+ WorkflowType.APPLICATION,
+ WorkflowType.KNOWLEDGE,
+ WorkflowType.TOOL,
+ ]
+ type = "variable-splitting-node"
+
+ def execute(self):
+ node_params = self.get_parameters()
+
+ input_variable = self.workflow_manage.get_reference_field(
+ node_params.get("input_variable")[0],
+ node_params.get("input_variable")[1:],
+ )
+ variable_list = node_params.get("variable_list", [])
+
+ if isinstance(input_variable, str):
+ try:
+ input_variable = json.loads(input_variable)
+ except Exception:
+ pass
+
+ self.write_context("request", input_variable)
+ response = {v["field"]: smart_jsonpath_search(input_variable, v["expression"]) for v in variable_list}
+ self.write_context("result", response)
+ for key, value in response.items():
+ self.write_context(key, value)
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "request": self.get_context("request"),
+ "result": self.get_context("result"),
+ "status": self.status.value if self.status else None,
+ }
+ )
+ return details
diff --git a/apps/application/workflow/nodes/video_understand_node/__init__.py b/apps/application/workflow/nodes/video_understand_node/__init__.py
new file mode 100644
index 00000000000..5bb3385963d
--- /dev/null
+++ b/apps/application/workflow/nodes/video_understand_node/__init__.py
@@ -0,0 +1,2 @@
+# coding=utf-8
+from .video_understand_node import VideoUnderstandNode
diff --git a/apps/application/workflow/nodes/video_understand_node/video_understand_node.py b/apps/application/workflow/nodes/video_understand_node/video_understand_node.py
new file mode 100644
index 00000000000..8f9d3b3b8ac
--- /dev/null
+++ b/apps/application/workflow/nodes/video_understand_node/video_understand_node.py
@@ -0,0 +1,387 @@
+# coding=utf-8
+import uuid_utils.compat as uuid
+from functools import reduce
+
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
+from rest_framework import serializers
+
+from application.workflow.common import WorkflowType
+from application.workflow.i_node import INode
+from application.workflow.message.struct.content import NodeInfo, Position
+from application.workflow.message.struct.reasoning_content import ReasoningContent
+from application.workflow.message.struct.text_content import TextContent
+from application.workflow.status import Status
+from application.workflow.tools import Reasoning
+from common.exception.app_exception import AppApiException
+from knowledge.models import File
+from models_provider.models import Model
+from models_provider.tools import get_model_instance_by_model_workspace_id
+
+
+class VideoUnderstandNodeSerializer(serializers.Serializer):
+ model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model id"))
+ model_id_type = serializers.CharField(required=False, default="custom", label=_("Model id type"))
+ model_id_reference = serializers.ListField(
+ required=False, child=serializers.CharField(), allow_empty=True, label=_("Reference Field")
+ )
+ system = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Role Setting"))
+ prompt = serializers.CharField(required=True, label=_("Prompt word"))
+ dialogue_number = serializers.IntegerField(required=True, label=_("Number of multi-round conversations"))
+ dialogue_type = serializers.CharField(required=True, label=_("Conversation storage type"))
+ is_result = serializers.BooleanField(required=False, label=_("Whether to return content"))
+ video_list = serializers.ListField(required=False, label=_("video"))
+ model_params_setting = serializers.JSONField(required=False, default=dict, label=_("Model parameter settings"))
+ model_setting = serializers.DictField(required=False, label="Model settings")
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ # reference / default 在运行时才解析,此处只校验自定义模型
+ if (self.data.get("model_id_type") or "custom") in ("reference", "default"):
+ return
+ model_id = self.data.get("model_id")
+ if not model_id or not QuerySet(Model).filter(id=model_id).exists():
+ raise AppApiException(500, _("The model of the node does not exist"))
+
+
+class VideoUnderstandNode(INode):
+ serializer_class = VideoUnderstandNodeSerializer
+ supported_workflow_type_list = [WorkflowType.APPLICATION, WorkflowType.KNOWLEDGE, WorkflowType.TOOL]
+ type = "video-understand-node"
+
+ def execute(self):
+ workflow_params = self.get_workflow_parameters()
+ node_params = self.get_parameters()
+
+ model_id = node_params.get("model_id")
+ model_id_type = node_params.get("model_id_type", "custom")
+ model_id_reference = node_params.get("model_id_reference")
+ system = node_params.get("system", "")
+ prompt = node_params.get("prompt", "")
+ dialogue_number = node_params.get("dialogue_number", 0)
+ dialogue_type = node_params.get("dialogue_type", "WORKFLOW")
+ is_result = node_params.get("is_result", False)
+ video_list_ref = node_params.get("video_list")
+ model_params_setting = node_params.get("model_params_setting")
+ model_setting = node_params.get("model_setting")
+
+ workflow_type = self.get_workflow_type()
+ if workflow_type in (WorkflowType.KNOWLEDGE, WorkflowType.TOOL):
+ history_chat_record = []
+ chat_id = None
+ workspace_id = workflow_params.get("workspace_id")
+ else:
+ history_chat_record = workflow_params.get("history_chat_record", [])
+ chat_id = workflow_params.get("chat_id")
+ workspace_id = workflow_params.get("workspace_id")
+
+ if model_setting is None:
+ model_setting = {
+ "reasoning_content_enable": False,
+ "reasoning_content_end": "",
+ "reasoning_content_start": "",
+ }
+ self.write_context("model_setting", model_setting)
+
+ if model_id_type == "reference" and model_id_reference:
+ reference_data = self.workflow_manage.get_reference_field(
+ model_id_reference[0],
+ model_id_reference[1:],
+ )
+ if reference_data and isinstance(reference_data, dict):
+ model_id = reference_data.get("model_id", model_id)
+ model_params_setting = reference_data.get("model_params_setting")
+
+ if model_id_type == "default":
+ default_model_setting = workflow_params.get("default_model_setting").get("IMAGE") or {}
+ if default_model_setting and isinstance(default_model_setting, dict):
+ model_id = default_model_setting.get("model_id", model_id)
+ model_params_setting = default_model_setting.get("model_params_setting")
+
+ if not model_id:
+ raise Exception(_("Model is not allowed to be empty"))
+
+ video = None
+ if video_list_ref:
+ video = self.workflow_manage.get_reference_field(video_list_ref[0], video_list_ref[1:])
+
+ chat_model = get_model_instance_by_model_workspace_id(model_id, workspace_id, **(model_params_setting or {}))
+
+ history_message_for_details = self._get_history_message_for_details(history_chat_record, dialogue_number)
+ self.write_context(
+ "history_message",
+ [{"content": message.content, "role": message.type} for message in (history_message_for_details or [])],
+ )
+
+ question = self.workflow_manage.generate_prompt(prompt)
+ self.write_context("question", question)
+
+ system = self.workflow_manage.generate_prompt(system)
+ self.write_context("system", system)
+
+ history_message = self._get_history_message(history_chat_record, dialogue_number, chat_model)
+ message_list = self._generate_message_list(chat_model, system, prompt, history_message, video)
+ self.write_context(
+ "message_list",
+ [{"content": m.content, "role": m.type} for m in message_list],
+ )
+
+ self._generate_context_video(video)
+ self.write_context("dialogue_type", dialogue_type)
+
+ reasoning_content_id = str(uuid.uuid7())
+ text_content_id = str(uuid.uuid7())
+
+ node_info = NodeInfo(self.get_node_id(), self.get_node_name(), Status.RUNNING)
+
+ r = chat_model.stream(message_list)
+ self._stream_response(
+ r, chat_model, message_list, question, reasoning_content_id, text_content_id, node_info, is_result
+ )
+
+ def _stream_response(
+ self, response, chat_model, message_list, question, reasoning_content_id, text_content_id, node_info, is_result
+ ):
+ model_setting = self.get_context("model_setting") or {}
+ reasoning = Reasoning(
+ model_setting.get("reasoning_content_start", ""),
+ model_setting.get("reasoning_content_end", ""),
+ )
+ answer = ""
+ reasoning_content = ""
+ response_reasoning_content = False
+
+ for chunk in response:
+ self._check_cancelled()
+ reasoning_chunk = reasoning.get_reasoning_content(chunk)
+ content_chunk = reasoning_chunk.get("content")
+ if "reasoning_content" in chunk.additional_kwargs:
+ response_reasoning_content = True
+ reasoning_content_chunk = chunk.additional_kwargs.get("reasoning_content", "")
+ else:
+ reasoning_content_chunk = reasoning_chunk.get("reasoning_content")
+ answer += content_chunk
+ if reasoning_content_chunk is None:
+ reasoning_content_chunk = ""
+ reasoning_content += reasoning_content_chunk
+
+ if is_result:
+ if isinstance(chunk.content, list):
+ for chunk_item in chunk.content:
+ text = chunk_item.get("text", "")
+ if text:
+ self.write(
+ TextContent(
+ text_content_id, text, Status.RUNNING, node_info, Position(self.get_node_id())
+ )
+ )
+ if reasoning_content_chunk and model_setting.get("reasoning_content_enable", False):
+ self.write(
+ ReasoningContent(
+ reasoning_content_id,
+ reasoning_content_chunk,
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+ else:
+ if content_chunk:
+ self.write(
+ TextContent(
+ text_content_id, content_chunk, Status.RUNNING, node_info, Position(self.get_node_id())
+ )
+ )
+ if reasoning_content_chunk and model_setting.get("reasoning_content_enable", False):
+ self.write(
+ ReasoningContent(
+ reasoning_content_id,
+ reasoning_content_chunk,
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+
+ reasoning_end = reasoning.get_end_reasoning_content()
+ answer += reasoning_end.get("content")
+ reasoning_content_chunk = ""
+ if not response_reasoning_content:
+ reasoning_content_chunk = reasoning_end.get("reasoning_content")
+ if is_result:
+ if reasoning_end.get("content"):
+ self.write(
+ TextContent(
+ text_content_id,
+ reasoning_end.get("content"),
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+ if reasoning_content_chunk and model_setting.get("reasoning_content_enable", False):
+ self.write(
+ ReasoningContent(
+ reasoning_content_id,
+ reasoning_content_chunk,
+ Status.RUNNING,
+ node_info,
+ Position(self.get_node_id()),
+ )
+ )
+
+ self._write_final_context(chat_model, message_list, question, answer, reasoning_content)
+
+ def _write_final_context(self, chat_model, message_list, question, answer, reasoning_content):
+ message_tokens = chat_model.get_num_tokens_from_messages(message_list)
+ answer_tokens = chat_model.get_num_tokens(answer)
+ self.write_context("message_tokens", message_tokens)
+ self.write_context("answer_tokens", answer_tokens)
+ self.write_context("answer", answer)
+ self.write_context("question", question)
+ self.write_context("reasoning_content", reasoning_content)
+
+ def _generate_context_video(self, video):
+ if isinstance(video, str) and video.startswith("http"):
+ self.write_context("video_list", [{"url": video}])
+ elif video is not None and len(video) > 0:
+ self.write_context("video_list", video)
+
+ def _get_history_message_for_details(self, history_chat_record, dialogue_number):
+ start_index = len(history_chat_record) - dialogue_number
+ history_message = reduce(
+ lambda x, y: [*x, *y],
+ [
+ [
+ self._generate_history_human_message_for_details(history_chat_record[index]),
+ *self._generate_history_ai_message(history_chat_record[index]),
+ ]
+ for index in range(start_index if start_index > 0 else 0, len(history_chat_record))
+ ],
+ [],
+ )
+ return history_message
+
+ def _generate_history_ai_message(self, chat_record):
+ for val in chat_record.details.values():
+ if self.node.id == val["node_id"] and "video_list" in val:
+ if val["dialogue_type"] == "WORKFLOW":
+ return chat_record.get_ai_message()
+ return [AIMessage(content=val["answer"])]
+ return chat_record.get_ai_message()
+
+ def _generate_history_human_message_for_details(self, chat_record):
+ for data in chat_record.details.values():
+ if self.node.id == data["node_id"] and "video_list" in data:
+ video_list = data["video_list"] or []
+ if len(video_list) == 0 or data["dialogue_type"] == "WORKFLOW":
+ return HumanMessage(content=chat_record.problem_text)
+ file_id_list = []
+ url_list = []
+ for video in video_list:
+ if "file_id" in video:
+ file_id_list.append(video.get("file_id"))
+ elif "url" in video:
+ url_list.append(video.get("url"))
+ return HumanMessage(
+ content=[
+ {"type": "text", "text": data["question"]},
+ *[
+ {"type": "video_url", "video_url": {"url": f"./oss/file/{file_id}"}}
+ for file_id in file_id_list
+ ],
+ *[{"type": "video_url", "video_url": {"url": url}} for url in url_list],
+ ]
+ )
+ return HumanMessage(content=chat_record.problem_text)
+
+ def _get_history_message(self, history_chat_record, dialogue_number, video_model):
+ start_index = len(history_chat_record) - dialogue_number
+ history_message = reduce(
+ lambda x, y: [*x, *y],
+ [
+ [
+ self._generate_history_human_message(history_chat_record[index], video_model),
+ *self._generate_history_ai_message(history_chat_record[index]),
+ ]
+ for index in range(start_index if start_index > 0 else 0, len(history_chat_record))
+ ],
+ [],
+ )
+ return history_message
+
+ def _generate_history_human_message(self, chat_record, video_model):
+ for data in chat_record.details.values():
+ if self.node.id == data["node_id"] and "video_list" in data:
+ video_list = data["video_list"] or []
+ if len(video_list) == 0 or data["dialogue_type"] == "WORKFLOW":
+ return HumanMessage(content=chat_record.problem_text)
+ file_id_list = []
+ url_list = []
+ for video in video_list:
+ if "file_id" in video:
+ file_id_list.append(video.get("file_id"))
+ elif "url" in video:
+ url_list.append(video.get("url"))
+ video_base64_list = [self._file_id_to_base64(file_id, video_model) for file_id in file_id_list]
+ return HumanMessage(
+ content=[
+ {"type": "text", "text": data["question"]},
+ *[
+ {"type": "video_url", "video_url": {"url": base64_video}}
+ for base64_video in video_base64_list
+ ],
+ *[{"type": "video_url", "video_url": {"url": url}} for url in url_list],
+ ]
+ )
+ return HumanMessage(content=chat_record.problem_text)
+
+ @staticmethod
+ def _file_id_to_base64(file_id: str, video_model):
+ file = QuerySet(File).filter(id=file_id).first()
+ file_bytes = file.get_bytes()
+ return video_model.upload_file_and_get_url(file_bytes, file.file_name)
+
+ def _process_videos(self, video, video_model):
+ videos = []
+ if isinstance(video, str) and video.startswith("http"):
+ videos.append({"type": "video_url", "video_url": {"url": video}})
+ elif video is not None and len(video) > 0:
+ for v in video:
+ if "file_id" in v:
+ file_id = v["file_id"]
+ file = QuerySet(File).filter(id=file_id).first()
+ url = video_model.upload_file_and_get_url(file.get_bytes(), file.file_name)
+ videos.append({"type": "video_url", "video_url": {"url": url}})
+ elif "url" in v and v["url"].startswith("http"):
+ videos.append({"type": "video_url", "video_url": {"url": v["url"]}})
+ return videos
+
+ def _generate_message_list(self, video_model, system: str, prompt: str, history_message, video):
+ prompt_text = self.workflow_manage.generate_prompt(prompt)
+ videos = self._process_videos(video, video_model)
+
+ if videos:
+ messages = [HumanMessage(content=[{"type": "text", "text": prompt_text}, *videos])]
+ else:
+ messages = [HumanMessage(prompt_text)]
+
+ if system is not None and len(system) > 0:
+ return [SystemMessage(system), *history_message, *messages]
+ else:
+ return [*history_message, *messages]
+
+ def get_details(self, index: int = 0, position: dict = None, old_details: dict = None, **kwargs):
+ details = super().get_details(index, position, old_details, **kwargs)
+ details.update(
+ {
+ "question": self.get_context("question"),
+ "answer": self.get_context("answer"),
+ "video_list": self.get_context("video_list"),
+ "reasoning_content": self.get_context("reasoning_content"),
+ "message_tokens": self.get_context("message_tokens"),
+ "answer_tokens": self.get_context("answer_tokens"),
+ }
+ )
+ return details
diff --git a/apps/application/workflow/status.py b/apps/application/workflow/status.py
new file mode 100644
index 00000000000..3bb2b0db594
--- /dev/null
+++ b/apps/application/workflow/status.py
@@ -0,0 +1,22 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: status.py
+ @date:2026/6/30 15:47
+ @desc:
+"""
+from enum import Enum
+
+
+class Status(Enum):
+ # 成功
+ SUCCESS = "SUCCESS"
+ # 失败
+ FAIL = "FAIL"
+ # 运行中
+ RUNNING = "RUNNING"
+ # 运行前
+ BEFORE_RUNNING = "BEFORE_RUNNING"
+ # 取消
+ CANCELLED = "CANCELLED"
diff --git a/apps/application/workflow/tools.py b/apps/application/workflow/tools.py
new file mode 100644
index 00000000000..c5e6db6fb87
--- /dev/null
+++ b/apps/application/workflow/tools.py
@@ -0,0 +1,94 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: tools.py
+ @date:2026/6/29 18:44
+ @desc:
+"""
+
+
+class Reasoning:
+ def __init__(self, reasoning_content_start, reasoning_content_end):
+ self.content = ""
+ self.reasoning_content = ""
+ self.all_content = ""
+ self.reasoning_content_start_tag = reasoning_content_start
+ self.reasoning_content_end_tag = reasoning_content_end
+ self.reasoning_content_start_tag_len = len(
+ reasoning_content_start) if reasoning_content_start is not None else 0
+ self.reasoning_content_end_tag_len = len(reasoning_content_end) if reasoning_content_end is not None else 0
+ self.reasoning_content_end_tag_prefix = reasoning_content_end[
+ 0] if self.reasoning_content_end_tag_len > 0 else ''
+ self.reasoning_content_is_start = False
+ self.reasoning_content_is_end = False
+ self.reasoning_content_chunk = ""
+
+ def get_end_reasoning_content(self):
+ if not self.reasoning_content_is_start and not self.reasoning_content_is_end:
+ r = {'content': self.all_content, 'reasoning_content': ''}
+ self.reasoning_content_chunk = ""
+ return r
+ if self.reasoning_content_is_start and not self.reasoning_content_is_end:
+ r = {'content': '', 'reasoning_content': self.reasoning_content_chunk}
+ self.reasoning_content_chunk = ""
+ return r
+ return {'content': '', 'reasoning_content': ''}
+
+ def get_reasoning_content(self, chunk):
+ # 如果没有开始思考过程标签那么就全是结果
+ if self.reasoning_content_start_tag is None or len(self.reasoning_content_start_tag) == 0:
+ self.content += chunk.content
+ return {'content': chunk.content, 'reasoning_content': ''}
+ # 如果没有结束思考过程标签那么就全部是思考过程
+ if self.reasoning_content_end_tag is None or len(self.reasoning_content_end_tag) == 0:
+ return {'content': '', 'reasoning_content': chunk.content}
+ self.all_content += chunk.content
+ if not self.reasoning_content_is_start and len(self.all_content) >= self.reasoning_content_start_tag_len:
+ if self.all_content.startswith(self.reasoning_content_start_tag):
+ self.reasoning_content_is_start = True
+ self.reasoning_content_chunk = self.all_content[self.reasoning_content_start_tag_len:]
+ else:
+ if not self.reasoning_content_is_end:
+ self.reasoning_content_is_end = True
+ self.content += self.all_content
+ return {'content': self.all_content, 'reasoning_content': ''}
+ else:
+ if self.reasoning_content_is_start:
+ self.reasoning_content_chunk += chunk.content
+ reasoning_content_end_tag_prefix_index = self.reasoning_content_chunk.find(
+ self.reasoning_content_end_tag_prefix)
+ if self.reasoning_content_is_end:
+ self.content += chunk.content
+ return {'content': chunk.content, 'reasoning_content': ''}
+ # 是否包含结束
+ if reasoning_content_end_tag_prefix_index > -1:
+ if len(self.reasoning_content_chunk) - reasoning_content_end_tag_prefix_index >= self.reasoning_content_end_tag_len:
+ reasoning_content_end_tag_index = self.reasoning_content_chunk.find(self.reasoning_content_end_tag)
+ if reasoning_content_end_tag_index > -1:
+ reasoning_content_chunk = self.reasoning_content_chunk[0:reasoning_content_end_tag_index]
+ content_chunk = self.reasoning_content_chunk[
+ reasoning_content_end_tag_index + self.reasoning_content_end_tag_len:]
+ self.reasoning_content += reasoning_content_chunk
+ self.content += content_chunk
+ self.reasoning_content_chunk = ""
+ self.reasoning_content_is_end = True
+ return {'content': content_chunk, 'reasoning_content': reasoning_content_chunk}
+ else:
+ reasoning_content_chunk = self.reasoning_content_chunk[0:reasoning_content_end_tag_prefix_index + 1]
+ self.reasoning_content_chunk = self.reasoning_content_chunk.replace(reasoning_content_chunk, '')
+ self.reasoning_content += reasoning_content_chunk
+ return {'content': '', 'reasoning_content': reasoning_content_chunk}
+ else:
+ return {'content': '', 'reasoning_content': ''}
+
+ else:
+ if self.reasoning_content_is_end:
+ self.content += chunk.content
+ return {'content': chunk.content, 'reasoning_content': ''}
+ else:
+ # aaa
+ result = {'content': '', 'reasoning_content': self.reasoning_content_chunk}
+ self.reasoning_content += self.reasoning_content_chunk
+ self.reasoning_content_chunk = ""
+ return result
diff --git a/apps/application/workflow/workflow_manage.py b/apps/application/workflow/workflow_manage.py
new file mode 100644
index 00000000000..5360ccc2480
--- /dev/null
+++ b/apps/application/workflow/workflow_manage.py
@@ -0,0 +1,276 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: workflow_manage.py
+@date:2026/6/29 10:30
+@desc:
+"""
+
+from __future__ import annotations
+
+import threading
+from typing import List, Dict, Optional, Callable
+
+from application.workflow.common import Workflow, WorkflowType, Node, get_node_parameters
+from application.workflow.i_node import INode, Signal
+from application.workflow.message.struct.content import Content
+
+from application.workflow.status import Status
+from common.utils.prompt_template import render_prompt
+
+
+class CallBack:
+ def __init__(
+ self,
+ on_next: Callable[[WorkflowManage, Content], None],
+ on_complete: Callable[[WorkflowManage, Optional[Exception]], None],
+ ):
+ self.on_next = on_next
+ self.on_complete = on_complete
+
+
+class WorkflowManage:
+ # 工作流节点数据
+ context: Dict[Dict[str, any]]
+ # 运行的节点
+ nodes: List[INode]
+ # 是否结束
+ done: bool
+
+ def __init__(
+ self,
+ workflow: Workflow,
+ parameters: Dict,
+ workflow_type: WorkflowType,
+ call_back: CallBack,
+ get_start_node: Callable[[Workflow, WorkflowManage], INode],
+ ):
+ """
+
+ @param workflow: 工作流对象
+ @param workflow_type: 工作流类型
+ @param parameters: 工作流使用到的其他数据
+ """
+ self._lock = threading.Lock()
+ self.done = False
+ self.call_back = call_back
+ self.workflow = workflow
+ self.workflow_type = workflow_type
+ self.parameters = parameters
+ self.context = {}
+ self.nodes = []
+ self.node_dict = {}
+ self.signal = None
+ self.details = {"position": {}, "details": {}}
+ self.start_node = get_start_node(workflow, self)
+
+ def run(self):
+ """
+ 工作流执行
+ @return: None
+ """
+ self.nodes.append(self.start_node)
+ self.node_dict = {node.node.id: node for node in self.nodes}
+ self._run_async(self.start_node)
+
+ def _run(self, node):
+ node.run()
+
+ def next_nodes(self, nodes: Optional[List[Node]]):
+ """
+ 继续执行下面的节点
+ @param nodes: 执行下面要执行的节点
+ @return:
+ """
+ if [Signal.FORM, Signal.CANCELLED].__contains__(self.signal):
+ return
+ if nodes is None or len(nodes) == 0:
+ return
+ # 需要校验是否可执行
+ for n in nodes:
+ condition = n.properties.get("condition")
+ if condition == "AND":
+ up_nodes = self.workflow.get_up_nodes(n.id)
+ # 如果是AND就是前面所有节点都执行结束
+ unfinished = {Status.BEFORE_RUNNING, Status.RUNNING}
+ end = all(
+ [
+ self.node_dict.get(node.id) and self.node_dict.get(node.id).status not in unfinished
+ for node in up_nodes
+ ]
+ )
+ if not end:
+ return
+
+ with self._lock:
+ from application.workflow.nodes import get_node_class
+
+ instances = [get_node_class(n.type, self.workflow_type)(n, self, get_node_parameters) for n in nodes]
+ self.nodes.extend(instances)
+ for node in instances:
+ self.node_dict[node.node.id] = node
+ for inst in instances:
+ self._run_async(inst)
+
+ def assertion_end(self, error=None):
+ with self._lock:
+ if self.done:
+ return
+ if not self.is_end():
+ return
+ self.done = True # 锁内抢占,保证只有一个线程能往下走
+ self.end(error) # 回调放到锁外,避免回调里再触碰本 manage 造成重入/死锁
+
+ def is_end(self):
+ """
+ 工作流是否执行结束
+ @return: 是否执行结束
+ """
+ unfinished = {Status.BEFORE_RUNNING, Status.RUNNING}
+ return not any(node.status in unfinished for node in self.nodes)
+
+ def write_context(self, node_id, key, value, append=False):
+ """
+ 写入上下文
+ @param node_id: 节点id
+ @param key: 数据key
+ @param value: 数据value
+ @param append: 是否追加
+ @return: None
+ """
+ node_context = self.context.setdefault(node_id, {})
+ if append and key in node_context:
+ node_context[key] += value
+ else:
+ node_context[key] = value
+
+ def get_context(self, node_id, key):
+ """
+ 获取节点上下文的指定key的内容
+ @param node_id: 节点id
+ @param key: key
+ @return: 数据
+ """
+ node_context = self.context.get(node_id)
+ if node_context is None:
+ return None
+ return node_context.get(key)
+
+ def _run_async(self, node):
+ t = threading.Thread(target=lambda: self._run(node))
+ t.start()
+ return t
+
+ def invoke(self):
+ """
+ 非流式响应
+ @return: 没个节点的 块数据
+ """
+ self.run()
+
+ def write(self, message: Content):
+ """
+ 写入数据
+ @param message: 节点输出内容
+ @return: None
+ """
+ self.call_back.on_next(self, message)
+
+ def end(self, error=None):
+ """
+ 工作流输出结束的时候调用
+ @return: None
+ """
+ self.call_back.on_complete(self, error)
+
+ def get_parameters(self):
+ """
+ 获取工作流的参数信息
+ @return: 工作流参数信息
+ """
+ return self.parameters
+
+ def get_details(self, position: Dict = None, old_details=None):
+ """
+ 获取所有节点的运行详情
+ @param position: 位置信息,用于表单节点等需要断点续跑的场景
+ @param old_details: 旧的详情数据,用于表单节点等断点续跑场景
+ @return: 包含position和details的字典
+ """
+ details_result = []
+ position_index = 0
+ if old_details and position:
+ for index, value in enumerate(old_details):
+ details_result.append(value)
+ if position.get("id") == value.get("node_id"):
+ position_index = index
+ for index, node in enumerate(self.nodes):
+ if position is not None and node.node.id == position.get("id") and index == 0:
+ details = node.get_details(
+ index + position_index, position=position, old_details=old_details[position_index]
+ )
+ details_result[position_index] = details
+ else:
+ details = node.get_details(index + position_index)
+ details_result.append(details)
+ return details_result
+
+ def generate_prompt(self, prompt):
+ """
+ 处理提示词
+ @param prompt: 提示词
+ @return: 处理后的提示词
+ """
+ input_template = self.workflow.reset_prompt(prompt)
+ return render_prompt(input_template, self.context)
+
+ def get_reference_field(self, node_id, fields):
+ """
+ 获取引用字段
+ @param node_id: 节点id
+ @param fields: 字段
+ @return: 引用数据
+ """
+ node_context = self.context.get(node_id)
+ if node_context is None:
+ return None
+ obj = node_context
+ for field in fields:
+ if isinstance(obj, dict):
+ obj = obj.get(field)
+ else:
+ return None
+ return obj
+
+ @classmethod
+ def from_context(cls, get_context, workflow, parameters, workflow_type, call_back, get_start_node):
+ """
+ 恢复 WorkflowManage:调用 get_context() 拿到历史 context 并用它重建实例。
+ context 从何而来(DB、缓存或其它)由调用方通过 get_context 决定,引擎不关心其业务来源;
+ get_context 返回空或抛异常则返回 None,调用方可据此回退为全新执行。
+ """
+ try:
+ context = get_context()
+ if not context:
+ return None
+ instance = cls(
+ workflow=workflow,
+ parameters=parameters,
+ workflow_type=workflow_type,
+ call_back=call_back,
+ get_start_node=get_start_node,
+ )
+ # 恢复全局 context
+ instance.context = context
+ return instance
+ except Exception:
+ import traceback
+
+ traceback.print_exc()
+ return None
+
+ def cancel(self):
+ self.signal = Signal.CANCELLED
+ for node in self.nodes:
+ node.cancel()
diff --git a/apps/application/workflow/workflow_run_registry.py b/apps/application/workflow/workflow_run_registry.py
new file mode 100644
index 00000000000..5e240eb84c4
--- /dev/null
+++ b/apps/application/workflow/workflow_run_registry.py
@@ -0,0 +1,168 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @file: workflow_run_registry.py
+ @desc: 工作流运行注册表,用于管理和取消正在运行的工作流实例
+"""
+import threading
+from enum import Enum
+
+from common.utils.logger import maxkb_logger
+
+
+class CancelResult(Enum):
+ """取消操作结果"""
+ CANCELLED = "CANCELLED"
+ NOT_FOUND = "NOT_FOUND"
+ FAILED = "FAILED"
+
+
+class WorkflowRunRegistry:
+ _lock = threading.Lock()
+ _running = {} # {chat_record_id: WorkflowManage}
+ _chat_to_records = {} # {chat_id: set[chat_record_id]}
+
+ @classmethod
+ def register(cls, chat_record_id: str, chat_id: str, workflow_manage) -> None:
+ """
+ 注册一个正在运行的工作流实例
+ @param chat_record_id: 聊天记录ID
+ @param chat_id: 聊天ID
+ @param workflow_manage: WorkflowManage 实例
+ """
+ if not chat_record_id or not workflow_manage:
+ return
+ with cls._lock:
+ cls._running[str(chat_record_id)] = workflow_manage
+ if chat_id:
+ if chat_id not in cls._chat_to_records:
+ cls._chat_to_records[chat_id] = set()
+ cls._chat_to_records[chat_id].add(str(chat_record_id))
+ maxkb_logger.debug(f"Workflow registered: {chat_record_id}, total running: {len(cls._running)}")
+
+ @classmethod
+ def unregister(cls, chat_record_id: str, chat_id: str = None) -> None:
+ """
+ 注销一个工作流实例(无论成功/失败/取消都应调用)
+ @param chat_record_id: 聊天记录ID
+ @param chat_id: 聊天ID
+ """
+ if not chat_record_id:
+ return
+ with cls._lock:
+ removed = cls._running.pop(str(chat_record_id), None)
+ if chat_id and chat_id in cls._chat_to_records:
+ cls._chat_to_records[chat_id].discard(str(chat_record_id))
+ if not cls._chat_to_records[chat_id]:
+ del cls._chat_to_records[chat_id]
+ if removed is not None:
+ maxkb_logger.debug(f"Workflow unregistered: {chat_record_id}, total running: {len(cls._running)}")
+
+ @classmethod
+ def cancel_by_chat_id(cls, chat_id: str) -> CancelResult:
+ """
+ 取消某个聊天下所有运行中的工作流
+ @param chat_id: 聊天ID
+ @return: CancelResult
+ """
+ if not chat_id:
+ return CancelResult.NOT_FOUND
+
+ with cls._lock:
+ record_ids = list(cls._chat_to_records.get(chat_id, set()))
+
+ if not record_ids:
+ maxkb_logger.info(f"Cancel requested but no running workflow found for chat: {chat_id}")
+ return CancelResult.NOT_FOUND
+
+ cancelled_count = 0
+ failed_count = 0
+ for record_id in record_ids:
+ with cls._lock:
+ wm = cls._running.get(record_id)
+ if wm:
+ try:
+ wm.cancel()
+ cancelled_count += 1
+ maxkb_logger.info(f"Cancel signal sent to workflow: {record_id}")
+ except Exception as e:
+ failed_count += 1
+ maxkb_logger.error(f"Failed to cancel workflow: {record_id}, error: {e}")
+
+ if failed_count > 0 and cancelled_count == 0:
+ return CancelResult.FAILED
+ return CancelResult.CANCELLED
+
+ @classmethod
+ def cancel_by_record_id(cls, chat_record_id: str) -> CancelResult:
+ """
+ 取消某个特定的工作流
+ @param chat_record_id: 聊天记录ID
+ @return: CancelResult
+ """
+ if not chat_record_id:
+ return CancelResult.NOT_FOUND
+
+ with cls._lock:
+ wm = cls._running.get(str(chat_record_id))
+
+ if wm is None:
+ maxkb_logger.info(f"Cancel requested but workflow not found (may already finished): {chat_record_id}")
+ return CancelResult.NOT_FOUND
+
+ try:
+ wm.cancel()
+ maxkb_logger.info(f"Cancel signal sent to workflow: {chat_record_id}")
+ return CancelResult.CANCELLED
+ except Exception as e:
+ maxkb_logger.error(f"Failed to cancel workflow: {chat_record_id}, error: {e}")
+ return CancelResult.FAILED
+
+ @classmethod
+ def get(cls, chat_record_id: str):
+ """
+ 获取正在运行的工作流实例
+ @param chat_record_id: 聊天记录ID
+ @return: WorkflowManage 实例或 None
+ """
+ if not chat_record_id:
+ return None
+ return cls._running.get(str(chat_record_id))
+
+ @classmethod
+ def is_running(cls, chat_record_id: str) -> bool:
+ """
+ 检查工作流是否正在运行
+ @param chat_record_id: 聊天记录ID
+ @return: 是否正在运行
+ """
+ return chat_record_id is not None and str(chat_record_id) in cls._running
+
+ @classmethod
+ def is_chat_running(cls, chat_id: str) -> bool:
+ """
+ 检查某个聊天是否有正在运行的工作流
+ @param chat_id: 聊天ID
+ @return: 是否有正在运行的工作流
+ """
+ if not chat_id:
+ return False
+ with cls._lock:
+ return chat_id in cls._chat_to_records and len(cls._chat_to_records[chat_id]) > 0
+
+ @classmethod
+ def running_count(cls) -> int:
+ """
+ 获取正在运行的工作流数量
+ @return: 数量
+ """
+ return len(cls._running)
+
+ @classmethod
+ def running_ids(cls) -> list:
+ """
+ 获取所有正在运行的工作流ID列表
+ @return: ID列表
+ """
+ with cls._lock:
+ return list(cls._running.keys())
diff --git a/apps/chat/api/chat_authentication_api.py b/apps/chat/api/chat_authentication_api.py
index 6f6b1b1a835..c344931a1bc 100644
--- a/apps/chat/api/chat_authentication_api.py
+++ b/apps/chat/api/chat_authentication_api.py
@@ -1,10 +1,10 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: chat_authentication_api.py
- @date:2025/6/6 19:59
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: chat_authentication_api.py
+@date:2025/6/6 19:59
+@desc:
"""
from django.utils.translation import gettext_lazy as _
@@ -25,31 +25,62 @@ def get_request():
class ChatAuthenticationAPI(APIMixin):
@staticmethod
def get_request():
- return AnonymousAuthenticationSerializer
+ return None
@staticmethod
def get_parameters():
- pass
+ return [
+ OpenApiParameter(
+ name="application_id",
+ description=_("Application ID"),
+ type=OpenApiTypes.UUID,
+ location="query",
+ required=False,
+ )
+ ]
@staticmethod
def get_response():
pass
-class ChatAuthenticationProfileAPI(APIMixin):
+class ChatAuthenticationProfileAPIV2(APIMixin):
+ @staticmethod
+ def get_parameters():
+ return [
+ OpenApiParameter(
+ name="access_token",
+ description=_("access_token"),
+ type=OpenApiTypes.STR,
+ location="query",
+ required=True,
+ )
+ ]
+
+class ChatAuthenticationProfileAPI(APIMixin):
@staticmethod
def get_parameters():
- return [OpenApiParameter(
- name="access_token",
- description=_("access_token"),
- type=OpenApiTypes.STR,
- location='query',
- required=True,
- )]
+ return [
+ OpenApiParameter(
+ name="application_id",
+ description=_("Application ID"),
+ type=OpenApiTypes.UUID,
+ location="query",
+ required=True,
+ )
+ ]
class ChatOpenAPI(APIMixin):
@staticmethod
def get_parameters():
- return []
+ return [
+ OpenApiParameter(
+ name="application_id",
+ description=_("Application ID"),
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ required=True,
+ )
+ ]
diff --git a/apps/chat/api/portal_api.py b/apps/chat/api/portal_api.py
new file mode 100644
index 00000000000..db9b1d6fb03
--- /dev/null
+++ b/apps/chat/api/portal_api.py
@@ -0,0 +1,128 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:MaxKB
+@file: portal.py
+@date:2026/8/14
+@desc: 门户API文档
+"""
+
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter
+
+from common.mixins.api_mixin import APIMixin
+from common.result import DefaultResultSerializer
+from users.serializers.login import LoginRequest
+
+
+class PortalAPI(APIMixin):
+ class Get(APIMixin):
+ @staticmethod
+ def get_response():
+ return DefaultResultSerializer
+
+ class Save(APIMixin):
+ @staticmethod
+ def get_request():
+ return {
+ "multipart/form-data": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string", "description": "门户名称"},
+ "description": {"type": "string", "description": "门户描述"},
+ "logo": {"type": "string", "format": "binary", "description": "门户Logo"},
+ "tab_logo": {"type": "string", "format": "binary", "description": "浏览器Tab Logo"},
+ "enable_public_access": {"type": "boolean", "description": "是否开启公开访问"},
+ "enable_api": {"type": "boolean", "description": "是否开启API服务"},
+ "enable_auth": {"type": "boolean", "description": "是否开启身份认证"},
+ "auth_config": {"type": "object", "description": "身份认证配置"},
+ "enable_cors": {"type": "boolean", "description": "是否开启跨域设置"},
+ "cors_config": {"type": "object", "description": "跨域配置"},
+ },
+ }
+ }
+
+ @staticmethod
+ def get_response():
+ return DefaultResultSerializer
+
+ class Application(APIMixin):
+ @staticmethod
+ def get_parameters():
+ return [
+ OpenApiParameter(
+ name="current_page",
+ description="当前页码",
+ type=OpenApiTypes.INT,
+ location="path",
+ required=True,
+ ),
+ OpenApiParameter(
+ name="page_size",
+ description="每页数量",
+ type=OpenApiTypes.INT,
+ location="path",
+ required=True,
+ ),
+ OpenApiParameter(
+ name="name",
+ description="应用名称搜索",
+ type=OpenApiTypes.STR,
+ location="query",
+ required=False,
+ ),
+ ]
+
+ @staticmethod
+ def get_response():
+ return DefaultResultSerializer
+
+ class Login(APIMixin):
+ @staticmethod
+ def get_request():
+ return LoginRequest
+
+ @staticmethod
+ def get_response():
+ return DefaultResultSerializer
+
+ class Info(APIMixin):
+ @staticmethod
+ def get_response():
+ return DefaultResultSerializer
+
+ class Logout(APIMixin):
+ @staticmethod
+ def get_response():
+ return DefaultResultSerializer
+
+ class Conversation(APIMixin):
+ @staticmethod
+ def get_parameters():
+ return [
+ OpenApiParameter(
+ name="current_page",
+ description="当前页码",
+ type=OpenApiTypes.INT,
+ location="path",
+ required=True,
+ ),
+ OpenApiParameter(
+ name="page_size",
+ description="每页数量",
+ type=OpenApiTypes.INT,
+ location="path",
+ required=True,
+ ),
+ OpenApiParameter(
+ name="name",
+ description="应用名称搜索",
+ type=OpenApiTypes.STR,
+ location="query",
+ required=False,
+ ),
+ ]
+
+ @staticmethod
+ def get_response():
+ return DefaultResultSerializer
diff --git a/apps/chat/mcp/knowledge.py b/apps/chat/mcp/knowledge.py
new file mode 100644
index 00000000000..223b40a1f40
--- /dev/null
+++ b/apps/chat/mcp/knowledge.py
@@ -0,0 +1,66 @@
+"""Knowledge equivalent of the application's MCPToolHandler."""
+
+import json
+
+from rest_framework.exceptions import ValidationError
+
+from knowledge.services.external_retrieval import retrieve
+from knowledge.services.retrieval_access import RetrievalError
+
+PROTOCOL_VERSIONS = ("2025-03-26", "2025-06-18", "2025-11-25")
+
+
+class KnowledgeMCPToolHandler:
+ def __init__(self, knowledge, identity):
+ self.knowledge, self.identity = knowledge, identity
+ self.tool_name = f"knowledge_{knowledge.id}"
+
+ def initialize(self, params):
+ version = params.get("protocolVersion")
+ if (
+ not isinstance(version, str)
+ or not isinstance(params.get("capabilities"), dict)
+ or not isinstance(params.get("clientInfo"), dict)
+ ):
+ raise ValidationError("Invalid initialization parameters.")
+ return {
+ "protocolVersion": version if version in PROTOCOL_VERSIONS else "2025-06-18",
+ "serverInfo": {"name": "maxkb-knowledge-mcp", "version": "1.0.0"},
+ "capabilities": {"tools": {}},
+ }
+
+ def list_tools(self):
+ return {
+ "tools": [
+ {
+ "name": self.tool_name,
+ "description": f"检索知识库:{self.knowledge.name}",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": False,
+ "required": ["query_text"],
+ "properties": {
+ "query_text": {"type": "string", "minLength": 1, "maxLength": 8000},
+ "top_number": {"type": "integer", "minimum": 1, "maximum": 50, "default": 5},
+ "similarity": {"type": "number", "minimum": 0, "maximum": 1, "default": 0},
+ "search_mode": {
+ "type": "string",
+ "enum": ["embedding", "keywords", "blend"],
+ "default": "embedding",
+ },
+ },
+ },
+ }
+ ]
+ }
+
+ def call_tool(self, params):
+ if params.get("name") != self.tool_name:
+ raise ValidationError("Unknown tool.")
+ try:
+ output = retrieve(self.knowledge.id, self.identity, params.get("arguments", {}))
+ except (RetrievalError, ValidationError):
+ raise
+ except Exception:
+ return {"isError": True, "content": [{"type": "text", "text": "Knowledge retrieval failed."}]}
+ return {"content": [{"type": "text", "text": json.dumps(output, ensure_ascii=False)}]}
diff --git a/apps/chat/mcp/tools.py b/apps/chat/mcp/tools.py
index 4a3ff972388..ea9ea8ea9d6 100644
--- a/apps/chat/mcp/tools.py
+++ b/apps/chat/mcp/tools.py
@@ -1,87 +1,174 @@
+import base64
import json
import re
import uuid_utils.compat as uuid
+from application.models import Application, ApplicationApiKey, ChatSourceChoices, ChatUserType
from django.db.models import QuerySet
+from django.utils import timezone
-from application.models import ApplicationApiKey, Application, ChatUserType, ChatSourceChoices
from chat.serializers.chat import ChatSerializers
+CHAT_FILE_LIST_FIELDS = ("image_list", "document_list", "audio_list", "video_list", "other_list")
+
+CHAT_FILE_TYPE_LABELS = {
+ "image_list": "image",
+ "document_list": "document",
+ "audio_list": "audio",
+ "video_list": "video",
+ "other_list": "file",
+}
+
class MCPToolHandler:
- def __init__(self, auth_header):
+ def __init__(self, auth_header, chat_files_header=None, form_data=None):
app_key = QuerySet(ApplicationApiKey).filter(secret_key=auth_header, is_active=True).first()
if not app_key:
raise PermissionError("Invalid API Key")
+ if app_key.is_permanent is False and app_key.expire_time < timezone.now():
+ raise PermissionError("API Key is expired")
self.application = QuerySet(Application).filter(id=app_key.application_id, is_publish=True).first()
if not self.application:
raise PermissionError("Application is not found or not published")
+ self.chat_files = self.decode_chat_files(chat_files_header)
+ self.form_data = self.decode_form_data(form_data)
+
+ @staticmethod
+ def decode_chat_files(chat_files_header):
+ """
+ 解析上层应用透传过来的文件列表
+ """
+ if not chat_files_header:
+ return {}
+ try:
+ chat_files = json.loads(base64.b64decode(chat_files_header).decode("utf-8"))
+ except Exception:
+ return {}
+ if not isinstance(chat_files, dict):
+ return {}
+ return {
+ key: value
+ for key, value in chat_files.items()
+ if key in CHAT_FILE_LIST_FIELDS and isinstance(value, list) and len(value) > 0
+ }
+
+ @staticmethod
+ def decode_form_data(form_data):
+ """
+ 解析上层应用透传过来的表单数据
+ """
+ if not form_data:
+ return {}
+ try:
+ form_data = json.loads(base64.b64decode(form_data).decode("utf-8"))
+ except Exception:
+ return {}
+ if not isinstance(form_data, dict):
+ return {}
+ return form_data
def initialize(self):
return {
"protocolVersion": "2025-06-18",
- "serverInfo": {
- "name": "maxkb-mcp",
- "version": "1.0.0"
- },
- "capabilities": {
- "tools": {}
- }
+ "serverInfo": {"name": "maxkb-mcp", "version": "1.0.0"},
+ "capabilities": {"tools": {}},
}
+ def build_description(self):
+ """
+ 工具描述中带上当前对话已上传的文件, 否则上层模型不知道子应用可以处理这些文件
+ """
+ description = f"{self.application.name} {self.application.desc}"
+ file_desc_list = []
+ for field, file_list in self.chat_files.items():
+ name_list = [
+ str(file.get("name") or file.get("file_id"))
+ for file in file_list
+ if isinstance(file, dict) and (file.get("name") or file.get("file_id"))
+ ]
+ if name_list:
+ file_desc_list.append(f"{CHAT_FILE_TYPE_LABELS.get(field, 'file')}: {', '.join(name_list)}")
+ if not file_desc_list:
+ return description
+ return (
+ f"{description}\n"
+ "The user has attached the following files to the current conversation. "
+ "They are forwarded to this AI automatically, so it can read and process them directly "
+ "and you do NOT need to pass them as arguments: "
+ f"{'; '.join(file_desc_list)}."
+ )
+
def list_tools(self):
return {
"tools": [
{
- "name": f'agent_{str(self.application.id)[:8]}',
- "description": f'{self.application.name} {self.application.desc}',
+ "name": f"agent_{str(self.application.id)}",
+ "description": self.build_description(),
"inputSchema": {
"type": "object",
"properties": {
"message": {"type": "string", "description": "The message to send to the AI."},
},
- "required": ["message"]
- }
+ "required": ["message"],
+ },
}
]
}
def _get_chat_id(self):
from application.models import ChatUserType
- from chat.serializers.chat import OpenChatSerializers
from common.init import init_template
+ from chat.serializers.chat import OpenChatSerializers
+
init_template.run()
- return OpenChatSerializers(data={
- 'application_id': self.application.id,
- 'chat_user_id': str(uuid.uuid7()),
- 'chat_user_type': ChatUserType.ANONYMOUS_USER,
- 'ip_address': '-',
- 'source': {"type": ChatSourceChoices.ONLINE.value},
- 'debug': False
- }).open()
+ return OpenChatSerializers(
+ data={
+ "application_id": self.application.id,
+ "chat_user_id": str(uuid.uuid7()),
+ "chat_user_type": ChatUserType.ANONYMOUS_USER,
+ "ip_address": "-",
+ "source": {"type": ChatSourceChoices.ONLINE.value},
+ "debug": False,
+ }
+ ).open()
+
+ def build_form_data(self, message):
+ """
+ 合并父应用透传参数与提示词中的 JSON 参数,提示词参数优先。
+ """
+ try:
+ message_form_data = json.loads(message or "{}")
+ except (TypeError, json.JSONDecodeError):
+ message_form_data = {}
+ if not isinstance(message_form_data, dict):
+ message_form_data = {}
+ return {**self.form_data, **message_form_data}
def call_tool(self, params):
- name = params["name"]
args = params.get("arguments", {})
- # print(params)
+ message = args.get("message")
payload = {
- 'message': args.get('message'),
- 'stream': True,
- 're_chat': False
+ "message": message,
+ "stream": True,
+ "re_chat": False,
+ "form_data": self.build_form_data(message),
+ **self.chat_files,
}
- resp = ChatSerializers(data={
- 'chat_id': self._get_chat_id(),
- 'chat_user_id': str(uuid.uuid7()),
- 'chat_user_type': ChatUserType.ANONYMOUS_USER,
- 'application_id': self.application.id,
- 'ip_address': '-',
- 'source': {"type": ChatSourceChoices.ONLINE.value},
- 'debug': False,
- }).chat(payload)
+ resp = ChatSerializers(
+ data={
+ "chat_id": self._get_chat_id(),
+ "chat_user_id": str(uuid.uuid7()),
+ "chat_user_type": ChatUserType.ANONYMOUS_USER,
+ "application_id": self.application.id,
+ "ip_address": "-",
+ "source": {"type": ChatSourceChoices.ONLINE.value},
+ "debug": False,
+ }
+ ).chat(payload)
chunks = []
for raw_line in resp:
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
@@ -99,7 +186,7 @@ def call_tool(self, params):
if event.get("is_end"):
break
- data = ''.join(chunks)
+ data = "".join(chunks)
# 排除标签
- data = re.sub(r'.*?', '', data, flags=re.DOTALL)
+ data = re.sub(r".*?", "", data, flags=re.DOTALL)
return {"content": [{"type": "text", "text": data}]}
diff --git a/apps/chat/serializers/chat.py b/apps/chat/serializers/chat.py
index 58f23136638..ffe5d60ef79 100644
--- a/apps/chat/serializers/chat.py
+++ b/apps/chat/serializers/chat.py
@@ -1,210 +1,442 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: chat.py
- @date:2025/6/9 11:23
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: chat.py
+@date:2025/6/9 11:23
+@desc: 对话新实现(统一走 workflow 引擎、去除 ChatInfo 与 Redis 会话缓存)。
"""
+
import json
import os
-from gettext import gettext
-from typing import List, Dict
+import queue
+import queue as thread_queue
+import threading
+import uuid_utils
import uuid_utils.compat as uuid
from django.db.models import QuerySet
+from django.http import StreamingHttpResponse
+from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from rest_framework import serializers
-
-from application.chat_pipeline.pipeline_manage import PipelineManage
-from application.chat_pipeline.step.chat_step.i_chat_step import PostResponseHandler
-from application.chat_pipeline.step.chat_step.impl.base_chat_step import BaseChatStep
-from application.chat_pipeline.step.generate_human_message_step.impl.base_generate_human_message_step import \
- BaseGenerateHumanMessageStep
-from application.chat_pipeline.step.reset_problem_step.impl.base_reset_problem_step import BaseResetProblemStep
-from application.chat_pipeline.step.search_dataset_step.impl.base_search_dataset_step import BaseSearchDatasetStep
-from application.flow.common import Answer, Workflow
-from application.flow.i_step_node import WorkFlowPostHandler
-from application.flow.tools import to_stream_response_simple
-from application.flow.workflow_manage import WorkflowManage
-from application.models import Application, ApplicationTypeChoices, \
- ChatUserType, ApplicationChatUserStats, ApplicationAccessToken, ChatRecord, Chat, ApplicationVersion
+from rest_framework.request import Request
+
+from common.utils.common import to_stream_response_simple
+from application.models import (
+ Application,
+ ApplicationVersion,
+ ApplicationAccessToken,
+ ApplicationChatUserStats,
+ Chat,
+ ChatRecord,
+ ChatUserType,
+ ExecuteType,
+)
from application.serializers.application import ApplicationOperateSerializer
-from application.serializers.common import ChatInfo
-from common.database_model_manage.database_model_manage import DatabaseModelManage
+from application.serializers.application_chat import ChatCountSerializer
+from application.serializers.common import load_debug_workflow_context, resolve_chat_user, resolve_chat_user_group
+from chat.serializers.chat_history import ChatHistory
+from application.workflow.common import WorkflowType, new_instance
+from application.workflow.message.aggregator import AggregationManager
+from application.workflow.message.struct.failure_content import FailureContent
+from application.workflow.message_queue import get_message_queue
+from application.workflow.nodes import get_start_node
+from application.workflow.workflow_manage import WorkflowManage, CallBack
+from application.workflow.workflow_run_registry import WorkflowRunRegistry
+from knowledge.services.retrieval_access import identity_from_server
+from chat.template.agent_simple import build_workflow
+from common import result
from common.exception.app_exception import AppApiException, AppChatNumOutOfBoundsFailed, ChatException
from common.handle.base_to_response import BaseToResponse
from common.handle.impl.response.openai_to_response import OpenaiToResponse
from common.handle.impl.response.system_to_response import SystemToResponse
-from common.utils.common import flat_map, get_file_content, is_valid_uuid
-from knowledge.models import Document, Paragraph
+from common.utils.common import get_file_content
+from common.utils.logger import maxkb_logger
from maxkb.conf import PROJECT_DIR
from models_provider.models import Model, Status
from models_provider.tools import get_model_instance_by_model_workspace_id
-from system_manage.models.resource_mapping import ResourceMapping
+from system_manage.models.chat_user_token_quota import ChatUserTokenQuota
+_CHAT_UNSET = object()
-class ChatMessagesSerializers(serializers.Serializer):
- role = serializers.CharField(required=True, label=_("Role"))
- content = serializers.CharField(required=True, label=_("Content"))
-
-class GeneratePromptSerializers(serializers.Serializer):
- prompt = serializers.CharField(required=True, label=_("Prompt template"))
- messages = serializers.ListSerializer(child=ChatMessagesSerializers(), required=True, label=_("Chat context"))
-
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
- messages = self.data.get("messages")
-
- if len(messages) > 30:
- raise AppApiException(400, _("Too many messages"))
-
- for index in range(len(messages)):
- role = messages[index].get('role')
- if role == 'ai' and index % 2 != 1:
- raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct."))
- if role == 'user' and index % 2 != 0:
- raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct."))
- if role not in ['user', 'ai']:
- raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct."))
+def get_work_flow(application):
+ if application.type == "WORK_FLOW":
+ return application.work_flow
+ return build_workflow(application)
class ChatMessageSerializers(serializers.Serializer):
- message = serializers.CharField(required=True, label=_("User Questions"))
- stream = serializers.BooleanField(required=True,
- label=_("Is the answer in streaming mode"))
- re_chat = serializers.BooleanField(required=True, label=_("Do you want to reply again"))
- chat_record_id = serializers.UUIDField(required=False, allow_null=True,
- label=_("Conversation record id"))
-
- node_id = serializers.CharField(required=False, allow_null=True, allow_blank=True,
- label=_("Node id"))
-
- runtime_node_id = serializers.CharField(required=False, allow_null=True, allow_blank=True,
- label=_("Runtime node id"))
-
- node_data = serializers.DictField(required=False, allow_null=True,
- label=_("Node parameters"))
+ """新流程的对话入参(去掉旧工作流调试字段 node_id/runtime_node_id/node_data/child_node)。"""
+ message = serializers.DictField(required=True, label=_("User Questions"))
+ stream = serializers.BooleanField(required=False, default=True, label=_("Is the answer in streaming mode"))
+ re_chat = serializers.BooleanField(required=False, default=False, label=_("Do you want to reply again"))
+ chat_record_id = serializers.UUIDField(required=False, allow_null=True, label=_("Conversation record id"))
form_data = serializers.DictField(required=False, label=_("Global variables"))
- image_list = serializers.ListField(required=False, label=_("picture"))
- document_list = serializers.ListField(required=False, label=_("document"))
- audio_list = serializers.ListField(required=False, label=_("Audio"))
- other_list = serializers.ListField(required=False, label=_("Other"))
- child_node = serializers.DictField(required=False, allow_null=True,
- label=_("Child Nodes"))
-
-
-def get_post_handler(chat_info: ChatInfo):
- class PostHandler(PostResponseHandler):
-
- def handler(self,
- chat_id,
- chat_record_id,
- paragraph_list: List[Paragraph],
- problem_text: str,
- answer_text,
- manage: PipelineManage,
- step: BaseChatStep,
- padding_problem_text: str = None,
- **kwargs):
- answer_list = [[Answer(answer_text, 'ai-chat-node', 'ai-chat-node', 'ai-chat-node', {}, 'ai-chat-node',
- kwargs.get('reasoning_content', '')).to_dict()]]
- chat_record = ChatRecord(id=chat_record_id,
- chat_id=chat_id,
- problem_text=problem_text,
- answer_text=answer_text,
- details=manage.get_details(),
- message_tokens=manage.context['message_tokens'],
- answer_tokens=manage.context['answer_tokens'],
- answer_text_list=answer_list,
- run_time=manage.context['run_time'],
- index=len(chat_info.chat_record_list) + 1,
- ip_address=chat_info.ip_address,
- source=chat_info.source
- )
- chat_info.append_chat_record(chat_record)
- # 重新设置缓存
- chat_info.set_cache()
-
- return PostHandler()
+ # Form 提交时的定位信息 {id, index, children}
+ position = serializers.DictField(required=False, allow_null=True, label=_("Form position"))
+ chunk_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Chunk id"))
class DebugChatSerializers(serializers.Serializer):
chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
+ workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
+ application_id = serializers.UUIDField(required=True, label=_("Application ID"))
+ chat_user_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Client id"))
+ chat_user_type = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Client Type"))
+ ip_address = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("IP Address"))
+ source = serializers.JSONField(required=False, allow_null=True, label=_("Source"))
def chat(self, instance: dict, base_to_response: BaseToResponse = SystemToResponse()):
self.is_valid(raise_exception=True)
- chat_id = self.data.get('chat_id')
- chat_info: ChatInfo = ChatInfo.get_cache(chat_id)
- application = QuerySet(Application).filter(id=chat_info.application_id).first()
- chat_info.application = application
- return ChatSerializers(data={
- 'chat_id': chat_id, "chat_user_id": chat_info.chat_user_id,
- "chat_user_type": chat_info.chat_user_type,
- "application_id": chat_info.application.id, "debug": True
- }).chat(instance, base_to_response)
+ return ChatSerializers(
+ data={
+ "chat_id": self.data.get("chat_id"),
+ "chat_user_id": self.data.get("chat_user_id"),
+ "chat_user_type": self.data.get("chat_user_type"),
+ "application_id": self.data.get("application_id"),
+ "ip_address": self.data.get("ip_address"),
+ "source": self.data.get("source"),
+ "debug": True,
+ }
+ ).chat(instance, base_to_response)
-SYSTEM_ROLE = get_file_content(os.path.join(PROJECT_DIR, "apps", "chat", 'template', 'generate_prompt_system'))
+class ChatSerializers(serializers.Serializer):
+ chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
+ chat_user_id = serializers.CharField(required=True, label=_("Client id"))
+ chat_user_type = serializers.CharField(required=True, label=_("Client Type"))
+ application_id = serializers.UUIDField(required=True, allow_null=True, label=_("Application ID"))
+ debug = serializers.BooleanField(required=False, label=_("Debug"))
+ ip_address = serializers.CharField(required=False, label=_("IP Address"), allow_null=True, allow_blank=True)
+ source = serializers.JSONField(required=False, label=_("Source"))
+ # ---------- 会话行(一次查询,全程复用) ----------
+ def get_chat(self):
+ """查询 Chat 行并缓存到实例,全流程只查一次(区分未查询/不存在)。"""
+ cached = getattr(self, "_chat_cache", _CHAT_UNSET)
+ if cached is _CHAT_UNSET:
+ cached = QuerySet(Chat).filter(id=self.data.get("chat_id")).first()
+ self._chat_cache = cached
+ return cached
+
+ # ---------- 校验 ----------
+ def is_valid_chat(self):
+ """
+ 会话不存在 → 视为新会话,后续 ensure_chat_row 惰性创建,无需前端传标记;
+ 会话已存在 → 校验归属(必须属于当前应用与当前对话用户),防止越权写入。
+ debug 会话同样落库(execute_type=DEBUG)、同样按此校验,不再特殊放行。
+ """
+ chat = self.get_chat()
+ if chat is None:
+ return
+ if str(chat.application_id) != str(self.data.get("application_id")) or str(chat.chat_user_id) != str(
+ self.data.get("chat_user_id")
+ ):
+ raise ChatException(500, _("Conversation does not exist"))
-class PromptGenerateSerializer(serializers.Serializer):
- workspace_id = serializers.CharField(required=False, label=_('Workspace ID'))
- model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model"))
- application_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Application"))
+ def is_valid_intraday_access_num(self):
+ if not self.data.get("debug") and [
+ ChatUserType.ANONYMOUS_USER.value,
+ ChatUserType.CHAT_USER.value,
+ ].__contains__(self.data.get("chat_user_type")):
+ access_client = (
+ QuerySet(ApplicationChatUserStats)
+ .filter(chat_user_id=self.data.get("chat_user_id"), application_id=self.data.get("application_id"))
+ .first()
+ )
+ if access_client is None:
+ access_client = ApplicationChatUserStats(
+ chat_user_id=self.data.get("chat_user_id"),
+ chat_user_type=self.data.get("chat_user_type"),
+ application_id=self.data.get("application_id"),
+ access_num=0,
+ intraday_access_num=0,
+ )
+ access_client.save()
- def is_valid(self, *, raise_exception=False):
- super().is_valid(raise_exception=True)
- workspace_id = self.data.get('workspace_id')
- query_set = QuerySet(Application).filter(id=self.data.get('application_id'))
- if workspace_id:
- query_set = query_set.filter(workspace_id=workspace_id)
- application = query_set.first()
- if application is None:
- raise AppApiException(500, _('Application id does not exist'))
- return application
+ application_access_token = (
+ QuerySet(ApplicationAccessToken).filter(application_id=self.data.get("application_id")).first()
+ )
+ if application_access_token.access_num <= access_client.intraday_access_num:
+ raise AppChatNumOutOfBoundsFailed(1002, _("The number of visits exceeds today's visits"))
- def generate_prompt(self, instance: dict):
- application = self.is_valid(raise_exception=True)
- GeneratePromptSerializers(data=instance).is_valid(raise_exception=True)
- workspace_id = self.data.get('workspace_id')
- model_id = self.data.get('model_id')
- prompt = instance.get('prompt')
- messages = instance.get('messages')
+ # ---------- application ----------
+ def get_application(self):
+ """debug 取 Application 本体;非 debug 取最新发布的 ApplicationVersion。"""
+ application_id = self.data.get("application_id")
+ if self.data.get("debug"):
+ application = QuerySet(Application).filter(id=application_id).first()
+ if application is None:
+ raise ChatException(500, _("The application does not exist"))
+ else:
+ application = (
+ QuerySet(ApplicationVersion).filter(application_id=application_id).order_by("-create_time")[0:1].first()
+ )
+ if application is None:
+ raise ChatException(500, _("The application has not been published. Please use it after publishing."))
+ return application
- message = messages[-1]['content']
- q = prompt.replace("{userInput}", message)
+ def ensure_chat_row(self, question, asker):
+ """Chat 行不存在则创建(debug 记为 DEBUG 类型),返回该行。复用 get_chat 的一次查询。"""
+ chat = self.get_chat()
+ if chat is not None:
+ return chat
+ chat = Chat(
+ id=self.data.get("chat_id"),
+ application_id=self.data.get("application_id"),
+ abstract=(question or "")[0:1024],
+ execute_type=ExecuteType.DEBUG if self.data.get("debug") else ExecuteType.CHAT,
+ chat_user_id=self.data.get("chat_user_id"),
+ chat_user_type=self.data.get("chat_user_type"),
+ ip_address=self.data.get("ip_address"),
+ source=self.data.get("source"),
+ asker=asker,
+ )
+ chat.save()
+ self._chat_cache = chat
+ return chat
+
+ def get_defaults_record(self, question):
+ """构造一条占位 ChatRecord 的字段(workflow 完成后由 update_chat_record 回填)。"""
+ return {
+ "chat_id": self.data.get("chat_id"),
+ "problem_text": "",
+ "answer_text": "",
+ "details": {},
+ "message_tokens": 0,
+ "answer_tokens": 0,
+ "answer_text_list": [[]],
+ "run_time": 0,
+ # index 现在用不上,字段 NOT NULL 故给常量 0
+ "index": 0,
+ "ip_address": self.data.get("ip_address") or "",
+ "source": self.data.get("source"),
+ "workflow_context": {},
+ "question": question,
+ "messages": [],
+ }
- messages[-1]['content'] = q
- SUPPORTED_MODEL_TYPES = ["LLM", "IMAGE"]
- model_exist = QuerySet(Model).filter(
- id=model_id,
- model_type__in=SUPPORTED_MODEL_TYPES
- ).exists()
- if not model_exist:
- raise Exception(_("Model does not exists or is not an LLM model"))
+ @staticmethod
+ def _usage_from_context(workflow_context):
+ """从 workflow_context 汇总 token 用量:prompt=message_tokens, completion=answer_tokens。"""
+ prompt_tokens = sum(
+ v.get("message_tokens", 0)
+ for v in workflow_context.values()
+ if isinstance(v, dict) and "message_tokens" in v
+ )
+ completion_tokens = sum(
+ v.get("answer_tokens", 0) for v in workflow_context.values() if isinstance(v, dict) and "answer_tokens" in v
+ )
+ return {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}
- def process():
- model = get_model_instance_by_model_workspace_id(model_id=model_id, workspace_id=workspace_id,
- **application.model_params_setting)
- try:
- for r in model.stream([SystemMessage(content=SYSTEM_ROLE),
- *[HumanMessage(content=m.get('content')) if m.get(
- 'role') == 'user' else AIMessage(
- content=m.get('content')) for m in messages]]):
- yield 'data: ' + json.dumps({'content': r.content}) + '\n\n'
- except Exception as e:
- yield 'data: ' + json.dumps({'error': str(e)}) + '\n\n'
+ @staticmethod
+ def update_chat_record(chat_user_id, chat_record_id, workflow_context, messages, details):
+ usage = ChatSerializers._usage_from_context(workflow_context)
+ message_tokens = usage["prompt_tokens"]
+ answer_tokens = usage["completion_tokens"]
+ ChatUserTokenQuota.consume(chat_user_id, message_tokens + answer_tokens)
+ QuerySet(ChatRecord).filter(id=chat_record_id).update(
+ workflow_context=workflow_context,
+ messages=messages,
+ message_tokens=message_tokens,
+ answer_tokens=answer_tokens,
+ details=details,
+ )
+
+ # ---------- 执行 ----------
+ def chat_work_flow(self, application, instance: dict, base_to_response):
+ message_dict = instance.get("message")
+ message = message_dict.get("content", "") if isinstance(message_dict, dict) else message_dict
+ re_chat = instance.get("re_chat")
+ stream = instance.get("stream")
+ chat_id = self.data.get("chat_id")
+ chat_user_id = self.data.get("chat_user_id")
+ chat_user_type = self.data.get("chat_user_type")
+ ip_address = self.data.get("ip_address")
+ source = self.data.get("source")
+ form_data = instance.get("form_data") or {}
+ image_list = message_dict.get("image_list", []) if isinstance(message_dict, dict) else []
+ video_list = message_dict.get("video_list", []) if isinstance(message_dict, dict) else []
+ document_list = message_dict.get("document_list", []) if isinstance(message_dict, dict) else []
+ audio_list = message_dict.get("audio_list", []) if isinstance(message_dict, dict) else []
+ other_list = message_dict.get("other_list", []) if isinstance(message_dict, dict) else []
+ workspace_id = application.workspace_id
+ chat_record_id = instance.get("chat_record_id")
+ position = instance.get("position")
+ chunk_id = instance.get("chunk_id")
+ debug = self.data.get("debug", False)
+ default_model_setting = application.default_model_setting or {}
+
+ # 对话用户信息(asker 取自 form_data)
+ chat_user = resolve_chat_user(chat_user_id, chat_user_type, asker=form_data.get("asker"))
+ chat_user_group = resolve_chat_user_group(chat_user)
+
+ history_chat_record = ChatHistory(chat_id).load(exclude_record_id=chat_record_id)
+
+ work_flow = get_work_flow(application)
+ workflow = new_instance(work_flow, WorkflowType.APPLICATION)
+
+ chat_record_id_str = str(uuid.uuid7()) if chat_record_id is None else str(chat_record_id)
+ self.ensure_chat_row(message, chat_user)
+ if chat_record_id is None:
+ ChatRecord(id=chat_record_id_str, **self.get_defaults_record(message_dict)).save(force_insert=True)
+
+ parameters = {
+ "retrieval_identity": identity_from_server(chat_user_id, chat_user_type, debug),
+ "history_chat_record": history_chat_record,
+ "question": message,
+ "chat_id": chat_id,
+ "chat_record_id": chat_record_id_str,
+ "stream": stream,
+ "re_chat": re_chat,
+ "chat_user_id": chat_user_id,
+ "chat_user_type": chat_user_type,
+ "ip_address": ip_address,
+ "source": source,
+ "workspace_id": workspace_id,
+ "debug": debug,
+ "chat_user": chat_user,
+ "chat_user_group": chat_user_group,
+ "application_id": str(self.data.get("application_id")),
+ "form_data": form_data,
+ "position": position,
+ "chunk_id": chunk_id,
+ "image_list": image_list or [],
+ "document_list": document_list or [],
+ "audio_list": audio_list or [],
+ "video_list": video_list or [],
+ "other_list": other_list or [],
+ "default_model_setting": default_model_setting,
+ }
+
+ result_queue = queue.Queue()
+ aggregation = AggregationManager()
+
+ def on_next(wf_manage, content):
+ aggregation.aggregate(content)
+ block = content.to_dict()
+ get_message_queue().produce(chat_record_id_str, block)
+ result_queue.put(("chunk", block))
+
+ def on_complete(wf_manage, error):
+ WorkflowRunRegistry.unregister(chat_record_id_str, str(chat_id))
+ message_queue = get_message_queue()
+ if error:
+ result_queue.put(("error", error))
+ message_queue.produce(
+ chat_record_id_str,
+ FailureContent(str(uuid_utils.uuid7()), str(error), Status.SUCCESS, None, None).to_dict(),
+ )
+ messages = aggregation.get_contents()
+ old_details = None
+ chat_record = None
+ if chat_record_id is not None:
+ chat_record = QuerySet(ChatRecord).filter(id=chat_record_id).first()
+ if chat_record:
+ old_details = chat_record.details
+ if position and chat_record.messages:
+ messages = list({m.get("id"): m for m in [*chat_record.messages, *messages]}.values())
+ details = wf_manage.get_details(position=position, old_details=old_details)
+ self.update_chat_record(chat_user_id, chat_record_id_str, wf_manage.context, messages, details)
+ ChatCountSerializer(data={"chat_id": chat_id}).update_chat()
+ # 表单续跑时 message_dict.content 为空;保留原记录里的用户问题,避免 WORKFLOW 历史丢问题
+ question = chat_record.question if (chat_record and chat_record.question) else message_dict
+ ChatHistory(chat_id).append(
+ ChatRecord(
+ id=chat_record_id_str,
+ chat_id=chat_id,
+ question=question,
+ messages=messages,
+ details=details,
+ create_time=timezone.now(),
+ )
+ )
+ result_queue.put(("done", None))
+ message_queue.produce_done(chat_record_id_str)
+
+ call_back = CallBack(on_next, on_complete)
+
+ def get_start_node_fn(wf, wm):
+ return get_start_node(wf, wm, WorkflowType.APPLICATION, position)
+
+ # Form 提交(有 position 和 chat_record_id):从历史 context 恢复
+ if position and chat_record_id:
+ work_flow_manage = WorkflowManage.from_context(
+ get_context=lambda: load_debug_workflow_context(chat_record_id),
+ workflow=workflow,
+ parameters=parameters,
+ workflow_type=WorkflowType.APPLICATION,
+ call_back=call_back,
+ get_start_node=get_start_node_fn,
+ )
+ if work_flow_manage is None:
+ work_flow_manage = WorkflowManage(
+ workflow, parameters, WorkflowType.APPLICATION, call_back, get_start_node_fn
+ )
+ else:
+ work_flow_manage = WorkflowManage(
+ workflow, parameters, WorkflowType.APPLICATION, call_back, get_start_node_fn
+ )
+
+ work_flow_manage.start_node.workflow_manage = work_flow_manage
+ WorkflowRunRegistry.register(chat_record_id_str, str(chat_id), work_flow_manage)
+
+ if stream:
+
+ def generate():
+ work_flow_manage.run()
+ while True:
+ msg_type, data = result_queue.get()
+ if msg_type == "done":
+ end_frame = base_to_response.to_stream_end(
+ chat_id,
+ chat_record_id_str,
+ usage=self._usage_from_context(work_flow_manage.context),
+ )
+ if end_frame is not None:
+ yield "data: " + end_frame + "\n\n"
+ yield "data: [DONE]\n\n"
+ break
+ if msg_type == "error":
+ error_block = {"id": str(uuid.uuid7()), "type": "FAILURE", "content": str(data)}
+ frame = base_to_response.to_stream(chat_id, chat_record_id_str, error_block)
+ if frame is not None:
+ yield "data: " + frame + "\n\n"
+ yield "data: [DONE]\n\n"
+ break
+ if msg_type == "chunk":
+ frame = base_to_response.to_stream(chat_id, chat_record_id_str, data)
+ if frame is not None:
+ yield "data: " + frame + "\n\n"
+
+ return to_stream_response_simple(generate())
+ else:
+ work_flow_manage.run()
+ while True:
+ msg_type, data = result_queue.get()
+ if msg_type == "done":
+ break
+ if msg_type == "error":
+ raise data
+ usage = self._usage_from_context(work_flow_manage.context)
+ return base_to_response.to_block(chat_id, chat_record_id_str, aggregation.get_contents(), usage)
- return to_stream_response_simple(process())
+ def chat(self, instance: dict, base_to_response: BaseToResponse = SystemToResponse()):
+ self.is_valid(raise_exception=True)
+ ChatMessageSerializers(data=instance).is_valid(raise_exception=True)
+ self.is_valid_chat()
+ application = self.get_application()
+ self.is_valid_intraday_access_num()
+ return self.chat_work_flow(application, instance, base_to_response)
class OpenAIMessage(serializers.Serializer):
- content = serializers.CharField(required=True, label=_('content'))
- role = serializers.CharField(required=True, label=_('Role'))
+ content = serializers.CharField(required=True, label=_("content"))
+ role = serializers.CharField(required=True, label=_("Role"))
class OpenAIInstanceSerializer(serializers.Serializer):
@@ -215,6 +447,8 @@ class OpenAIInstanceSerializer(serializers.Serializer):
class OpenAIChatSerializer(serializers.Serializer):
+ """OpenAI 兼容入口:走新 ChatSerializers + OpenaiToResponse,无 ChatInfo/缓存。"""
+
application_id = serializers.UUIDField(required=True, label=_("Application ID"))
chat_user_id = serializers.CharField(required=True, label=_("Client id"))
chat_user_type = serializers.CharField(required=True, label=_("Client Type"))
@@ -223,382 +457,316 @@ class OpenAIChatSerializer(serializers.Serializer):
@staticmethod
def get_message(instance):
- return instance.get('messages')[-1].get('content')
+ return instance.get("messages")[-1].get("content")
- @staticmethod
- def generate_chat(chat_id, application_id, message, chat_user_id, chat_user_type, ip_address, source):
- if chat_id is None:
- chat_id = str(uuid.uuid1())
- chat_info = ChatInfo(chat_id, chat_user_id, chat_user_type, ip_address, source, [], [],
- application_id)
- chat_info.set_cache()
- else:
- chat_info = ChatInfo.get_cache(chat_id)
- if chat_info is None:
- open_chat = ChatSerializers(data={
- 'chat_id': chat_id,
- 'chat_user_id': chat_user_id,
- 'chat_user_type': chat_user_type,
- 'application_id': application_id,
- 'ip_address': ip_address,
- 'source': source,
- })
- open_chat.is_valid(raise_exception=True)
- chat_info = open_chat.re_open_chat(chat_id)
- chat_info.set_cache()
- return chat_id
-
- def chat(self, instance: Dict, with_valid=True):
+ def chat(self, instance: dict, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
OpenAIInstanceSerializer(data=instance).is_valid(raise_exception=True)
- chat_id = instance.get('chat_id')
+ # 会话不存在则新开:新 ChatSerializers 会按 chat_id 惰性建 Chat 行,无需缓存
+ chat_id = instance.get("chat_id") or str(uuid.uuid7())
message = self.get_message(instance)
- re_chat = instance.get('re_chat', False)
- stream = instance.get('stream', False)
- application_id = self.data.get('application_id')
- chat_user_id = self.data.get('chat_user_id')
- chat_user_type = self.data.get('chat_user_type')
- ip_address = self.data.get('ip_address')
- source = self.data.get('source')
- chat_id = self.generate_chat(chat_id, application_id, message, chat_user_id, chat_user_type, ip_address, source)
return ChatSerializers(
data={
- 'chat_id': chat_id,
- 'chat_user_id': chat_user_id,
- 'chat_user_type': chat_user_type,
- 'application_id': application_id,
- 'ip_address': ip_address,
- 'source': source,
+ "chat_id": chat_id,
+ "chat_user_id": self.data.get("chat_user_id"),
+ "chat_user_type": self.data.get("chat_user_type"),
+ "application_id": self.data.get("application_id"),
+ "ip_address": self.data.get("ip_address"),
+ "source": self.data.get("source"),
}
- ).chat({'message': message,
- 're_chat': re_chat,
- 'stream': stream,
- 'form_data': instance.get('form_data', {}),
- 'image_list': instance.get('image_list', []),
- 'document_list': instance.get('document_list', []),
- 'audio_list': instance.get('audio_list', []),
- 'other_list': instance.get('other_list', [])},
- base_to_response=OpenaiToResponse())
+ ).chat(
+ {
+ "message": {
+ "content": message,
+ "image_list": instance.get("image_list", []),
+ "document_list": instance.get("document_list", []),
+ "audio_list": instance.get("audio_list", []),
+ "video_list": instance.get("video_list", []),
+ "other_list": instance.get("other_list", []),
+ },
+ "re_chat": instance.get("re_chat", False),
+ "stream": instance.get("stream", False),
+ "form_data": instance.get("form_data", {}),
+ },
+ base_to_response=OpenaiToResponse(),
+ )
+
+
+# ==================== 会话创建 ====================
-class ChatSerializers(serializers.Serializer):
- chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
+class OpenChatSerializers(serializers.Serializer):
+ workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
+ application_id = serializers.UUIDField(required=True)
chat_user_id = serializers.CharField(required=True, label=_("Client id"))
chat_user_type = serializers.CharField(required=True, label=_("Client Type"))
- application_id = serializers.UUIDField(required=True, allow_null=True,
- label=_("Application ID"))
- debug = serializers.BooleanField(required=False, label=_("Debug"))
- ip_address = serializers.CharField(required=False, label=_("IP Address"), allow_null=True, allow_blank=True)
+ debug = serializers.BooleanField(required=True, label=_("Debug"))
+ ip_address = serializers.CharField(required=False, label=_("IP Address"))
source = serializers.JSONField(required=False, label=_("Source"))
- def is_valid_application_workflow(self, *, raise_exception=False):
- self.is_valid_intraday_access_num()
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ workspace_id = self.data.get("workspace_id")
+ application_id = self.data.get("application_id")
+ query_set = QuerySet(Application).filter(id=application_id)
+ if workspace_id:
+ query_set = query_set.filter(workspace_id=workspace_id)
+ if not query_set.exists():
+ raise AppApiException(500, _("Application does not exist"))
- def is_valid_chat_id(self, chat_info: ChatInfo):
- if self.data.get('application_id') is not None and self.data.get('application_id') != str(
- chat_info.application_id):
- raise ChatException(500, _("Conversation does not exist"))
+ def open(self, chat_id=None):
+ """新建会话:直接建 Chat 行(cache-free,无 ChatInfo)。SIMPLE/WORK_FLOW 一视同仁。"""
+ self.is_valid(raise_exception=True)
+ application_id = self.data.get("application_id")
+ debug = self.data.get("debug")
+ if not debug:
+ published = (
+ QuerySet(ApplicationVersion).filter(application_id=application_id).order_by("-create_time")[0:1].first()
+ )
+ if published is None:
+ raise AppApiException(500, _("The application has not been published. Please use it after publishing."))
+ chat_id = chat_id or str(uuid.uuid7())
+ Chat(
+ id=chat_id,
+ application_id=application_id,
+ abstract="新建对话",
+ execute_type=ExecuteType.DEBUG if debug else ExecuteType.CHAT,
+ chat_user_id=self.data.get("chat_user_id"),
+ chat_user_type=self.data.get("chat_user_type"),
+ ip_address=self.data.get("ip_address"),
+ source=self.data.get("source"),
+ asker=resolve_chat_user(self.data.get("chat_user_id"), self.data.get("chat_user_type")),
+ ).save()
+ return chat_id
- def is_valid_intraday_access_num(self):
- if not self.data.get('debug') and [ChatUserType.ANONYMOUS_USER.value,
- ChatUserType.CHAT_USER.value].__contains__(
- self.data.get('chat_user_type')):
- access_client = QuerySet(ApplicationChatUserStats).filter(chat_user_id=self.data.get('chat_user_id'),
- application_id=self.data.get(
- 'application_id')).first()
- if access_client is None:
- access_client = ApplicationChatUserStats(chat_user_id=self.data.get('chat_user_id'),
- chat_user_type=self.data.get('chat_user_type'),
- application_id=self.data.get('application_id'),
- access_num=0,
- intraday_access_num=0)
- access_client.save()
- application_access_token = QuerySet(ApplicationAccessToken).filter(
- application_id=self.data.get('application_id')).first()
- if application_access_token.access_num <= access_client.intraday_access_num:
- raise AppChatNumOutOfBoundsFailed(1002, _("The number of visits exceeds today's visits"))
+# ==================== 断点续传 ====================
- def is_valid_application_simple(self, *, chat_info: ChatInfo, raise_exception=False):
- self.is_valid_intraday_access_num()
- model_id = chat_info.application.model_id
- if model_id is None:
- return chat_info
- model = QuerySet(Model).filter(id=model_id).first()
- if model is None:
- return chat_info
- if model.status == Status.ERROR:
- raise ChatException(500, _("The current model is not available"))
- if model.status == Status.DOWNLOAD:
- raise ChatException(500, _("The model is downloading, please try again later"))
- return chat_info
-
- def chat_simple(self, chat_info: ChatInfo, instance, base_to_response):
- message = instance.get('message')
- re_chat = instance.get('re_chat')
- stream = instance.get('stream')
- chat_user_id = self.data.get('chat_user_id')
- chat_user_type = self.data.get('chat_user_type')
- ip_address = self.data.get('ip_address')
- source = self.data.get('source')
- form_data = instance.get("form_data")
- chat_record_id = instance.get('chat_record_id')
- pipeline_manage_builder = PipelineManage.builder()
- # 如果开启了问题优化,则添加上问题优化步骤
- if chat_info.application.problem_optimization:
- pipeline_manage_builder.append_step(BaseResetProblemStep)
- # 构建流水线管理器
- pipeline_message = (pipeline_manage_builder.append_step(BaseSearchDatasetStep)
- .append_step(BaseGenerateHumanMessageStep)
- .append_step(BaseChatStep)
- .add_base_to_response(base_to_response)
- .add_debug(self.data.get('debug', False))
- .build())
- exclude_paragraph_id_list = []
- # 相同问题是否需要排除已经查询到的段落
- if re_chat:
- paragraph_id_list = flat_map(
- [[paragraph.get('id') for paragraph in chat_record.details['search_step']['paragraph_list']] for
- chat_record in chat_info.chat_record_list if
- chat_record.problem_text == message and 'search_step' in chat_record.details and 'paragraph_list' in
- chat_record.details['search_step']])
- exclude_paragraph_id_list = list(set(paragraph_id_list))
- # 构建运行参数
- params = chat_info.to_pipeline_manage_params(message, get_post_handler(chat_info), exclude_paragraph_id_list,
- chat_user_id, chat_user_type, ip_address, source, stream,
- form_data)
- if chat_record_id:
- params['chat_record_id'] = chat_record_id
- chat_info.set_chat(message)
- # 运行流水线作业
- pipeline_message.run(params)
- return pipeline_message.context['chat_result']
+# consume 桥接队列的上限:满了会反压 pump 线程,防止慢客户端把消息全堆进内存
+_BRIDGE_MAXSIZE = 1000
+# 消费上限(秒),与桥接 get 的超时保持一致的量级
+_CONSUME_TIMEOUT = 300
- @staticmethod
- def get_chat_record(chat_info, chat_record_id):
- if chat_info is not None:
- chat_record_list = [chat_record for chat_record in chat_info.chat_record_list if
- str(chat_record.id) == str(chat_record_id)]
- if chat_record_list is not None and len(chat_record_list):
- return chat_record_list[-1]
- chat_record = QuerySet(ChatRecord).filter(id=chat_record_id, chat_id=chat_info.chat_id).first()
- if chat_record is None:
- raise ChatException(500, _("Conversation record does not exist"))
-
- return chat_record
- chat_record = QuerySet(ChatRecord).filter(id=chat_record_id).first()
- return chat_record
-
- def chat_work_flow(self, chat_info: ChatInfo, instance: dict, base_to_response):
- message = instance.get('message')
- re_chat = instance.get('re_chat')
- stream = instance.get('stream')
- chat_user_id = self.data.get("chat_user_id")
- chat_user_type = self.data.get('chat_user_type')
- ip_address = self.data.get('ip_address')
- source = self.data.get('source')
- form_data = instance.get('form_data')
- image_list = instance.get('image_list')
- video_list = instance.get('video_list')
- document_list = instance.get('document_list')
- audio_list = instance.get('audio_list')
- other_list = instance.get('other_list')
- workspace_id = chat_info.application.workspace_id
- chat_record_id = instance.get('chat_record_id')
- debug = self.data.get('debug', False)
- chat_record = None
- history_chat_record = chat_info.chat_record_list
- if chat_record_id is not None:
- chat_record = self.get_chat_record(chat_info, chat_record_id)
- if chat_record:
- history_chat_record = [r for r in chat_info.chat_record_list if str(r.id) != chat_record_id]
- work_flow = chat_info.application.work_flow
- work_flow_manage = WorkflowManage(Workflow.new_instance(work_flow),
- {'history_chat_record': history_chat_record, 'question': message,
- 'chat_id': chat_info.chat_id, 'chat_record_id': str(
- uuid.uuid7()) if chat_record_id is None else str(chat_record_id),
- 'stream': stream,
- 're_chat': re_chat,
- 'chat_user_id': chat_user_id,
- 'chat_user_type': chat_user_type,
- 'ip_address': ip_address,
- 'source': source,
- 'workspace_id': workspace_id,
- 'debug': debug,
- 'chat_user': chat_info.get_chat_user(),
- 'chat_user_group': chat_info.get_chat_user_group(),
- 'application_id': str(chat_info.application_id)},
- WorkFlowPostHandler(chat_info),
- base_to_response, form_data, image_list, document_list, audio_list,
- video_list,
- other_list,
- instance.get('runtime_node_id'),
- instance.get('node_data'), chat_record, instance.get('child_node'))
- chat_info.set_chat(message)
- r = work_flow_manage.run()
- return r
-
- def is_valid_chat_user(self):
- chat_user_id = self.data.get('chat_user_id')
- application_id = self.data.get('application_id')
- chat_user_type = self.data.get('chat_user_type')
- is_auth_chat_user = DatabaseModelManage.get_model("is_auth_chat_user")
- application_access_token = QuerySet(ApplicationAccessToken).filter(application_id=application_id).first()
- if application_access_token and application_access_token.authentication and application_access_token.authentication_value.get(
- 'type') == 'login':
- if chat_user_type == ChatUserType.ANONYMOUS_USER.value:
- raise ChatException(500, _("The chat user is not authorized."))
- if chat_user_type == ChatUserType.CHAT_USER.value and is_auth_chat_user:
- is_auth = is_auth_chat_user(chat_user_id, application_id)
- if not is_auth:
- raise ChatException(500, _("The chat user is not authorized."))
- def chat(self, instance: dict, base_to_response: BaseToResponse = SystemToResponse()):
- super().is_valid(raise_exception=True)
- ChatMessageSerializers(data=instance).is_valid(raise_exception=True)
- chat_info = self.get_chat_info()
- chat_info.get_application()
- chat_info.get_chat_user(asker=(instance.get('form_data') or {}).get('asker'))
- self.is_valid_chat_id(chat_info)
- if not self.data.get('debug'):
- self.is_valid_chat_user()
- if chat_info.application.type == ApplicationTypeChoices.SIMPLE:
- self.is_valid_application_simple(raise_exception=True, chat_info=chat_info)
- return self.chat_simple(chat_info, instance, base_to_response)
- else:
- self.is_valid_application_workflow(raise_exception=True)
- return self.chat_work_flow(chat_info, instance, base_to_response)
+class ResumeSerializers(serializers.Serializer):
+ chat_id = serializers.UUIDField(required=True)
+ chat_record_id = serializers.UUIDField(required=True)
- def get_chat_info(self):
+ def resume(self, request):
self.is_valid(raise_exception=True)
- chat_id = self.data.get('chat_id')
- chat_info: ChatInfo = ChatInfo.get_cache(chat_id)
- if chat_info is None:
- chat_info: ChatInfo = self.re_open_chat(chat_id)
- chat_info.set_cache()
- return chat_info
-
- def re_open_chat(self, chat_id: str):
- chat = QuerySet(Chat).filter(id=chat_id).first()
- if chat is None:
- raise ChatException(500, _("Conversation does not exist"))
- application = QuerySet(Application).filter(id=chat.application_id).first()
- if application is None:
- raise ChatException(500, _("Application does not exist"))
- application_version = QuerySet(ApplicationVersion).filter(application_id=application.id).order_by(
- '-create_time')[0:1].first()
- if application_version is None:
- raise ChatException(500, _("The application has not been published. Please use it after publishing."))
- if application.type == ApplicationTypeChoices.SIMPLE:
- return self.re_open_chat_simple(chat_id, application)
+ chat_record_id = self.data.get("chat_record_id")
+ mq = get_message_queue()
+
+ start_id = self._resolve_start_id(request)
+
+ is_running = mq.exists(chat_record_id) and not mq.is_done(chat_record_id)
+
+ if is_running:
+ generator = self._stream_from_queue(mq, chat_record_id, start_id)
else:
- return self.re_open_chat_work_flow(chat_id, application)
-
- def re_open_chat_simple(self, chat_id, application):
- # 数据集id列表
- knowledge_id_list = [str(row.target_id) for row in
- QuerySet(ResourceMapping).filter(source_id=str(application.id),
- source_type='APPLICATION',
- target_type='KNOWLEDGE')]
-
- # 需要排除的文档
- exclude_document_id_list = [str(document.id) for document in
- QuerySet(Document).filter(
- knowledge_id__in=knowledge_id_list,
- is_active=False)]
- chat_info = ChatInfo(chat_id, self.data.get('chat_user_id'), self.data.get('chat_user_type'),
- self.data.get('ip_address'),
- self.data.get('source'), knowledge_id_list,
- exclude_document_id_list, application.id)
- chat_record_list = list(QuerySet(ChatRecord).filter(chat_id=chat_id).order_by('-create_time')[0:5])
- chat_record_list.sort(key=lambda r: r.create_time)
- for chat_record in chat_record_list:
- chat_info.chat_record_list.append(chat_record)
- return chat_info
-
- def re_open_chat_work_flow(self, chat_id, application):
- chat_info = ChatInfo(chat_id, self.data.get('chat_user_id'), self.data.get('chat_user_type'),
- self.data.get('ip_address'),
- self.data.get('source'), [], [],
- application.id)
- chat_record_list = list(QuerySet(ChatRecord).filter(chat_id=chat_id).order_by('-create_time')[0:5])
- chat_record_list.sort(key=lambda r: r.create_time)
- for chat_record in chat_record_list:
- chat_info.chat_record_list.append(chat_record)
- return chat_info
+ chat_record = ChatRecord.objects.filter(id=chat_record_id).first()
+ if not chat_record:
+ return result.error(_("Chat record not found"))
+ generator = self._stream_from_db(chat_record, start_id)
+
+ response = StreamingHttpResponse(
+ generator,
+ content_type="text/event-stream;charset=utf-8",
+ )
+ response["Cache-Control"] = "no-cache"
+ response["X-Accel-Buffering"] = "no"
+ return response
+ @staticmethod
+ def _resolve_start_id(request: Request) -> str:
+ """
+ 优先取 SSE 标准的 Last-Event-ID 头(浏览器 EventSource 断线重连会自动带上),
+ 兼容 body / query 里显式传的 last_event_id。取不到则从头开始。
+ """
+ candidate = (
+ request.META.get("HTTP_LAST_EVENT_ID")
+ or (request.data.get("last_event_id") if hasattr(request, "data") else None)
+ or request.query_params.get("last_event_id")
+ )
+ candidate = (candidate or "").strip()
+ return candidate or "0"
-class OpenChatSerializers(serializers.Serializer):
- workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
- application_id = serializers.UUIDField(required=True)
- chat_user_id = serializers.CharField(required=True, label=_("Client id"))
- chat_user_type = serializers.CharField(required=True, label=_("Client Type"))
- debug = serializers.BooleanField(required=True, label=_("Debug"))
- ip_address = serializers.CharField(required=False, label=_("IP Address"))
- source = serializers.JSONField(required=False, label=_("Source"))
+ @staticmethod
+ def _sse(msg_id: str, msg_data: str) -> str:
+ """
+ 带 id 字段的 SSE 帧:浏览器会把最后收到的 id 存进 Last-Event-ID,
+ 下次重连自动回传,从而实现断点续传。
+ """
+ return f"id: {msg_id}\ndata: {msg_data}\n\n"
+
+ def _stream_from_queue(self, mq, chat_record_id: str, start_id: str):
+ """
+ 用后台线程跑阻塞式 consume,把回调桥接成 generator。
+ 复用 consume 已经处理好的 done 标记 / 尾部残留竞态,视图层不再重写收尾。
+ """
+ bridge: thread_queue.Queue = thread_queue.Queue(maxsize=_BRIDGE_MAXSIZE)
+ done_sentinel = object()
+ stop_event = threading.Event()
+
+ def pump():
+ try:
+ mq.consume(
+ queue_id=chat_record_id,
+ start_id=start_id,
+ # bridge.put 无 timeout:队列满时在此反压,等 generator 消费腾位
+ on_message=lambda mid, data: bridge.put((mid, data)),
+ on_done=lambda: bridge.put(done_sentinel), # 契约保证有且仅一次
+ timeout=_CONSUME_TIMEOUT,
+ should_stop=stop_event.is_set, # 客户端断开时提前结束,省掉空转
+ )
+ except Exception as e:
+ maxkb_logger.error(f"ResumeStream pump error [{chat_record_id}]: {e}")
+ # 兜底:即使 consume 内部异常也要放哨兵,避免 generator 永久阻塞
+ try:
+ bridge.put_nowait(done_sentinel)
+ except thread_queue.Full:
+ pass
+
+ worker = threading.Thread(target=pump, name=f"resume-{chat_record_id}", daemon=True)
+ worker.start()
+
+ try:
+ while True:
+ try:
+ # 略大于 consume timeout:正常情况下哨兵会先到,这里只防线程异常挂死
+ item = bridge.get(timeout=_CONSUME_TIMEOUT + 5)
+ except thread_queue.Empty:
+ maxkb_logger.warning(f"ResumeStream bridge idle timeout [{chat_record_id}]")
+ break
+ if item is done_sentinel:
+ break
+ msg_id, msg_data = item
+ yield self._sse(msg_id, msg_data)
+ yield "data: [DONE]\n\n"
+ finally:
+ # 客户端提前关闭连接会在 yield 处抛 GeneratorExit,落到这里;
+ # 通知 consume 线程停止,不必再等到 300s 超时
+ stop_event.set()
+
+ def _stream_from_db(self, chat_record, start_id: str):
+ """
+ 已完成 / 不存在于队列:从库里读。
+ 若带了 Last-Event-ID,则跳过已发送过的部分(按落库时的消息 id 对齐)。
+ """
+ try:
+ messages = chat_record.messages or []
+ resuming = start_id and start_id != "0"
+ passed = not resuming # 无续传点则全部下发
+
+ for msg in messages:
+ msg_id = str(msg.get("id", "")) if isinstance(msg, dict) else ""
+
+ if not passed:
+ # 尚未越过续传点:命中该 id 后,从下一条开始发
+ if msg_id and msg_id == start_id:
+ passed = True
+ continue
+
+ yield self._sse(msg_id, json.dumps(msg, ensure_ascii=False))
+
+ # 续传点在库里没匹配到(比如 id 体系不一致):退化为整段重放,别让客户端收到空流
+ if not passed:
+ for msg in messages:
+ msg_id = str(msg.get("id", "")) if isinstance(msg, dict) else ""
+ yield self._sse(msg_id, json.dumps(msg, ensure_ascii=False))
+ finally:
+ yield "data: [DONE]\n\n"
+
+
+# ==================== 提示词生成 ====================
+
+SYSTEM_ROLE = get_file_content(os.path.join(PROJECT_DIR, "apps", "chat", "template", "generate_prompt_system"))
+
+
+class ChatMessagesSerializers(serializers.Serializer):
+ role = serializers.CharField(required=True, label=_("Role"))
+ content = serializers.CharField(required=True, label=_("Content"))
+
+
+class GeneratePromptSerializers(serializers.Serializer):
+ prompt = serializers.CharField(required=True, label=_("Prompt template"))
+ messages = serializers.ListSerializer(child=ChatMessagesSerializers(), required=True, label=_("Chat context"))
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
- workspace_id = self.data.get('workspace_id')
- application_id = self.data.get('application_id')
- query_set = QuerySet(Application).filter(id=application_id)
+ messages = self.data.get("messages")
+
+ if len(messages) > 30:
+ raise AppApiException(400, _("Too many messages"))
+
+ for index in range(len(messages)):
+ role = messages[index].get("role")
+ if role == "ai" and index % 2 != 1:
+ raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct."))
+ if role == "user" and index % 2 != 0:
+ raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct."))
+ if role not in ["user", "ai"]:
+ raise AppApiException(400, _("Authentication failed. Please verify that the parameters are correct."))
+
+
+class PromptGenerateSerializer(serializers.Serializer):
+ workspace_id = serializers.CharField(required=False, label=_("Workspace ID"))
+ model_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Model"))
+ application_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, label=_("Application"))
+
+ def is_valid(self, *, raise_exception=False):
+ super().is_valid(raise_exception=True)
+ workspace_id = self.data.get("workspace_id")
+ query_set = QuerySet(Application).filter(id=self.data.get("application_id"))
if workspace_id:
query_set = query_set.filter(workspace_id=workspace_id)
- if not query_set.exists():
- raise AppApiException(500, gettext('Application does not exist'))
+ application = query_set.first()
+ if application is None:
+ raise AppApiException(500, _("Application id does not exist"))
+ return application
- def open(self):
- self.is_valid(raise_exception=True)
- application_id = self.data.get('application_id')
- application = QuerySet(Application).get(id=application_id)
- debug = self.data.get("debug")
- if not debug:
- application_version = QuerySet(ApplicationVersion).filter(application_id=application_id).order_by(
- '-create_time')[0:1].first()
- if application_version is None:
- raise AppApiException(500,
- _("The application has not been published. Please use it after publishing."))
- if application.type == ApplicationTypeChoices.SIMPLE:
- return self.open_simple(application)
- else:
- return self.open_work_flow(application)
+ def generate_prompt(self, instance: dict):
+ application = self.is_valid(raise_exception=True)
+ GeneratePromptSerializers(data=instance).is_valid(raise_exception=True)
+ workspace_id = self.data.get("workspace_id")
+ model_id = self.data.get("model_id")
+ prompt = instance.get("prompt")
+ messages = instance.get("messages")
- def open_work_flow(self, application):
- self.is_valid(raise_exception=True)
- application_id = self.data.get('application_id')
- chat_user_id = self.data.get("chat_user_id")
- chat_user_type = self.data.get("chat_user_type")
- ip_address = self.data.get("ip_address")
- source = self.data.get("source")
- debug = self.data.get("debug")
- chat_id = str(uuid.uuid7())
- ChatInfo(chat_id, chat_user_id, chat_user_type, ip_address, source, [],
- [],
- application_id, debug).set_cache()
- return chat_id
+ message = messages[-1]["content"]
+ q = prompt.replace("{userInput}", message)
- def open_simple(self, application):
- application_id = self.data.get('application_id')
- chat_user_id = self.data.get("chat_user_id")
- chat_user_type = self.data.get("chat_user_type")
- ip_address = self.data.get("ip_address")
- source = self.data.get("source")
- debug = self.data.get("debug")
- knowledge_id_list = [str(row.target_id) for row in
- QuerySet(ResourceMapping).filter(source_id=str(application_id),
- source_type='APPLICATION',
- target_type='KNOWLEDGE')]
-
- chat_id = str(uuid.uuid7())
- ChatInfo(chat_id, chat_user_id, chat_user_type, ip_address, source, knowledge_id_list,
- [str(document.id) for document in
- QuerySet(Document).filter(
- knowledge_id__in=knowledge_id_list,
- is_active=False)],
- application_id,
- debug=debug).set_cache()
- return chat_id
+ messages[-1]["content"] = q
+ SUPPORTED_MODEL_TYPES = ["LLM", "IMAGE"]
+ model_exist = QuerySet(Model).filter(id=model_id, model_type__in=SUPPORTED_MODEL_TYPES).exists()
+ if not model_exist:
+ raise Exception(_("Model does not exists or is not an LLM model"))
+
+ def process():
+ model = get_model_instance_by_model_workspace_id(
+ model_id=model_id, workspace_id=workspace_id, **application.model_params_setting
+ )
+ try:
+ for r in model.stream(
+ [
+ SystemMessage(content=SYSTEM_ROLE),
+ *[
+ HumanMessage(content=m.get("content"))
+ if m.get("role") == "user"
+ else AIMessage(content=m.get("content"))
+ for m in messages
+ ],
+ ]
+ ):
+ yield "data: " + json.dumps({"content": r.content}) + "\n\n"
+ except Exception as e:
+ yield "data: " + json.dumps({"error": str(e)}) + "\n\n"
+
+ return to_stream_response_simple(process())
+
+
+# ==================== 语音 ====================
class TextToSpeechSerializers(serializers.Serializer):
@@ -606,11 +774,11 @@ class TextToSpeechSerializers(serializers.Serializer):
def text_to_speech(self, instance):
self.is_valid(raise_exception=True)
- application_id = self.data.get('application_id')
+ application_id = self.data.get("application_id")
application = QuerySet(Application).filter(id=application_id).first()
return ApplicationOperateSerializer(
- data={'application_id': application_id,
- 'user_id': application.user_id}).text_to_speech(instance, False)
+ data={"application_id": application_id, "user_id": application.user_id}
+ ).text_to_speech(instance, False)
class SpeechToTextSerializers(serializers.Serializer):
@@ -618,8 +786,8 @@ class SpeechToTextSerializers(serializers.Serializer):
def speech_to_text(self, instance):
self.is_valid(raise_exception=True)
- application_id = self.data.get('application_id')
+ application_id = self.data.get("application_id")
application = QuerySet(Application).filter(id=application_id).first()
return ApplicationOperateSerializer(
- data={'application_id': application_id,
- 'user_id': application.user_id}).speech_to_text(instance, False)
+ data={"application_id": application_id, "user_id": application.user_id}
+ ).speech_to_text(instance, False)
diff --git a/apps/chat/serializers/chat_authentication.py b/apps/chat/serializers/chat_authentication.py
index b6c801b4a9c..7db073c478f 100644
--- a/apps/chat/serializers/chat_authentication.py
+++ b/apps/chat/serializers/chat_authentication.py
@@ -1,11 +1,12 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: ChatAuthentication.py
- @date:2025/6/6 13:48
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: ChatAuthentication.py
+@date:2025/6/6 13:48
+@desc:
"""
+
import uuid_utils.compat as uuid
from django.core import signing
from django.core.cache import cache
@@ -13,80 +14,119 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
-from application.models import ApplicationAccessToken, ChatUserType, Application, ApplicationVersion
-from application.serializers.application import ApplicationSerializerModel
-from common.auth.common import ChatUserToken, ChatAuthentication
+from application.models import ApplicationAccessToken, Application, ApplicationVersion
+from common.auth.common import ChatToken
+from common.auth.constants.operate_constants import Operate
from common.constants.authentication_type import AuthenticationType
from common.constants.cache_version import Cache_Version
from common.database_model_manage.database_model_manage import DatabaseModelManage
-from common.exception.app_exception import NotFound404, AppUnauthorizedFailed
+from common.exception.app_exception import NotFound404, AppUnauthorizedFailed, AppApiException
from common.utils.rsa_util import get_key_pair_by_sql
class AnonymousAuthenticationSerializer(serializers.Serializer):
+ """v3 匿名认证:application_id 为可选 query 参数。
+ 传入时颁发应用级令牌,未传入时颁发全局令牌。"""
+
+ application_id = serializers.UUIDField(required=False, label=_("application_id"))
+
+ def auth(self, request):
+ token = request.META.get("HTTP_AUTHORIZATION")
+ token_details = {}
+ try:
+ # 校验token
+ if token is not None:
+ token_details = signing.loads(token[7:])
+ except Exception:
+ pass
+ chat_user_id = token_details.get("id") or str(uuid.uuid7())
+ _type = AuthenticationType.CHAT_USER
+
+ application_id = self.validated_data.get("application_id")
+ if application_id:
+ application_access_token = QuerySet(ApplicationAccessToken).filter(application_id=application_id).first()
+ if application_access_token is None or not application_access_token.is_active:
+ raise AppApiException(500, _("Invalid application_id"))
+ application_id = str(application_id)
+ return ChatToken(
+ chat_user_id, _type, str(Operate.ANNOTATION_AUTH), application_id=application_id
+ ).to_token()
+ return (ChatToken(chat_user_id, _type, str(Operate.ANNOTATION_AUTH)).to_token(),)
+
+
+class AnonymousAuthenticationV2Serializer(serializers.Serializer):
+ """v2 匿名认证:application_id 不在 path,从 access_token 解出并写进 token,
+ 供 ChatUserToken handler 收窄到该应用。"""
+
access_token = serializers.CharField(required=True, label=_("access_token"))
def auth(self, request, with_valid=True):
- token = request.META.get('HTTP_AUTHORIZATION')
+ token = request.META.get("HTTP_AUTHORIZATION")
token_details = {}
try:
# 校验token
if token is not None:
token_details = signing.loads(token[7:])
- except Exception as e:
+ except Exception:
pass
if with_valid:
self.is_valid(raise_exception=True)
access_token = self.data.get("access_token")
application_access_token = QuerySet(ApplicationAccessToken).filter(access_token=access_token).first()
- if application_access_token is not None and application_access_token.is_active:
- chat_user_id = token_details.get('chat_user_id') or str(uuid.uuid7())
- _type = AuthenticationType.CHAT_ANONYMOUS_USER
- return ChatUserToken(application_access_token.application_id, None, access_token, _type,
- ChatUserType.ANONYMOUS_USER,
- chat_user_id, ChatAuthentication(None)).to_token()
- else:
+ if application_access_token is None or not application_access_token.is_active:
raise NotFound404(404, _("Invalid access_token"))
+ chat_user_id = token_details.get("user_id") or token_details.get("id") or str(uuid.uuid7())
+ _type = AuthenticationType.CHAT_USER
+ application_id = str(application_access_token.application_id)
+ return ChatToken(
+ chat_user_id, _type, str(Operate.ANNOTATION_AUTH), application_id=application_id
+ ).to_token(), FileToken(chat_user_id, _type, application_id=application_id).to_token()
class AuthProfileSerializer(serializers.Serializer):
+ """v3: 直接通过 application_id 获取认证 profile"""
+
+ application_id = serializers.UUIDField(required=True, label=_("application_id"))
+
+ def profile(self):
+ self.is_valid(raise_exception=True)
+ application_id = self.validated_data.get("application_id")
+ application_access_token = QuerySet(ApplicationAccessToken).filter(application_id=application_id).first()
+ if application_access_token is None:
+ raise NotFound404(404, _("Invalid application_id"))
+ if not application_access_token.is_active:
+ raise NotFound404(404, _("Invalid application_id"))
+ login_value = application_access_token.authentication_value.get("login_value", [])
+ chat_platform = DatabaseModelManage.get_model("chat_platform")
+ if chat_platform is not None:
+ types = QuerySet(chat_platform).filter(is_active=True, is_valid=True).values_list("auth_type", flat=True)
+ login_value = list(set(login_value) & set(types))
+ if "LOCAL" in application_access_token.authentication_value.get("login_value", []):
+ login_value.insert(0, "LOCAL")
+ return {
+ "application_name": application_access_token.application.name,
+ "authentication": application_access_token.authentication,
+ "authentication_type": application_access_token.authentication_value.get("type", "password"),
+ "max_attempts": application_access_token.authentication_value.get("max_attempts", 1),
+ "login_value": login_value,
+ "rsaKey": get_key_pair_by_sql().get("key"),
+ }
+
+
+class AuthProfileV2Serializer(serializers.Serializer):
+ """v2: 通过 access_token 查表得到 application_id,委托给 AuthProfileSerializer"""
+
access_token = serializers.CharField(required=True, label=_("access_token"))
def profile(self):
self.is_valid(raise_exception=True)
- access_token = self.data.get("access_token")
+ access_token = self.validated_data.get("access_token")
application_access_token = QuerySet(ApplicationAccessToken).filter(access_token=access_token).first()
if application_access_token is None:
raise NotFound404(404, _("Invalid access_token"))
if not application_access_token.is_active:
raise NotFound404(404, _("Invalid access_token"))
- application_id = application_access_token.application_id
- profile = {
- 'authentication': False
- }
- application_setting_model = DatabaseModelManage.get_model('application_setting')
- chat_platform = DatabaseModelManage.get_model('chat_platform')
- if application_setting_model and chat_platform:
- application_setting = QuerySet(application_setting_model).filter(application_id=application_id).first()
- types = QuerySet(chat_platform).filter(is_active=True, is_valid=True).values_list('auth_type', flat=True)
- login_value = application_access_token.authentication_value.get('login_value', [])
- max_attempts = application_access_token.authentication_value.get('max_attempts', 1)
- final_login_value = list(set(login_value) & set(types))
- if 'LOCAL' in login_value:
- final_login_value.insert(0, 'LOCAL')
- if application_setting is not None:
- profile = {
- 'icon': application_setting.application.icon,
- 'application_name': application_setting.application.name,
- 'bg_icon': application_setting.chat_background,
- 'authentication': application_access_token.authentication,
- 'authentication_type': application_access_token.authentication_value.get(
- 'type', 'password'),
- 'max_attempts': max_attempts,
- 'login_value': final_login_value,
- 'rsaKey' : get_key_pair_by_sql().get('key')
- }
- return profile
+ return AuthProfileSerializer(data={"application_id": application_access_token.application_id}).profile()
class ApplicationProfileSerializer(serializers.Serializer):
@@ -95,18 +135,30 @@ class ApplicationProfileSerializer(serializers.Serializer):
@staticmethod
def reset_application(application, application_version):
update_field_dict = {
- 'application_name': 'name', 'desc': 'desc', 'prologue': 'prologue', 'dialogue_number': 'dialogue_number',
- 'user_id': 'user_id', 'model_id': 'model_id', 'knowledge_setting': 'knowledge_setting',
- 'model_setting': 'model_setting', 'model_params_setting': 'model_params_setting',
- 'tts_model_params_setting': 'tts_model_params_setting',
- 'problem_optimization': 'problem_optimization', 'work_flow': 'work_flow',
- 'problem_optimization_prompt': 'problem_optimization_prompt', 'tts_model_id': 'tts_model_id',
- 'stt_model_id': 'stt_model_id', 'tts_model_enable': 'tts_model_enable',
- 'stt_model_enable': 'stt_model_enable', 'tts_type': 'tts_type',
- 'tts_autoplay': 'tts_autoplay', 'stt_autosend': 'stt_autosend', 'file_upload_enable': 'file_upload_enable',
- 'file_upload_setting': 'file_upload_setting'
+ "application_name": "name",
+ "desc": "desc",
+ "prologue": "prologue",
+ "dialogue_number": "dialogue_number",
+ "user_id": "user_id",
+ "model_id": "model_id",
+ "knowledge_setting": "knowledge_setting",
+ "model_setting": "model_setting",
+ "model_params_setting": "model_params_setting",
+ "tts_model_params_setting": "tts_model_params_setting",
+ "problem_optimization": "problem_optimization",
+ "work_flow": "work_flow",
+ "problem_optimization_prompt": "problem_optimization_prompt",
+ "tts_model_id": "tts_model_id",
+ "stt_model_id": "stt_model_id",
+ "tts_model_enable": "tts_model_enable",
+ "stt_model_enable": "stt_model_enable",
+ "tts_type": "tts_type",
+ "tts_autoplay": "tts_autoplay",
+ "stt_autosend": "stt_autosend",
+ "file_upload_enable": "file_upload_enable",
+ "file_upload_setting": "file_upload_setting",
}
- for (version_field, app_field) in update_field_dict.items():
+ for version_field, app_field in update_field_dict.items():
_v = getattr(application_version, version_field)
setattr(application, app_field, _v)
@@ -118,60 +170,74 @@ def profile(self, with_valid=True):
application_access_token = QuerySet(ApplicationAccessToken).filter(application_id=application.id).first()
if application_access_token is None:
raise AppUnauthorizedFailed(500, _("Illegal User"))
- application_setting_model = DatabaseModelManage.get_model('application_setting')
- application_version = QuerySet(ApplicationVersion).filter(application_id=application.id).order_by(
- '-create_time').first()
+ application_setting_model = DatabaseModelManage.get_model("application_setting")
+ application_version = (
+ QuerySet(ApplicationVersion).filter(application_id=application.id).order_by("-create_time").first()
+ )
if application_version is not None:
self.reset_application(application, application_version)
- license_is_valid = cache.get(Cache_Version.SYSTEM.get_key(key='license_is_valid'),
- version=Cache_Version.SYSTEM.get_version())
+ license_is_valid = cache.get(
+ Cache_Version.SYSTEM.get_key(key="license_is_valid"), version=Cache_Version.SYSTEM.get_version()
+ )
application_setting_dict = {}
if application_setting_model is not None and license_is_valid:
- application_setting = QuerySet(application_setting_model).filter(
- application_id=application_access_token.application_id).first()
+ application_setting = (
+ QuerySet(application_setting_model)
+ .filter(application_id=application_access_token.application_id)
+ .first()
+ )
if application_setting is not None:
- custom_theme = getattr(application_setting, 'custom_theme', {})
- float_location = getattr(application_setting, 'float_location', {})
+ custom_theme = getattr(application_setting, "custom_theme", {})
+ float_location = getattr(application_setting, "float_location", {})
if not custom_theme:
- application_setting.custom_theme = {
- 'theme_color': '',
- 'header_font_color': ''
- }
+ application_setting.custom_theme = {"theme_color": "", "header_font_color": ""}
if not float_location:
application_setting.float_location = {
- 'x': {'type': '', 'value': ''},
- 'y': {'type': '', 'value': ''}
+ "x": {"type": "", "value": ""},
+ "y": {"type": "", "value": ""},
}
- application_setting_dict = {'show_source': application_access_token.show_source,
- 'show_history': application_setting.show_history,
- 'draggable': application_setting.draggable,
- 'show_guide': application_setting.show_guide,
- 'avatar': application_setting.avatar,
- 'show_avatar': application_setting.show_avatar,
- 'float_icon': application_setting.float_icon,
- 'disclaimer': application_setting.disclaimer,
- 'disclaimer_value': application_setting.disclaimer_value,
- 'custom_theme': application_setting.custom_theme,
- 'user_avatar': application_setting.user_avatar,
- 'show_user_avatar': application_setting.show_user_avatar,
- 'show_share': application_setting.show_share,
- 'float_location': application_setting.float_location,
- 'chat_background': application_setting.chat_background}
- base_node = [node for node in ((application.work_flow or {}).get('nodes', []) or []) if
- node.get('id') == 'base-node']
- return {**ApplicationSerializerModel(application).data,
- 'stt_model_id': application.stt_model_id,
- 'tts_model_id': application.tts_model_id,
- 'stt_model_enable': application.stt_model_enable,
- 'tts_model_enable': application.tts_model_enable,
- 'tts_type': application.tts_type,
- 'tts_autoplay': application.tts_autoplay,
- 'stt_autosend': application.stt_autosend,
- 'file_upload_enable': application.file_upload_enable,
- 'file_upload_setting': application.file_upload_setting,
- 'work_flow': {'nodes': base_node} if base_node else None,
- 'show_source': application_access_token.show_source,
- 'show_exec': application_access_token.show_exec,
- 'show_share': True,
- 'language': application_access_token.language,
- **application_setting_dict}
+ application_setting_dict = {
+ "show_source": application_access_token.show_source,
+ "show_history": application_setting.show_history,
+ "draggable": application_setting.draggable,
+ "show_guide": application_setting.show_guide,
+ "avatar": application_setting.avatar,
+ "show_avatar": application_setting.show_avatar,
+ "float_icon": application_setting.float_icon,
+ "disclaimer": application_setting.disclaimer,
+ "disclaimer_value": application_setting.disclaimer_value,
+ "custom_theme": application_setting.custom_theme,
+ "user_avatar": application_setting.user_avatar,
+ "show_user_avatar": application_setting.show_user_avatar,
+ "show_share": application_setting.show_share,
+ "float_location": application_setting.float_location,
+ "chat_background": application_setting.chat_background,
+ }
+ base_node = [
+ node for node in ((application.work_flow or {}).get("nodes", []) or []) if node.get("id") == "base-node"
+ ]
+ return {
+ "id": application.id,
+ "name": application.name,
+ "desc": application.desc,
+ "prologue": application.prologue,
+ "icon": application.icon,
+ "type": application.type,
+ "dialogue_number": application.dialogue_number,
+ "problem_optimization": application.problem_optimization,
+ "stt_model_id": application.stt_model_id,
+ "tts_model_id": application.tts_model_id,
+ "stt_model_enable": application.stt_model_enable,
+ "tts_model_enable": application.tts_model_enable,
+ "tts_type": application.tts_type,
+ "tts_autoplay": application.tts_autoplay,
+ "stt_autosend": application.stt_autosend,
+ "file_upload_enable": application.file_upload_enable,
+ "file_upload_setting": application.file_upload_setting,
+ "work_flow": {"nodes": base_node} if base_node else None,
+ "show_source": application_access_token.show_source,
+ "show_exec": application_access_token.show_exec,
+ "show_share": True,
+ "language": application_access_token.language,
+ **application_setting_dict,
+ }
diff --git a/apps/chat/serializers/chat_history.py b/apps/chat/serializers/chat_history.py
new file mode 100644
index 00000000000..f103f532bf5
--- /dev/null
+++ b/apps/chat/serializers/chat_history.py
@@ -0,0 +1,92 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎
+@file: chat_history.py
+@date:2025/6/9 11:23
+@desc: 会话历史的滚动窗口缓存(Redis,跨 worker 共享)。
+
+- 历史是 append-only:每轮末尾追加一条已完成记录,旧记录不再变。
+- 只缓存最近 LIMIT 条,且只存历史真正要用的字段:question + messages
+ (新流程用 question/messages 构造 Human/AI message,不再用 problem_text/answer_text)。
+- 缓存缺失时回落 DB 并回填;记录定稿(on_complete)后 append/按 id upsert;清历史时失效。
+"""
+
+from django.core.cache import cache
+from django.db.models import QuerySet
+
+from application.models import ChatRecord
+from common.constants.cache_version import Cache_Version
+
+
+class ChatHistory:
+ # 最近多少条历史进上下文(注意:若节点 dialogue_number 超过该值会喂不够)
+ LIMIT = 5
+ TIMEOUT = 60 * 30
+
+ def __init__(self, chat_id):
+ self.chat_id = str(chat_id)
+
+ def _key(self):
+ return Cache_Version.CHAT_HISTORY.get_key(key=self.chat_id)
+
+ def _version(self):
+ return Cache_Version.CHAT_HISTORY.get_version()
+
+ @staticmethod
+ def _to_map(r):
+ return {
+ "id": str(r.id),
+ "chat_id": str(r.chat_id),
+ "question": r.question,
+ "messages": r.messages,
+ "details": r.details,
+ "create_time": r.create_time,
+ }
+
+ @staticmethod
+ def _from_map(d):
+ return ChatRecord(
+ id=d.get("id"),
+ chat_id=d.get("chat_id"),
+ question=d.get("question"),
+ messages=d.get("messages"),
+ details=d.get("details"),
+ create_time=d.get("create_time"),
+ )
+
+ def _load_from_db(self):
+ records = list(QuerySet(ChatRecord).filter(chat_id=self.chat_id).order_by("-create_time")[0 : self.LIMIT])
+ records.sort(key=lambda r: r.create_time)
+ return records
+
+ def load(self, exclude_record_id=None):
+ """
+ 读历史:命中缓存则还原,未命中从 DB 取最近 N 条并回填。
+ exclude_record_id:重答/Form 提交时把当前这条从历史上下文里剔掉。
+ """
+ cached = cache.get(self._key(), version=self._version())
+ if cached is None:
+ records = self._load_from_db()
+ cache.set(self._key(), [self._to_map(r) for r in records], version=self._version(), timeout=self.TIMEOUT)
+ else:
+ records = [self._from_map(d) for d in cached]
+ if exclude_record_id is not None:
+ records = [r for r in records if str(r.id) != str(exclude_record_id)]
+ return records
+
+ def append(self, chat_record):
+ """
+ 记录定稿后追加进缓存(按 create_time 天然排在最后)。
+ re_chat 复用同一 id → 先按 id 去重再追加,等价 upsert。
+ 未预热(缓存为空)则跳过,下次 load 会从 DB 重建。
+ """
+ cached = cache.get(self._key(), version=self._version())
+ if cached is None:
+ return
+ cached = [d for d in cached if str(d.get("id")) != str(chat_record.id)]
+ cached.append(self._to_map(chat_record))
+ cache.set(self._key(), cached[-self.LIMIT :], version=self._version(), timeout=self.TIMEOUT)
+
+ def clear(self):
+ cache.delete(self._key(), version=self._version())
diff --git a/apps/chat/serializers/chat_record.py b/apps/chat/serializers/chat_record.py
index ee6d446dd57..de0db6fcf77 100644
--- a/apps/chat/serializers/chat_record.py
+++ b/apps/chat/serializers/chat_record.py
@@ -1,11 +1,12 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: chat_record.py
- @date:2025/6/23 11:16
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: chat_record.py
+@date:2025/6/23 11:16
+@desc:
"""
+
from typing import Dict
from django.db import transaction
@@ -15,18 +16,20 @@
from application.models import VoteChoices, ChatRecord, Chat, ApplicationAccessToken, VoteReasonChoices
from application.serializers.application_chat import ChatCountSerializer
-from application.serializers.application_chat_record import ChatRecordSerializerModel, \
- ApplicationChatRecordQuerySerializers
+from application.serializers.application_chat_record import (
+ ChatRecordSerializerModel,
+ ApplicationChatRecordQuerySerializers,
+)
from common.db.search import page_search
from common.exception.app_exception import AppApiException
from common.utils.lock import RedisLock
class VoteRequest(serializers.Serializer):
- vote_status = serializers.ChoiceField(choices=VoteChoices.choices,
- label=_("Bidding Status"))
- vote_reason = serializers.ChoiceField(choices=VoteReasonChoices.choices, label=_("Vote Reason"), required=False,
- allow_null=True)
+ vote_status = serializers.ChoiceField(choices=VoteChoices.choices, label=_("Bidding Status"))
+ vote_reason = serializers.ChoiceField(
+ choices=VoteReasonChoices.choices, label=_("Vote Reason"), required=False, allow_null=True
+ )
vote_other_content = serializers.CharField(required=False, allow_blank=True, label=_("Vote other content"))
@@ -34,18 +37,14 @@ class VoteRequest(serializers.Serializer):
class HistoryChatModel(serializers.ModelSerializer):
class Meta:
model = Chat
- fields = ['id',
- 'application_id',
- 'abstract',
- 'create_time',
- 'update_time']
+ fields = ["id", "application_id", "abstract", "create_time", "update_time"]
class VoteSerializer(serializers.Serializer):
+ application_id = serializers.UUIDField(required=True, label=_("Application ID"))
chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
- chat_record_id = serializers.UUIDField(required=True,
- label=_("Conversation record id"))
+ chat_record_id = serializers.UUIDField(required=True, label=_("Conversation record id"))
@transaction.atomic
def vote(self, instance: Dict, with_valid=True):
@@ -53,13 +52,16 @@ def vote(self, instance: Dict, with_valid=True):
self.is_valid(raise_exception=True)
VoteRequest(data=instance).is_valid(raise_exception=True)
rlock = RedisLock()
- if not rlock.try_lock(self.data.get('chat_record_id')):
- raise AppApiException(500,
- gettext(
- "Voting on the current session minutes, please do not send repeated requests"))
+ if not rlock.try_lock(self.data.get("chat_record_id")):
+ raise AppApiException(
+ 500, gettext("Voting on the current session minutes, please do not send repeated requests")
+ )
try:
- chat_record_details_model = QuerySet(ChatRecord).get(id=self.data.get('chat_record_id'),
- chat_id=self.data.get('chat_id'))
+ chat_record_details_model = QuerySet(ChatRecord).get(
+ id=self.data.get("chat_record_id"),
+ chat_id=self.data.get("chat_id"),
+ chat__application_id=self.data.get("application_id"),
+ )
if chat_record_details_model is None:
raise AppApiException(500, gettext("Non-existent conversation chat_record_id"))
vote_status = instance.get("vote_status")
@@ -68,7 +70,7 @@ def vote(self, instance: Dict, with_valid=True):
if chat_record_details_model.vote_status == VoteChoices.UN_VOTE:
# 投票时获取字段
vote_reason = instance.get("vote_reason")
- vote_other_content = instance.get("vote_other_content") or ''
+ vote_other_content = instance.get("vote_other_content") or ""
if vote_status == VoteChoices.STAR:
# 点赞
@@ -88,25 +90,28 @@ def vote(self, instance: Dict, with_valid=True):
# 取消点赞
chat_record_details_model.vote_status = VoteChoices.UN_VOTE
chat_record_details_model.vote_reason = None
- chat_record_details_model.vote_other_content = ''
+ chat_record_details_model.vote_other_content = ""
chat_record_details_model.save()
else:
raise AppApiException(500, gettext("Already voted, please cancel first and then vote again"))
finally:
- rlock.un_lock(self.data.get('chat_record_id'))
- ChatCountSerializer(data={'chat_id': self.data.get('chat_id')}).update_chat()
+ rlock.un_lock(self.data.get("chat_record_id"))
+ ChatCountSerializer(data={"chat_id": self.data.get("chat_id")}).update_chat()
return True
class HistoricalConversationSerializer(serializers.Serializer):
- application_id = serializers.UUIDField(required=True, label=_('Application ID'))
- chat_user_id = serializers.UUIDField(required=True, label=_('Chat User ID'))
+ application_id = serializers.UUIDField(required=True, label=_("Application ID"))
+ chat_user_id = serializers.UUIDField(required=True, label=_("Chat User ID"))
def get_queryset(self):
- chat_user_id = self.data.get('chat_user_id')
+ chat_user_id = self.data.get("chat_user_id")
application_id = self.data.get("application_id")
- return QuerySet(Chat).filter(application_id=application_id, chat_user_id=chat_user_id,
- is_deleted=False).order_by('-update_time', 'id')
+ return (
+ QuerySet(Chat)
+ .filter(application_id=application_id, chat_user_id=chat_user_id, is_deleted=False)
+ .order_by("-update_time", "id")
+ )
def list(self):
self.is_valid(raise_exception=True)
@@ -119,67 +124,90 @@ def page(self, current_page, page_size):
class EditAbstractSerializer(serializers.Serializer):
- abstract = serializers.CharField(required=True, label=_('Abstract'))
+ abstract = serializers.CharField(required=True, label=_("Abstract"))
class HistoricalConversationOperateSerializer(serializers.Serializer):
- application_id = serializers.UUIDField(required=True, label=_('Application ID'))
- chat_user_id = serializers.UUIDField(required=True, label=_('Chat User ID'))
- chat_id = serializers.UUIDField(required=True, label=_('Chat ID'))
+ application_id = serializers.UUIDField(required=True, label=_("Application ID"))
+ chat_user_id = serializers.UUIDField(required=True, label=_("Chat User ID"))
+ chat_id = serializers.UUIDField(required=True, label=_("Chat ID"))
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
- e = QuerySet(Chat).filter(id=self.data.get('chat_id'), application_id=self.data.get('application_id'),
- chat_user_id=self.data.get('chat_user_id')).exists()
+ e = (
+ QuerySet(Chat)
+ .filter(
+ id=self.data.get("chat_id"),
+ application_id=self.data.get("application_id"),
+ chat_user_id=self.data.get("chat_user_id"),
+ )
+ .exists()
+ )
if not e:
- raise AppApiException(500, _('Chat is not exist'))
+ raise AppApiException(500, _("Chat is not exist"))
def edit_abstract(self, instance, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
EditAbstractSerializer(data=instance).is_valid(raise_exception=True)
- QuerySet(Chat).filter(id=self.data.get('chat_id'), application_id=self.data.get('application_id'),
- chat_user_id=self.data.get('chat_user_id')).update(abstract=instance.get('abstract'))
+ chat = (
+ QuerySet(Chat)
+ .filter(
+ id=self.data.get("chat_id"),
+ application_id=self.data.get("application_id"),
+ chat_user_id=self.data.get("chat_user_id"),
+ )
+ .first()
+ )
+ if chat.is_deleted:
+ raise AppApiException(500, _("Chat has been deleted"))
+ chat.abstract = instance.get("abstract")
+ chat.save()
return True
def logic_delete(self, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
- QuerySet(Chat).filter(id=self.data.get('chat_id'), application_id=self.data.get('application_id'),
- chat_user_id=self.data.get('chat_user_id')).update(is_deleted=True)
+ QuerySet(Chat).filter(
+ id=self.data.get("chat_id"),
+ application_id=self.data.get("application_id"),
+ chat_user_id=self.data.get("chat_user_id"),
+ ).update(is_deleted=True)
return True
class Clear(serializers.Serializer):
- application_id = serializers.UUIDField(required=True, label=_('Application ID'))
- chat_user_id = serializers.UUIDField(required=True, label=_('Chat User ID'))
+ application_id = serializers.UUIDField(required=True, label=_("Application ID"))
+ chat_user_id = serializers.UUIDField(required=True, label=_("Chat User ID"))
def batch_logic_delete(self, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
- QuerySet(Chat).filter(application_id=self.data.get('application_id'),
- chat_user_id=self.data.get('chat_user_id')).update(is_deleted=True)
+ QuerySet(Chat).filter(
+ application_id=self.data.get("application_id"), chat_user_id=self.data.get("chat_user_id")
+ ).update(is_deleted=True)
return True
class HistoricalConversationRecordSerializer(serializers.Serializer):
- application_id = serializers.UUIDField(required=True, label=_('Application ID'))
- chat_id = serializers.UUIDField(required=True, label=_('Chat ID'))
- chat_user_id = serializers.UUIDField(required=True, label=_('Chat User ID'))
+ application_id = serializers.UUIDField(required=True, label=_("Application ID"))
+ chat_id = serializers.UUIDField(required=True, label=_("Chat ID"))
+ chat_user_id = serializers.UUIDField(required=True, label=_("Chat User ID"))
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
- chat_user_id = self.data.get('chat_user_id')
+ chat_user_id = self.data.get("chat_user_id")
application_id = self.data.get("application_id")
- chat_id = self.data.get('chat_id')
- chat_exist = QuerySet(Chat).filter(application_id=application_id, chat_user_id=chat_user_id,
- id=chat_id).exists()
+ chat_id = self.data.get("chat_id")
+ chat_exist = (
+ QuerySet(Chat).filter(application_id=application_id, chat_user_id=chat_user_id, id=chat_id).exists()
+ )
if not chat_exist:
- raise AppApiException(500, _('Non-existent chatID'))
+ raise AppApiException(500, _("Non-existent chatID"))
def get_queryset(self):
- chat_id = self.data.get('chat_id')
- return QuerySet(ChatRecord).filter(chat_id=chat_id).order_by('-create_time')
+ chat_id = self.data.get("chat_id")
+ return QuerySet(ChatRecord).filter(chat_id=chat_id).order_by("-create_time")
def list(self):
self.is_valid(raise_exception=True)
@@ -188,13 +216,14 @@ def list(self):
def page(self, current_page, page_size):
self.is_valid(raise_exception=True)
- application_access_token = QuerySet(ApplicationAccessToken).filter(
- application_id=self.data.get('application_id')).first()
+ application_access_token = (
+ QuerySet(ApplicationAccessToken).filter(application_id=self.data.get("application_id")).first()
+ )
show_source = False
show_exec = False
if application_access_token is not None:
show_exec = application_access_token.show_exec
show_source = application_access_token.show_source
return ApplicationChatRecordQuerySerializers(
- data={'application_id': self.data.get('application_id'), 'chat_id': self.data.get('chat_id')}).page(
- current_page, page_size, show_source=show_source, show_exec=show_exec)
+ data={"application_id": self.data.get("application_id"), "chat_id": self.data.get("chat_id")}
+ ).page(current_page, page_size, show_source=show_source, show_exec=show_exec)
diff --git a/apps/chat/serializers/chat_user_api_key_serializers.py b/apps/chat/serializers/chat_user_api_key_serializers.py
new file mode 100644
index 00000000000..ab0d7bce1f5
--- /dev/null
+++ b/apps/chat/serializers/chat_user_api_key_serializers.py
@@ -0,0 +1,56 @@
+# coding=utf-8
+
+import hashlib
+
+import uuid_utils.compat as uuid
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from common.db.search import page_search
+from system_manage.models import ChatUserApiKey
+
+
+class ChatUserApiKeyModelSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ChatUserApiKey
+ fields = ['id', 'secret_key', 'is_active', 'create_time', 'user_id']
+
+
+class ChatUserApiKeySerializer(serializers.Serializer):
+ user_id = serializers.UUIDField(required=True, label=_('user id'))
+ order_by = serializers.CharField(required=False, label=_('order by'), allow_null=True, allow_blank=True)
+
+ def generate(self, with_valid=True):
+ if with_valid:
+ self.is_valid(raise_exception=True)
+ api_key = ChatUserApiKey(
+ id=uuid.uuid7(),
+ secret_key=hashlib.md5(uuid.uuid7().bytes).hexdigest(),
+ user_id=self.data.get('user_id')
+ )
+ api_key.save()
+ return ChatUserApiKeyModelSerializer(api_key).data
+
+ def page(self, current_page: int, page_size: int, with_valid=True):
+ if with_valid:
+ self.is_valid(raise_exception=True)
+ user_id = self.data.get('user_id')
+ query_set = QuerySet(ChatUserApiKey).filter(user_id=user_id)
+ order_by = '-create_time' if self.data.get('order_by') is None or self.data.get('order_by') == '' else self.data.get('order_by')
+ query_set = query_set.order_by(order_by)
+ return page_search(current_page, page_size,
+ query_set,
+ post_records_handler=lambda u: ChatUserApiKeyModelSerializer(u).data)
+
+ class Operate(serializers.Serializer):
+ id = serializers.UUIDField(required=True, label=_('api key id'))
+ user_id = serializers.UUIDField(required=True, label=_('user id'))
+
+ def destroy(self, with_valid=True):
+ if with_valid:
+ self.is_valid(raise_exception=True)
+ QuerySet(ChatUserApiKey).filter(
+ id=self.data.get('id'), user_id=self.data.get('user_id')
+ ).delete()
+ return True
\ No newline at end of file
diff --git a/apps/chat/serializers/chat_user_serializer.py b/apps/chat/serializers/chat_user_serializer.py
new file mode 100644
index 00000000000..ee9c6f76dc6
--- /dev/null
+++ b/apps/chat/serializers/chat_user_serializer.py
@@ -0,0 +1,96 @@
+import json
+
+from django.core.cache import cache
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.models import ApplicationAccessToken
+from common.constants.cache_version import Cache_Version
+from common.exception.app_exception import AppApiException
+from common.utils.common import password_encrypt
+from common.utils.common import password_verify, needs_password_upgrade
+from common.utils.rsa_util import decrypt
+from system_manage.models import ChatUser
+from users.serializers.login import LoginRequest
+
+system_version, system_get_key = Cache_Version.SYSTEM.value
+
+
+class ChatUserAccessTokenV3Serializer(serializers.Serializer):
+ @staticmethod
+ def get_auth_setting():
+ application_access_token = ApplicationAccessToken.objects.filter(is_active=True).first()
+
+ if not application_access_token:
+ raise AppApiException(1005, _("Invalid access token"))
+
+ return application_access_token.authentication_value
+
+ @staticmethod
+ def local_login(instance):
+ username = instance.get("username", "")
+ encryptedData = instance.get("encryptedData", "")
+ if encryptedData:
+ json_data = json.loads(decrypt(encryptedData))
+ instance.update(json_data)
+ try:
+ LoginRequest(data=instance).is_valid(raise_exception=True)
+ except Exception as e:
+ raise e
+ auth_setting = ChatUserAccessTokenV3Serializer.get_auth_setting()
+
+ max_attempts = auth_setting.get("max_attempts", 1)
+ password = instance.get("password")
+ captcha = instance.get("captcha", "")
+
+ # 判断是否需要验证码
+ need_captcha = True
+ if max_attempts == -1:
+ need_captcha = False
+ elif max_attempts > 0:
+ fail_count = cache.get(system_get_key(f"chat_{username}"), version=system_version) or 0
+ need_captcha = fail_count >= max_attempts
+
+ if need_captcha:
+ ChatUserAccessTokenV3Serializer._validate_captcha(username, captcha)
+
+ user = ChatUser.objects.filter(username=username).first()
+
+ if not user or not password_verify(password, user.password):
+ record_login_fail(username)
+ raise AppApiException(500, _("The username or password is incorrect"))
+
+ if needs_password_upgrade(user.password):
+ user.password = password_encrypt(password)
+ user.save(update_fields=["password"])
+ if not user.is_active:
+ raise AppApiException(1005, _("The user has been disabled, please contact the administrator!"))
+ cache.delete(system_get_key(f"chat_{username}"), version=system_version)
+ return user
+
+ @staticmethod
+ def _validate_captcha(username: str, captcha: str) -> None:
+ """验证验证码(一次性消费)"""
+ if not captcha:
+ raise AppApiException(1005, _("Captcha is required"))
+
+ captcha_key = Cache_Version.CAPTCHA.get_key(captcha=f"chat_{username}")
+ captcha_cache = cache.get(captcha_key, version=Cache_Version.CAPTCHA.get_version())
+
+ if captcha_cache is None or captcha.lower() != captcha_cache:
+ record_login_fail(username)
+ raise AppApiException(1005, _("Captcha code error or expiration"))
+
+ # 校验通过即销毁,保证验证码一次性使用
+ cache.delete(captcha_key, version=Cache_Version.CAPTCHA.get_version())
+
+
+def record_login_fail(username: str, expire: int = 600):
+ """记录登录失败次数(原子递增)"""
+ if not username:
+ return
+ fail_key = system_get_key(f"chat_{username}")
+ try:
+ cache.incr(fail_key, 1, version=system_version)
+ except ValueError:
+ cache.set(fail_key, 1, timeout=expire, version=system_version)
diff --git a/apps/chat/serializers/portal.py b/apps/chat/serializers/portal.py
new file mode 100644
index 00000000000..c5a2cd7648b
--- /dev/null
+++ b/apps/chat/serializers/portal.py
@@ -0,0 +1,199 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:MaxKB
+@file: portal.py
+@date:2026/8/14
+@desc: 门户配置序列化器
+"""
+
+from django.core.cache import cache
+from django.db.models import Exists, OuterRef
+from django.utils.translation import gettext_lazy as _
+from rest_framework import serializers
+
+from application.models import Application, Chat
+from application.models.application_access_token import ApplicationAccessToken
+from common.constants.cache_version import Cache_Version
+from common.database_model_manage.database_model_manage import DatabaseModelManage
+from common.db.search import page_search
+from system_manage.models.chat_user import (
+ ChatUser,
+ ResourceChatUserAuthorize,
+ ResourceChatUserGroupAuthorize,
+ ResourceType,
+ UserGroupRelation,
+)
+
+
+def build_application_setting_dict(setting, show_source):
+ return {
+ "show_source": show_source,
+ "show_history": setting.show_history,
+ "draggable": setting.draggable,
+ "show_guide": setting.show_guide,
+ "avatar": setting.avatar,
+ "show_avatar": setting.show_avatar,
+ "float_icon": setting.float_icon,
+ "disclaimer": setting.disclaimer,
+ "disclaimer_value": setting.disclaimer_value,
+ "custom_theme": setting.custom_theme or {"theme_color": "", "header_font_color": ""},
+ "user_avatar": setting.user_avatar,
+ "show_user_avatar": setting.show_user_avatar,
+ "show_share": setting.show_share,
+ "float_location": setting.float_location or {"x": {"type": "", "value": ""}, "y": {"type": "", "value": ""}},
+ "chat_background": setting.chat_background,
+ }
+
+
+def get_application_settings_map(application_ids):
+ """批量返回 application_id -> 门户设置信息;license 无效或模型缺失时返回空 dict"""
+ application_setting_model = DatabaseModelManage.get_model("application_setting")
+ if application_setting_model is None or not application_ids:
+ return {}
+ license_is_valid = cache.get(
+ Cache_Version.SYSTEM.get_key(key="license_is_valid"), version=Cache_Version.SYSTEM.get_version()
+ )
+ if not license_is_valid:
+ return {}
+ settings = application_setting_model.objects.filter(application_id__in=application_ids)
+ access_tokens = ApplicationAccessToken.objects.filter(application_id__in=application_ids).values_list(
+ "application_id", "show_source"
+ )
+ token_map = {str(application_id): show_source for application_id, show_source in access_tokens}
+ return {
+ str(setting.application_id): build_application_setting_dict(
+ setting, token_map.get(str(setting.application_id), False)
+ )
+ for setting in settings
+ }
+
+
+class PortalApplicationAuthMixin:
+ """门户应用授权过滤公共逻辑"""
+
+ @staticmethod
+ def get_authorized_application_ids(user_id):
+ public_apps = ApplicationAccessToken.objects.filter(application_id=OuterRef("id"), authentication=False)
+ if not ChatUser.objects.filter(id=user_id).exists():
+ return (
+ Application.objects.filter(is_publish=True, is_portal=True)
+ .filter(Exists(public_apps))
+ .values_list("id", flat=True)
+ )
+ authed_token_exists = ApplicationAccessToken.objects.filter(application_id=OuterRef("id"), authentication=True)
+ direct_auth = ResourceChatUserAuthorize.objects.filter(
+ resource_id=OuterRef("id"), resource_type=ResourceType.APPLICATION.value, is_auth=True, user_id=user_id
+ )
+ user_groups = UserGroupRelation.objects.filter(user_id=user_id).values_list("group_id", flat=True)
+ group_auth = ResourceChatUserGroupAuthorize.objects.filter(
+ resource_id=OuterRef("id"),
+ resource_type=ResourceType.APPLICATION.value,
+ is_auth=True,
+ user_group_id__in=user_groups,
+ )
+ return (
+ Application.objects.filter(is_publish=True, is_portal=True)
+ .filter(Exists(public_apps) | (Exists(authed_token_exists) & (Exists(direct_auth) | Exists(group_auth))))
+ .values_list("id", flat=True)
+ )
+
+
+class ApplicationResponseSerializer(serializers.Serializer):
+ id = serializers.CharField(required=True)
+ name = serializers.CharField(required=True)
+ desc = serializers.CharField(required=True)
+ icon = serializers.CharField(required=True)
+ type = serializers.CharField(required=True)
+ dialogue_number = serializers.IntegerField(required=True)
+ prologue = serializers.CharField(required=True)
+ is_publish = serializers.BooleanField(required=True)
+ is_portal = serializers.BooleanField(required=True)
+
+
+class PortalApplicationSerializer(serializers.Serializer):
+ class Query(PortalApplicationAuthMixin, serializers.Serializer):
+ name = serializers.CharField(
+ required=False, allow_blank=True, label=_("Application Name"), help_text=_("Application name")
+ )
+
+ def get_query_set(self):
+ queryset = Application.objects.filter(is_publish=True, is_portal=True)
+ name = self.data.get("name")
+ if name:
+ queryset = queryset.filter(name__icontains=name)
+ return queryset.order_by("-create_time")
+
+ def page(self, current_page, page_size, user_id, with_valid=True):
+ if with_valid:
+ self.is_valid(raise_exception=True)
+ queryset = self.get_query_set()
+ queryset = queryset.filter(id__in=self.get_authorized_application_ids(user_id))
+ return page_search(
+ current_page,
+ page_size,
+ queryset,
+ post_records_handler=lambda app: ApplicationResponseSerializer(app).data,
+ )
+
+
+def get_recent_chats_map(user_id, application_ids, limit=5):
+ """批量返回 application_id -> 该应用最近的 limit 条历史会话;show_history=false 的应用不在此表里"""
+ chats = Chat.objects.filter(chat_user_id=user_id, is_deleted=False, application_id__in=application_ids).order_by(
+ "application_id", "-update_time", "id"
+ )
+ result = {}
+ for chat in chats:
+ key = str(chat.application_id)
+ if len(result.get(key, [])) >= limit:
+ continue
+ result.setdefault(key, []).append(
+ {
+ "id": str(chat.id),
+ "abstract": chat.abstract,
+ "create_time": str(chat.create_time),
+ "update_time": str(chat.update_time),
+ }
+ )
+ return result
+
+
+class PortalHistoricalConversationSerializer(serializers.Serializer):
+ class Query(PortalApplicationAuthMixin, serializers.Serializer):
+ name = serializers.CharField(
+ required=False, allow_blank=True, label=_("Application Name"), help_text=_("Application name")
+ )
+
+ def get_query_set(self, user_id):
+ # 主表是应用:返回用户有权限访问的已发布门户应用
+ queryset = Application.objects.filter(
+ is_publish=True,
+ is_portal=True,
+ id__in=self.get_authorized_application_ids(user_id),
+ )
+ name = self.data.get("name")
+ if name:
+ queryset = queryset.filter(name__icontains=name)
+ return queryset.order_by("-create_time")
+
+ def page(self, current_page, page_size, user_id, with_valid=True):
+ if with_valid:
+ self.is_valid(raise_exception=True)
+ result = page_search(
+ current_page,
+ page_size,
+ self.get_query_set(user_id),
+ post_records_handler=lambda app: {
+ "id": str(app.id),
+ "name": app.name,
+ "icon": app.icon,
+ },
+ )
+ app_ids = [record["id"] for record in result["records"]]
+ settings_map = get_application_settings_map(app_ids)
+ show_history_ids = [aid for aid in app_ids if settings_map.get(aid, {}).get("show_history")]
+ chat_map = get_recent_chats_map(user_id, show_history_ids) if show_history_ids else {}
+ for record in result["records"]:
+ record.update(settings_map.get(record["id"], {}))
+ record["conversations"] = chat_map.get(record["id"], [])
+ return result
diff --git a/apps/chat/template/agent_simple.py b/apps/chat/template/agent_simple.py
new file mode 100644
index 00000000000..45db39983e4
--- /dev/null
+++ b/apps/chat/template/agent_simple.py
@@ -0,0 +1,655 @@
+from django.db.models import QuerySet
+
+template = {
+ "edges": [
+ {
+ "id": "6a8d23d9-5179-424e-80c2-f08d37cdb8d4",
+ "type": "app-edge",
+ "endPoint": {"x": 2760, "y": 1054.125},
+ "pointsList": [
+ {"x": 2620, "y": 1054.125},
+ {"x": 2730, "y": 1054.125},
+ {"x": 2650, "y": 1054.125},
+ {"x": 2760, "y": 1054.125},
+ ],
+ "properties": {},
+ "startPoint": {"x": 2620, "y": 1054.125},
+ "sourceNodeId": "fd0324fc-f5e4-4fa6-a2d9-cb251b467605",
+ "targetNodeId": "420a6e4f-44ff-4847-bb81-0923630846b5",
+ "sourceAnchorId": "fd0324fc-f5e4-4fa6-a2d9-cb251b467605_right",
+ "targetAnchorId": "420a6e4f-44ff-4847-bb81-0923630846b5_left",
+ },
+ {
+ "id": "56006748-d9fe-491b-a14b-04fd568cac08",
+ "type": "app-edge",
+ "endPoint": {"x": 3610, "y": 149.25},
+ "pointsList": [
+ {"x": 3340, "y": 913.75},
+ {"x": 3450, "y": 913.75},
+ {"x": 3500, "y": 149.25},
+ {"x": 3610, "y": 149.25},
+ ],
+ "properties": {},
+ "startPoint": {"x": 3340, "y": 913.75},
+ "sourceNodeId": "420a6e4f-44ff-4847-bb81-0923630846b5",
+ "targetNodeId": "36a440a9-5b00-4d82-b13a-8e7819112918",
+ "sourceAnchorId": "420a6e4f-44ff-4847-bb81-0923630846b5_7887_right",
+ "targetAnchorId": "36a440a9-5b00-4d82-b13a-8e7819112918_left",
+ },
+ {
+ "id": "9bc8721b-07aa-4730-9347-910ed64e26b9",
+ "type": "app-edge",
+ "endPoint": {"x": 3610, "y": 1054.125},
+ "pointsList": [
+ {"x": 3340, "y": 1043.125},
+ {"x": 3450, "y": 1043.125},
+ {"x": 3500, "y": 1054.125},
+ {"x": 3610, "y": 1054.125},
+ ],
+ "properties": {},
+ "startPoint": {"x": 3340, "y": 1043.125},
+ "sourceNodeId": "420a6e4f-44ff-4847-bb81-0923630846b5",
+ "targetNodeId": "f7c3b4a2-cb80-4e47-b050-7fef0315daaf",
+ "sourceAnchorId": "420a6e4f-44ff-4847-bb81-0923630846b5_6847_right",
+ "targetAnchorId": "f7c3b4a2-cb80-4e47-b050-7fef0315daaf_left",
+ },
+ {
+ "id": "e4b4bb4e-35ed-40a4-b4e7-b86f77131d92",
+ "type": "app-edge",
+ "endPoint": {"x": 550, "y": 1054.125},
+ "pointsList": [
+ {"x": 280, "y": 1054.125},
+ {"x": 390, "y": 1054.125},
+ {"x": 440, "y": 1054.125},
+ {"x": 550, "y": 1054.125},
+ ],
+ "properties": {},
+ "startPoint": {"x": 280, "y": 1054.125},
+ "sourceNodeId": "start-node",
+ "targetNodeId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94",
+ "sourceAnchorId": "start-node_right",
+ "targetAnchorId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94_left",
+ },
+ {
+ "id": "0ea723ab-bebd-4058-98af-74b6c5f03260",
+ "type": "app-edge",
+ "endPoint": {"x": 1270, "y": 1054.125},
+ "pointsList": [
+ {"x": 1130, "y": 978.4375},
+ {"x": 1240, "y": 978.4375},
+ {"x": 1160, "y": 1054.125},
+ {"x": 1270, "y": 1054.125},
+ ],
+ "properties": {},
+ "startPoint": {"x": 1130, "y": 978.4375},
+ "sourceNodeId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94",
+ "targetNodeId": "a0089772-3821-474f-bb4f-9bfe32c1d95f",
+ "sourceAnchorId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94_gWldyeZ3CMPKS9teLWQeI_right",
+ "targetAnchorId": "a0089772-3821-474f-bb4f-9bfe32c1d95f_left",
+ },
+ {
+ "id": "c0c675d3-cb0b-4b67-8009-16951303791d",
+ "type": "app-edge",
+ "endPoint": {"x": 1730, "y": 1054.125},
+ "pointsList": [
+ {"x": 1130, "y": 1069.125},
+ {"x": 1240, "y": 1069.125},
+ {"x": 1620, "y": 1054.125},
+ {"x": 1730, "y": 1054.125},
+ ],
+ "properties": {},
+ "startPoint": {"x": 1130, "y": 1069.125},
+ "sourceNodeId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94",
+ "targetNodeId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836",
+ "sourceAnchorId": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94_TvdY3NQkSdYbC8A15VrId_right",
+ "targetAnchorId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836_left",
+ },
+ {
+ "id": "0c1d5fc1-6ab2-431e-afdc-9f332ce8b466",
+ "type": "app-edge",
+ "endPoint": {"x": 1730, "y": 1054.125},
+ "pointsList": [
+ {"x": 1590, "y": 1054.125},
+ {"x": 1700, "y": 1054.125},
+ {"x": 1620, "y": 1054.125},
+ {"x": 1730, "y": 1054.125},
+ ],
+ "properties": {},
+ "startPoint": {"x": 1590, "y": 1054.125},
+ "sourceNodeId": "a0089772-3821-474f-bb4f-9bfe32c1d95f",
+ "targetNodeId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836",
+ "sourceAnchorId": "a0089772-3821-474f-bb4f-9bfe32c1d95f_right",
+ "targetAnchorId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836_left",
+ },
+ {
+ "id": "422564a4-2b0a-469b-be86-ded4204e7742",
+ "type": "app-edge",
+ "endPoint": {"x": 2300, "y": 1054.125},
+ "pointsList": [
+ {"x": 2160, "y": 1054.125},
+ {"x": 2270, "y": 1054.125},
+ {"x": 2190, "y": 1054.125},
+ {"x": 2300, "y": 1054.125},
+ ],
+ "properties": {},
+ "startPoint": {"x": 2160, "y": 1054.125},
+ "sourceNodeId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836",
+ "targetNodeId": "fd0324fc-f5e4-4fa6-a2d9-cb251b467605",
+ "sourceAnchorId": "124fe8a0-70fa-42cb-b854-4b6c02ebb836_right",
+ "targetAnchorId": "fd0324fc-f5e4-4fa6-a2d9-cb251b467605_left",
+ },
+ {
+ "id": "a0cee2ac-4d0d-4b68-8cb2-ca2cb39993e9",
+ "type": "app-edge",
+ "endPoint": {"x": 3480, "y": 1973.375},
+ "pointsList": [
+ {"x": 3340, "y": 1133.8125},
+ {"x": 3450, "y": 1133.8125},
+ {"x": 3370, "y": 1973.375},
+ {"x": 3480, "y": 1973.375},
+ ],
+ "properties": {},
+ "startPoint": {"x": 3340, "y": 1133.8125},
+ "sourceNodeId": "420a6e4f-44ff-4847-bb81-0923630846b5",
+ "targetNodeId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4",
+ "sourceAnchorId": "420a6e4f-44ff-4847-bb81-0923630846b5_2794_right",
+ "targetAnchorId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4_left",
+ },
+ {
+ "id": "cd66759a-bcb9-4d61-806b-7bde23ae4582",
+ "type": "app-edge",
+ "endPoint": {"x": 4200, "y": 1001.5},
+ "pointsList": [
+ {"x": 4060, "y": 1897.6875},
+ {"x": 4170, "y": 1897.6875},
+ {"x": 4090, "y": 1001.5},
+ {"x": 4200, "y": 1001.5},
+ ],
+ "properties": {},
+ "startPoint": {"x": 4060, "y": 1897.6875},
+ "sourceNodeId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4",
+ "targetNodeId": "dd02a0d8-0ea1-41c4-8b64-0cb7d8963fd9",
+ "sourceAnchorId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4_Iu8b0BMQU9xXWy5JbcTnz_right",
+ "targetAnchorId": "dd02a0d8-0ea1-41c4-8b64-0cb7d8963fd9_left",
+ },
+ {
+ "id": "7113c5b7-d9d6-4f49-a030-24eaeee00e7d",
+ "type": "app-edge",
+ "endPoint": {"x": 4200, "y": 1973.375},
+ "pointsList": [
+ {"x": 4060, "y": 1988.375},
+ {"x": 4170, "y": 1988.375},
+ {"x": 4090, "y": 1973.375},
+ {"x": 4200, "y": 1973.375},
+ ],
+ "properties": {},
+ "startPoint": {"x": 4060, "y": 1988.375},
+ "sourceNodeId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4",
+ "targetNodeId": "04dd6c1e-95f9-4757-bb3e-134d503fce54",
+ "sourceAnchorId": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4_s-groW06vt6a7B-aqDqnX_right",
+ "targetAnchorId": "04dd6c1e-95f9-4757-bb3e-134d503fce54_left",
+ },
+ ],
+ "nodes": [
+ {
+ "x": 120,
+ "y": 120,
+ "id": "base-node",
+ "type": "base-node",
+ "properties": {
+ "config": {},
+ "height": 984.25,
+ "showNode": True,
+ "stepName": "基本信息",
+ "node_data": {
+ "desc": "www",
+ "name": "www",
+ "prologue": "您好,我是 XXX 小助手,您可以向我提出 XXX 使用问题。\n- XXX 主要功能有什么?\n- XXX 如何收费?\n- 需要转人工服务",
+ "tts_type": "BROWSER",
+ "stt_model_id_type": "default",
+ "long_term_model_id_type": "default",
+ },
+ "enableException": False,
+ "input_field_list": [],
+ "user_input_config": {"title": "用户输入"},
+ "api_input_field_list": [],
+ "chat_input_field_list": [],
+ "user_input_field_list": [
+ {
+ "attrs": {},
+ "field": "problem_optimization",
+ "label": {
+ "attrs": {"tooltip": "是否需要问题优化"},
+ "label": "问题优化",
+ "input_type": "TooltipLabel",
+ "props_info": {},
+ },
+ "required": True,
+ "input_type": "SwitchInput",
+ "default_value": False,
+ "visibility_rules": {
+ "action": "show",
+ "node_id": "base-node",
+ "condition": "and",
+ "node_name": "基本信息",
+ "conditions": [],
+ },
+ "show_default_value": True,
+ },
+ {
+ "attrs": {},
+ "field": "ai_questioning",
+ "label": {
+ "attrs": {"tooltip": "是否ai回复"},
+ "label": "是否ai回复",
+ "input_type": "TooltipLabel",
+ "props_info": {},
+ },
+ "required": True,
+ "input_type": "SwitchInput",
+ "default_value": False,
+ "visibility_rules": {
+ "action": "show",
+ "node_id": "base-node",
+ "condition": "and",
+ "node_name": "基本信息",
+ "conditions": [],
+ },
+ "show_default_value": True,
+ },
+ ],
+ },
+ },
+ {
+ "x": 120,
+ "y": 1054.125,
+ "id": "start-node",
+ "type": "start-node",
+ "properties": {
+ "config": {
+ "fields": [{"label": "用户问题", "value": "question"}],
+ "chatFields": [],
+ "globalFields": [
+ {"label": "当前时间", "value": "time"},
+ {"label": "历史聊天记录", "value": "history_context"},
+ {"label": "对话 ID", "value": "chat_id"},
+ {"label": "对话用户 ID", "value": "chat_user_id"},
+ {"label": "对话用户类型", "value": "chat_user_type"},
+ {"label": "对话用户组", "value": "chat_user_group"},
+ {"label": "对话用户", "value": "chat_user"},
+ {"label": "问题优化", "value": "problem_optimization"},
+ {"label": "是否ai回复", "value": "ai_questioning"},
+ ],
+ },
+ "fields": [{"label": "用户问题", "value": "question"}],
+ "height": 644,
+ "showNode": True,
+ "stepName": "开始",
+ "globalFields": [{"label": "当前时间", "value": "time"}],
+ "enableException": False,
+ },
+ },
+ {
+ "x": 2460,
+ "y": 1054.125,
+ "id": "fd0324fc-f5e4-4fa6-a2d9-cb251b467605",
+ "type": "search-knowledge-node",
+ "properties": {
+ "config": {
+ "fields": [
+ {"label": "检索结果的分段列表", "value": "paragraph_list"},
+ {"label": "满足直接回答的分段列表", "value": "is_hit_handling_method_list"},
+ {"label": "检索结果", "value": "data"},
+ {"label": "满足直接回答的分段内容", "value": "directly_return"},
+ ]
+ },
+ "height": 806.375,
+ "showNode": True,
+ "stepName": "知识库检索",
+ "condition": "AND",
+ "node_data": {
+ "knowledge_list": [],
+ "show_knowledge": True,
+ "knowledge_id_list": [],
+ "knowledge_setting": {
+ "top_n": 3,
+ "similarity": 0.6,
+ "search_mode": "embedding",
+ "max_paragraph_char_number": 5000,
+ },
+ "search_scope_type": "custom",
+ "search_scope_source": "knowledge",
+ "all_knowledge_id_list": [],
+ "question_reference_address": ["124fe8a0-70fa-42cb-b854-4b6c02ebb836", "Group1"],
+ "no_permission_knowledge_id_list": [],
+ },
+ "enableException": False,
+ },
+ },
+ {
+ "x": 3050,
+ "y": 1054.125,
+ "id": "420a6e4f-44ff-4847-bb81-0923630846b5",
+ "type": "condition-node",
+ "properties": {
+ "width": 600,
+ "config": {"fields": [{"label": "分支名称", "value": "branch_name"}]},
+ "height": 552.125,
+ "showNode": True,
+ "stepName": "判断器",
+ "condition": "AND",
+ "node_data": {
+ "branch": [
+ {
+ "id": "7887",
+ "type": "IF",
+ "condition": "and",
+ "conditions": [
+ {
+ "field": ["fd0324fc-f5e4-4fa6-a2d9-cb251b467605", "is_hit_handling_method_list"],
+ "value": 1,
+ "compare": "is_not_None",
+ }
+ ],
+ },
+ {
+ "id": "6847",
+ "type": "ELSE IF 1",
+ "condition": "and",
+ "conditions": [
+ {
+ "field": ["fd0324fc-f5e4-4fa6-a2d9-cb251b467605", "paragraph_list"],
+ "value": 1,
+ "compare": "is_not_None",
+ }
+ ],
+ },
+ {"id": "2794", "type": "ELSE", "condition": "and", "conditions": []},
+ ]
+ },
+ "enableException": False,
+ "branch_condition_list": [
+ {"id": "7887", "index": 0, "height": 121.375},
+ {"id": "6847", "index": 1, "height": 121.375},
+ {"id": "2794", "index": 2, "height": 44},
+ ],
+ },
+ },
+ {
+ "x": 3770,
+ "y": 149.25,
+ "id": "36a440a9-5b00-4d82-b13a-8e7819112918",
+ "type": "reply-node",
+ "properties": {
+ "config": {"fields": [{"label": "内容", "value": "answer"}]},
+ "height": 394,
+ "showNode": True,
+ "stepName": "指定回复",
+ "condition": "AND",
+ "node_data": {
+ "fields": ["fd0324fc-f5e4-4fa6-a2d9-cb251b467605", "directly_return"],
+ "content": "",
+ "is_result": True,
+ "reply_type": "referencing",
+ },
+ "enableException": False,
+ },
+ },
+ {
+ "x": 3770,
+ "y": 1054.125,
+ "id": "f7c3b4a2-cb80-4e47-b050-7fef0315daaf",
+ "type": "ai-chat-node",
+ "properties": {
+ "config": {
+ "fields": [
+ {"label": "AI 回答内容", "value": "answer"},
+ {"label": "思考过程", "value": "reasoning_content"},
+ {"label": "历史聊天记录", "value": "history_message"},
+ ]
+ },
+ "height": 1175.75,
+ "showNode": True,
+ "stepName": "AI 对话",
+ "condition": "AND",
+ "node_data": {
+ "prompt": "已知信息:\n{{知识库检索.data}}\n问题:\n{{开始.question}}",
+ "system": "",
+ "model_id": "",
+ "is_result": True,
+ "max_tokens": None,
+ "temperature": None,
+ "dialogue_type": "WORKFLOW",
+ "model_id_type": "custom",
+ "model_setting": {
+ "reasoning_content_end": "",
+ "reasoning_content_start": "",
+ "reasoning_content_enable": False,
+ },
+ "dialogue_number": 1,
+ "mcp_output_enable": True,
+ "model_id_reference": [],
+ },
+ "enableException": False,
+ },
+ },
+ {
+ "x": 4360,
+ "y": 1973.375,
+ "id": "04dd6c1e-95f9-4757-bb3e-134d503fce54",
+ "type": "reply-node",
+ "properties": {
+ "config": {"fields": [{"label": "内容", "value": "answer"}]},
+ "height": 512,
+ "showNode": True,
+ "stepName": "指定回复1",
+ "condition": "AND",
+ "node_data": {
+ "fields": [],
+ "content": "抱歉,没有在知识库查询到相关内容,请提供更详细的信息。",
+ "is_result": True,
+ "reply_type": "content",
+ },
+ "enableException": False,
+ },
+ },
+ {
+ "x": 840,
+ "y": 1054.125,
+ "id": "b4dd9d45-25f0-4b01-9ec3-557a46a97d94",
+ "type": "condition-node",
+ "properties": {
+ "width": 600,
+ "config": {"fields": [{"label": "分支名称", "value": "branch_name"}]},
+ "height": 422.75,
+ "showNode": True,
+ "stepName": "判断器1",
+ "condition": "AND",
+ "node_data": {
+ "branch": [
+ {
+ "id": "gWldyeZ3CMPKS9teLWQeI",
+ "type": "IF",
+ "condition": "and",
+ "conditions": [
+ {"field": ["global", "problem_optimization"], "value": 1, "compare": "is_True"}
+ ],
+ },
+ {"id": "TvdY3NQkSdYbC8A15VrId", "type": "ELSE", "condition": "and", "conditions": []},
+ ]
+ },
+ "enableException": False,
+ "branch_condition_list": [
+ {"id": "gWldyeZ3CMPKS9teLWQeI", "index": 0, "height": 121.375},
+ {"id": "TvdY3NQkSdYbC8A15VrId", "index": 1, "height": 44},
+ ],
+ },
+ },
+ {
+ "x": 1430,
+ "y": 1054.125,
+ "id": "a0089772-3821-474f-bb4f-9bfe32c1d95f",
+ "type": "question-node",
+ "properties": {
+ "config": {"fields": [{"label": "问题优化结果", "value": "answer"}]},
+ "height": 842,
+ "showNode": True,
+ "stepName": "问题优化",
+ "condition": "AND",
+ "node_data": {
+ "prompt": "{{开始.question}}",
+ "system": "# 角色\n你是一位问题优化大师,擅长根据上下文精准揣测用户意图,并对用户提出的问题进行优化。\n\n## 技能\n### 技能 1: 优化问题\n2. 接收用户输入的问题。\n3. 依据上下文仔细分析问题含义。\n4. 输出优化后的问题。\n\n## 限制:\n- 仅返回优化后的问题,不进行额外解释或说明。\n- 确保优化后的问题准确反映原始问题意图,不得改变原意。",
+ "model_id": "",
+ "is_result": False,
+ "model_id_type": "default",
+ "dialogue_number": 0,
+ "model_id_reference": [],
+ },
+ "enableException": False,
+ },
+ },
+ {
+ "x": 1945,
+ "y": 1054.125,
+ "id": "124fe8a0-70fa-42cb-b854-4b6c02ebb836",
+ "type": "variable-aggregation-node",
+ "properties": {
+ "config": {"fields": [{"label": "Group1", "value": "Group1"}]},
+ "height": 530.75,
+ "showNode": True,
+ "stepName": "变量聚合",
+ "condition": "AND",
+ "node_data": {
+ "strategy": "first_non_None",
+ "is_result": True,
+ "group_list": [
+ {
+ "id": "A5aBuBrQJ5hq12mKSJNiQ",
+ "field": "Group1",
+ "label": "Group1",
+ "variable_list": [
+ {
+ "v_id": "0bmeMSbo9696jwbfp3jDX",
+ "variable": ["a0089772-3821-474f-bb4f-9bfe32c1d95f", "answer"],
+ },
+ {"v_id": "1YHRj-fr3_IQpELv_HAdC", "variable": ["start-node", "question"]},
+ ],
+ }
+ ],
+ },
+ "enableException": False,
+ },
+ },
+ {
+ "x": 4360,
+ "y": 1001.5,
+ "id": "dd02a0d8-0ea1-41c4-8b64-0cb7d8963fd9",
+ "type": "ai-chat-node",
+ "properties": {
+ "config": {
+ "fields": [
+ {"label": "AI 回答内容", "value": "answer"},
+ {"label": "思考过程", "value": "reasoning_content"},
+ {"label": "历史聊天记录", "value": "history_message"},
+ ]
+ },
+ "height": 1191.75,
+ "showNode": True,
+ "stepName": "AI 对话1",
+ "condition": "AND",
+ "node_data": {
+ "prompt": "{{开始.question}}",
+ "system": "",
+ "model_id": "",
+ "is_result": True,
+ "max_tokens": None,
+ "temperature": None,
+ "dialogue_type": "WORKFLOW",
+ "model_id_type": "custom",
+ "model_setting": {
+ "reasoning_content_end": "",
+ "reasoning_content_start": "",
+ "reasoning_content_enable": False,
+ },
+ "dialogue_number": 0,
+ "mcp_output_enable": True,
+ "model_id_reference": [],
+ },
+ "enableException": False,
+ },
+ },
+ {
+ "x": 3770,
+ "y": 1973.375,
+ "id": "f9ae6300-5b07-4244-9b88-2a5e7329e1d4",
+ "type": "condition-node",
+ "properties": {
+ "width": 600,
+ "config": {"fields": [{"label": "分支名称", "value": "branch_name"}]},
+ "height": 422.75,
+ "showNode": True,
+ "stepName": "判断器2",
+ "condition": "AND",
+ "node_data": {
+ "branch": [
+ {
+ "id": "Iu8b0BMQU9xXWy5JbcTnz",
+ "type": "IF",
+ "condition": "and",
+ "conditions": [{"field": ["global", "ai_questioning"], "value": 1, "compare": "is_True"}],
+ },
+ {"id": "s-groW06vt6a7B-aqDqnX", "type": "ELSE", "condition": "and", "conditions": []},
+ ]
+ },
+ "enableException": False,
+ "branch_condition_list": [
+ {"id": "Iu8b0BMQU9xXWy5JbcTnz", "index": 0, "height": 121.375},
+ {"id": "s-groW06vt6a7B-aqDqnX", "index": 1, "height": 44},
+ ],
+ },
+ },
+ ],
+}
+
+
+def build_workflow(application):
+ from system_manage.models.resource_mapping import ResourceMapping, ResourceType
+
+ data = template.copy()
+ if application.knowledge_ids:
+ knowledge_ids = application.knowledge_ids
+ else:
+ knowledge_ids = (
+ QuerySet(ResourceMapping)
+ .filter(source_type=ResourceType.APPLICATION, source_id=application.id, target_type=ResourceType.KNOWLEDGE)
+ .values_list("target_id", flat=True)
+ )
+
+ data["nodes"][0]["properties"]["user_input_field_list"][0]["default_value"] = application.problem_optimization
+
+ data["nodes"][0]["properties"]["user_input_field_list"][1]["default_value"] = (
+ application.knowledge_setting.no_references_setting.status == "ai_questioning"
+ )
+ model_id = application.model
+ model_params_setting = application.model_params_setting or {}
+ ## 问题优化设置
+ data["nodes"][8]["properties"]["node_data"]["model_id"] = model_id
+ data["nodes"][8]["properties"]["node_data"]["prompt"] = application.problem_optimization_prompt.replace(
+ "{question}", "{{开始.question}}"
+ )
+ data["nodes"][8]["properties"]["node_data"]["model_params_setting"] = model_params_setting
+ ## 知识库检索
+ data["nodes"][2]["properties"]["node_data"]["knowledge_id_list"] = knowledge_ids
+ data["nodes"][2]["properties"]["node_data"]["knowledge_setting"] = application.knowledge_setting
+ ## ai对话
+ data["nodes"][5]["properties"]["node_data"]["model_id"] = model_id
+ data["nodes"][5]["properties"]["node_data"]["model_params_setting"] = model_params_setting
+ data["nodes"][5]["properties"]["node_data"]["prompt"] = application.model_setting.prompt
+ ## 未查询到知识库ai 回复
+ data["nodes"][10]["properties"]["node_data"]["model_id"] = model_id
+ data["nodes"][10]["properties"]["node_data"]["model_params_setting"] = model_params_setting
+ ## 未查询到知识库指定回复
+ if application.knowledge_setting.no_references_setting.status == "designated_answer":
+ data["nodes"][6]["properties"]["node_data"]["content"] = application.knowledge_setting.value
+
+ return data
diff --git a/apps/chat/urls.py b/apps/chat/urls.py
index 5fb3dc23fa0..b898adf6b66 100644
--- a/apps/chat/urls.py
+++ b/apps/chat/urls.py
@@ -1,32 +1,77 @@
-from django.urls import path
+from django.urls import path, include
from application.views import ChatRecordDetailView, ChatRecordLinkView
-from chat.views.mcp import mcp_view
-from . import views
+from chat.views import v2 as v2_views, v3 as v3_views
+from chat.views.v3.knowledge import knowledge_mcp_view, retrieve_view
-app_name = 'chat'
+app_name = "chat"
# @formatter:off
# fmt: off
-urlpatterns = [
- path('embed', views.ChatEmbedView.as_view()),
- path('mcp', mcp_view),
- path('auth/anonymous', views.AnonymousAuthentication.as_view()),
- path('profile', views.AuthProfile.as_view()),
- path('application/profile', views.ApplicationProfile.as_view(), name='profile'),
- path('chat_message/', views.ChatView.as_view(), name='chat'),
- path('open', views.OpenView.as_view(), name='open'),
- path('text_to_speech', views.TextToSpeech.as_view()),
- path('speech_to_text', views.SpeechToText.as_view()),
- path('captcha', views.CaptchaView.as_view(), name='captcha'),
- path('/chat/completions', views.OpenAIView.as_view(), name='application/chat_completions'),
- path('vote/chat//chat_record/', views.VoteView.as_view(), name='vote'),
- path('historical_conversation', views.HistoricalConversationView.as_view(), name='historical_conversation'),
- path('historical_conversation//record/',views.ChatRecordView.as_view(),name='conversation_details'),
- path('historical_conversation//', views.HistoricalConversationView.PageView.as_view(), name='historical_conversation'),
- path('historical_conversation/clear',views.HistoricalConversationView.BatchDelete.as_view(), name='historical_conversation_clear'),
- path('historical_conversation/',views.HistoricalConversationView.Operate.as_view(), name='historical_conversation_operate'),
- path('historical_conversation_record/', views.HistoricalConversationRecordView.as_view(), name='historical_conversation_record'),
- path('historical_conversation_record///', views.HistoricalConversationRecordView.PageView.as_view(), name='historical_conversation_record'),
+
+v3=[
+ # ---- application 作用域:application_id 从 path 获取 ----
+ path('application//', include([
+ path("profile",v3_views.ApplicationProfile.as_view(), name='v3_profile'),
+ path('open', v3_views.OpenView.as_view(), name='v3_open'),
+ path('text_to_speech',v3_views.TextToSpeech.as_view(),name='v3_text_to_speech'),
+ path('speech_to_text',v3_views.SpeechToText.as_view(),name='v3_speech_to_text'),
+ path('chat/completions',v3_views.OpenAIView.as_view(), name='v3_chat_completions'),
+ path('chat/clear',v3_views.HistoricalConversationView.BatchDelete.as_view(), name='v3_historical_conversation_clear'),
+ path('chat',v3_views.HistoricalConversationView.as_view(), name='v3_historical_conversation'),
+ path('chat//',v3_views.HistoricalConversationView.PageView.as_view(),name='v3_historical_conversation_page'),
+ path('chat//chat_message',v3_views.ChatView.as_view(), name='v3_chat'),
+ path('chat//chat_record',v3_views.HistoricalConversationRecordView.as_view(), name='v3_historical_conversation_record'),
+ path('chat//chat_record//', v3_views.HistoricalConversationRecordView.PageView.as_view(), name='v3_historical_conversation_record_page'),
+ path('chat//chat_record/',v3_views.ChatRecordView.as_view(),name='v3_conversation_details'),
+ path('chat//chat_record//vote',v3_views.VoteView.as_view(), name='v3_vote'),
+ path('chat//share_chat',ChatRecordLinkView.as_view(),name='v3_share_chat'),
+ path('chat/',v3_views.HistoricalConversationView.Operate.as_view(), name='v3_historical_conversation_operate'),
+ ])),
+ # ---- 全局(非 application 作用域)----
+ path('embed', v3_views.ChatEmbedView.as_view()),
+ path('mcp', v3_views.mcp_view),
+ path('auth/anonymous', v3_views.AnonymousAuthentication.as_view()),
+ path('auth/login', v3_views.LocalLoginView.as_view()),
+ path('auth/logout', v3_views.Logout.as_view(), name='v3_logout'),
+ path('profile', v3_views.AuthProfile.as_view()),
+ path('captcha', v3_views.CaptchaView.as_view(), name='v3_captcha'),
+ path('share/', ChatRecordDetailView.as_view()),
+ path('chat_message//cancel', v3_views.CancelWorkflowView.as_view(), name='v3_cancel_workflow'),
+ path('chat_user/profile', v3_views.ChatUserProfileView.as_view(), name='v3_chat_user_profile'),
+ path('chat_user/current/reset_password', v3_views.ResetCurrentUserPasswordView.as_view(), name='v3_reset_password_current'),
+ path('api_key', v3_views.ChatUserApiKeyView.as_view()),
+ path('api_key//', v3_views.ChatUserApiKeyView.Page.as_view()),
+ path('api_key/', v3_views.ChatUserApiKeyView.Operate.as_view()),
+ path('portal/application//', v3_views.PortalApplicationView.as_view(), name='v3_portal_application'),
+ path('portal/chat//', v3_views.PortalHistoricalConversationView.as_view(), name='v3_portal_historical_conversation'),
+ path('knowledge//retrieve', retrieve_view),
+ path('knowledge//mcp', knowledge_mcp_view),
+]
+v2=[
+ path('embed', v2_views.ChatEmbedView.as_view()),
+ path('mcp', v2_views.mcp_view),
+ path('auth/anonymous', v2_views.AnonymousAuthentication.as_view(), name='anonymous'),
+ path('profile', v2_views.AuthProfile.as_view()),
+ path('application/profile', v2_views.ApplicationProfile.as_view(), name='profile'),
+ path('chat_message/', v2_views.ChatView.as_view(), name='chat'),
+ path('open', v2_views.OpenView.as_view(), name='open'),
+ path('text_to_speech', v2_views.TextToSpeech.as_view()),
+ path('speech_to_text', v2_views.SpeechToText.as_view()),
+ path('captcha', v2_views.CaptchaView.as_view(), name='captcha'),
+ path('/chat/completions', v2_views.OpenAIView.as_view(), name='application/chat_completions'),
+ path('vote/chat//chat_record/', v2_views.VoteView.as_view(), name='vote'),
+ path('historical_conversation', v2_views.HistoricalConversationView.as_view(), name='historical_conversation'),
+ path('historical_conversation//record/',v2_views.ChatRecordView.as_view(),name='conversation_details'),
+ path('historical_conversation//', v2_views.HistoricalConversationView.PageView.as_view(), name='historical_conversation'),
+ path('historical_conversation/clear',v2_views.HistoricalConversationView.BatchDelete.as_view(), name='historical_conversation_clear'),
+ path('historical_conversation/',v2_views.HistoricalConversationView.Operate.as_view(), name='historical_conversation_operate'),
+ path('historical_conversation_record/', v2_views.HistoricalConversationRecordView.as_view(), name='historical_conversation_record'),
+ path('historical_conversation_record///', v2_views.HistoricalConversationRecordView.PageView.as_view(), name='historical_conversation_record'),
path('share/', ChatRecordDetailView.as_view()),
path('/chat//share_chat', ChatRecordLinkView.as_view()),
+
+]
+urlpatterns = [
+ *v2,
+ path('v3/',include(v3))
]
diff --git a/apps/chat/views/__init__.py b/apps/chat/views/__init__.py
index fa38335c952..da273c43822 100644
--- a/apps/chat/views/__init__.py
+++ b/apps/chat/views/__init__.py
@@ -6,6 +6,5 @@
@date:2025/5/29 16:08
@desc:
"""
-from .chat_embed import *
-from .chat import *
-from .chat_record import *
+from . import v2
+from . import v3
diff --git a/apps/chat/views/chat.py b/apps/chat/views/chat.py
deleted file mode 100644
index a18a5ad95dc..00000000000
--- a/apps/chat/views/chat.py
+++ /dev/null
@@ -1,274 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: chat.py
- @date:2025/6/6 11:18
- @desc:
-"""
-import requests
-from django.http import HttpResponse, StreamingHttpResponse
-from django.utils.translation import gettext_lazy as _
-from drf_spectacular.utils import extend_schema
-from rest_framework.parsers import MultiPartParser
-from rest_framework.request import Request
-from rest_framework.views import APIView
-
-from application.api.application_api import SpeechToTextAPI, TextToSpeechAPI
-from application.models import ChatUserType, ChatSourceChoices
-from chat.api.chat_api import ChatAPI
-from chat.api.chat_authentication_api import ChatAuthenticationAPI, ChatAuthenticationProfileAPI, ChatOpenAPI, OpenAIAPI
-from chat.serializers.chat import OpenChatSerializers, ChatSerializers, SpeechToTextSerializers, \
- TextToSpeechSerializers, OpenAIChatSerializer
-from chat.serializers.chat_authentication import AnonymousAuthenticationSerializer, ApplicationProfileSerializer, \
- AuthProfileSerializer
-from common.auth import ChatTokenAuth
-from common.constants.permission_constants import ChatAuth
-from common.exception.app_exception import AppAuthenticationFailed
-from common.log.log import _get_ip_address
-from common.result import result
-from knowledge.models import FileSourceType
-from oss.serializers.file import FileSerializer
-from users.api import CaptchaAPI
-from users.serializers.login import CaptchaSerializer
-
-
-def stream_image(response):
- """生成器函数,用于流式传输图片数据"""
- for chunk in response.iter_content(chunk_size=4096):
- if chunk: # 过滤掉保持连接的空块
- yield chunk
-
-
-class ResourceProxy(APIView):
- def get(self, request: Request):
- image_url = request.query_params.get("url")
- if not image_url:
- return result.error("Missing 'url' parameter")
- try:
-
- # 发送GET请求,流式获取图片内容
- response = requests.get(
- image_url,
- stream=True, # 启用流式响应
- allow_redirects=True,
- timeout=10
- )
- content_type = response.headers.get('Content-Type', '').split(';')[0]
- # 创建Django流式响应
- django_response = StreamingHttpResponse(
- stream_image(response), # 使用生成器
- content_type=content_type
- )
-
- return django_response
- except Exception as e:
- return result.error(f"Image request failed: {str(e)}")
-
-
-class OpenAIView(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['POST'],
- description=_('OpenAI Interface Dialogue'),
- summary=_('OpenAI Interface Dialogue'),
- operation_id=_('OpenAI Interface Dialogue'), # type: ignore
- request=OpenAIAPI.get_request(),
- responses=None,
- tags=[_('Chat')] # type: ignore
- )
- def post(self, request: Request, application_id: str):
- ip_address = _get_ip_address(request)
- if application_id != str(request.auth.application_id):
- raise AppAuthenticationFailed(500, _('Secret key is invalid'))
- return OpenAIChatSerializer(
- data={'application_id': application_id, 'chat_user_id': request.auth.chat_user_id,
- 'chat_user_type': request.auth.chat_user_type,
- 'ip_address': ip_address,
- 'source': {"type": ChatSourceChoices.API_CALL.value}}).chat(request.data)
-
-
-class AnonymousAuthentication(APIView):
- def options(self, request, *args, **kwargs):
- return HttpResponse(
- headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Credentials": "true",
- "Access-Control-Allow-Methods": "POST",
- "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token"}, )
-
- @extend_schema(
- methods=['POST'],
- description=_('Application Anonymous Certification'),
- summary=_('Application Anonymous Certification'),
- operation_id=_('Application Anonymous Certification'), # type: ignore
- request=ChatAuthenticationAPI.get_request(),
- responses=None,
- tags=[_('Chat')] # type: ignore
- )
- def post(self, request: Request):
- return result.success(
- AnonymousAuthenticationSerializer(data={'access_token': request.data.get("access_token")}).auth(
- request),
- headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Credentials": "true",
- "Access-Control-Allow-Methods": "POST",
- "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token"}
- )
-
-
-class ApplicationProfile(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['GET'],
- description=_("Get application related information"),
- summary=_("Get application related information"),
- operation_id=_("Get application related information"), # type: ignore
- request=None,
- responses=None,
- tags=[_('Chat')] # type: ignore
- )
- def get(self, request: Request):
- if isinstance(request.auth, ChatAuth):
- return result.success(ApplicationProfileSerializer(
- data={'application_id': request.auth.application_id}).profile())
- raise AppAuthenticationFailed(401, "身份异常")
-
-
-class AuthProfile(APIView):
- @extend_schema(
- methods=['GET'],
- description=_("Get application authentication information"),
- summary=_("Get application authentication information"),
- operation_id=_("Get application authentication information"), # type: ignore
- parameters=ChatAuthenticationProfileAPI.get_parameters(),
- responses=None,
- tags=[_('Chat')] # type: ignore
- )
- def get(self, request: Request):
- return result.success(
- AuthProfileSerializer(data={'access_token': request.query_params.get("access_token")}).profile())
-
-
-class ChatView(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['POST'],
- description=_("dialogue"),
- summary=_("dialogue"),
- operation_id=_("dialogue"), # type: ignore
- request=ChatAPI.get_request(),
- parameters=ChatAPI.get_parameters(),
- responses=None,
- tags=[_('Chat')] # type: ignore
- )
- def post(self, request: Request, chat_id: str):
- ip_address = _get_ip_address(request)
- return ChatSerializers(data={'chat_id': chat_id,
- 'chat_user_id': request.auth.chat_user_id,
- 'chat_user_type': request.auth.chat_user_type,
- 'application_id': request.auth.application_id,
- 'debug': False,
- 'ip_address': ip_address,
- 'source': {
- 'type': ChatSourceChoices.API_CALL.value if request.auth.chat_user_type == ChatUserType.APPLICATION_API_KEY.value else ChatSourceChoices.ONLINE.value}
- }
- ).chat(request.data)
-
-
-class OpenView(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['GET'],
- description=_("Get the session id according to the application id"),
- summary=_("Get the session id according to the application id"),
- operation_id=_("Get the session id according to the application id"), # type: ignore
- parameters=ChatOpenAPI.get_parameters(),
- responses=None,
- tags=[_('Chat')] # type: ignore
- )
- def get(self, request: Request):
- ip_address = _get_ip_address(request)
- return result.success(OpenChatSerializers(
- data={'application_id': request.auth.application_id,
- 'chat_user_id': request.auth.chat_user_id, 'chat_user_type': request.auth.chat_user_type,
- 'ip_address': ip_address,
- 'source': {
- 'type': ChatSourceChoices.API_CALL.value if request.auth.chat_user_type == ChatUserType.APPLICATION_API_KEY.value else ChatSourceChoices.ONLINE.value},
- 'debug': False}).open())
-
-
-class CaptchaView(APIView):
- @extend_schema(methods=['GET'],
- summary=_("Get Chat captcha"),
- description=_("Get Chat captcha"),
- operation_id=_("Get Chat captcha"), # type: ignore
- tags=[_("Chat")], # type: ignore
- responses=CaptchaAPI.get_response())
- def get(self, request: Request):
- username = request.query_params.get('username', None)
- accessToken = request.query_params.get('accessToken', None)
- return result.success(CaptchaSerializer().chat_generate(username, 'chat', accessToken))
-
-
-class SpeechToText(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['POST'],
- description=_("speech to text"),
- summary=_("speech to text"),
- operation_id=_("speech to text"), # type: ignore
- request=SpeechToTextAPI.get_request(),
- responses=SpeechToTextAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def post(self, request: Request):
- return result.success(
- SpeechToTextSerializers(
- data={'application_id': request.auth.application_id})
- .speech_to_text({'file': request.FILES.get('file')}))
-
-
-class TextToSpeech(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['POST'],
- description=_("text to speech"),
- summary=_("text to speech"),
- operation_id=_("text to speech"), # type: ignore
- request=TextToSpeechAPI.get_request(),
- responses=TextToSpeechAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def post(self, request: Request):
- byte_data = TextToSpeechSerializers(
- data={'application_id': request.auth.application_id}).text_to_speech(request.data)
- return HttpResponse(byte_data, status=200, headers={'Content-Type': 'audio/mp3',
- 'Content-Disposition': 'attachment; filename="abc.mp3"'})
-
-
-class UploadFile(APIView):
- authentication_classes = [ChatTokenAuth]
- parser_classes = [MultiPartParser]
-
- @extend_schema(
- methods=['POST'],
- description=_("Upload files"),
- summary=_("Upload files"),
- operation_id=_("Upload files"), # type: ignore
- request=TextToSpeechAPI.get_request(),
- responses=TextToSpeechAPI.get_response(),
- tags=[_('Application')] # type: ignore
- )
- def post(self, request: Request, chat_id: str):
- files = request.FILES.getlist('file')
- file_ids = []
- meta = {}
- for file in files:
- file_url = FileSerializer(
- data={'file': file, 'meta': meta, 'source_id': chat_id, 'source_type': FileSourceType.CHAT, }).upload()
- file_ids.append({'name': file.name, 'url': file_url, 'file_id': file_url.split('/')[-1]})
- return result.success(file_ids)
diff --git a/apps/chat/views/chat_record.py b/apps/chat/views/chat_record.py
deleted file mode 100644
index c50d95b6437..00000000000
--- a/apps/chat/views/chat_record.py
+++ /dev/null
@@ -1,200 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: chat_record.py
- @date:2025/6/23 10:42
- @desc:
-"""
-from django.utils.translation import gettext_lazy as _
-from drf_spectacular.utils import extend_schema
-from rest_framework.request import Request
-from rest_framework.views import APIView
-
-from application.serializers.application_chat_record import ChatRecordOperateSerializer
-from chat.api.chat_api import HistoricalConversationAPI, PageHistoricalConversationAPI, \
- PageHistoricalConversationRecordAPI, HistoricalConversationRecordAPI, HistoricalConversationOperateAPI
-from chat.api.vote_api import VoteAPI
-from chat.serializers.chat_record import VoteSerializer, HistoricalConversationSerializer, \
- HistoricalConversationRecordSerializer, HistoricalConversationOperateSerializer
-from common import result
-from common.auth import ChatTokenAuth
-
-
-class VoteView(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['PUT'],
- description=_("Like, Dislike"),
- summary=_("Like, Dislike"),
- operation_id=_("Like, Dislike"), # type: ignore
- parameters=VoteAPI.get_parameters(),
- request=VoteAPI.get_request(),
- responses=VoteAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def put(self, request: Request, chat_id: str, chat_record_id: str):
- return result.success(VoteSerializer(
- data={'chat_id': chat_id,
- 'chat_record_id': chat_record_id
- }).vote(request.data))
-
-
-class HistoricalConversationView(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['GET'],
- description=_("Get historical conversation"),
- summary=_("Get historical conversation"),
- operation_id=_("Get historical conversation"), # type: ignore
- parameters=HistoricalConversationAPI.get_parameters(),
- responses=HistoricalConversationAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def get(self, request: Request):
- return result.success(HistoricalConversationSerializer(
- data={
- 'application_id': request.auth.application_id,
- 'chat_user_id': request.auth.chat_user_id,
- }).list())
-
- class Operate(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['PUT'],
- description=_("Modify conversation about"),
- summary=_("Modify conversation about"),
- operation_id=_("Modify conversation about"), # type: ignore
- parameters=HistoricalConversationOperateAPI.get_parameters(),
- request=HistoricalConversationOperateAPI.get_request(),
- responses=HistoricalConversationOperateAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def put(self, request: Request, chat_id: str):
- return result.success(HistoricalConversationOperateSerializer(
- data={
- 'application_id': request.auth.application_id,
- 'chat_user_id': request.auth.chat_user_id,
- 'chat_id': chat_id,
- }).edit_abstract(request.data)
- )
-
- @extend_schema(
- methods=['DELETE'],
- description=_("Delete history conversation"),
- summary=_("Delete history conversation"),
- operation_id=_("Delete history conversation"), # type: ignore
- parameters=HistoricalConversationOperateAPI.get_parameters(),
- responses=HistoricalConversationOperateAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def delete(self, request: Request, chat_id: str):
- return result.success(HistoricalConversationOperateSerializer(
- data={
- 'application_id': request.auth.application_id,
- 'chat_user_id': request.auth.chat_user_id,
- 'chat_id': chat_id,
- }).logic_delete())
-
- class BatchDelete(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['DELETE'],
- description=_("Batch delete history conversation"),
- summary=_("Batch delete history conversation"),
- operation_id=_("Batch delete history conversation"), # type: ignore
- parameters=HistoricalConversationOperateAPI.get_parameters(),
- responses=HistoricalConversationOperateAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def delete(self, request: Request):
- return result.success(HistoricalConversationOperateSerializer.Clear(data={
- 'application_id': request.auth.application_id,
- 'chat_user_id': request.auth.chat_user_id,
- }).batch_logic_delete())
-
- class PageView(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['GET'],
- description=_("Get historical conversation by page"),
- summary=_("Get historical conversation by page"),
- operation_id=_("Get historical conversation by page"), # type: ignore
- parameters=PageHistoricalConversationAPI.get_parameters(),
- responses=PageHistoricalConversationAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def get(self, request: Request, current_page: int, page_size: int):
- return result.success(HistoricalConversationSerializer(
- data={
- 'application_id': request.auth.application_id,
- 'chat_user_id': request.auth.chat_user_id,
- }).page(current_page, page_size))
-
-
-class HistoricalConversationRecordView(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['GET'],
- description=_("Get historical conversation records"),
- summary=_("Get historical conversation records"),
- operation_id=_("Get historical conversation records"), # type: ignore
- parameters=HistoricalConversationRecordAPI.get_parameters(),
- responses=HistoricalConversationRecordAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def get(self, request: Request, chat_id: str):
- return result.success(HistoricalConversationRecordSerializer(
- data={
- 'chat_id': chat_id,
- 'application_id': request.auth.application_id,
- 'chat_user_id': request.auth.chat_user_id,
- }).list())
-
- class PageView(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['GET'],
- description=_("Get historical conversation records by page "),
- summary=_("Get historical conversation records by page"),
- operation_id=_("Get historical conversation records by page"), # type: ignore
- parameters=PageHistoricalConversationRecordAPI.get_parameters(),
- responses=PageHistoricalConversationRecordAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def get(self, request: Request, chat_id: str, current_page: int, page_size: int):
- return result.success(HistoricalConversationRecordSerializer(
- data={
- 'chat_id': chat_id,
- 'application_id': request.auth.application_id,
- 'chat_user_id': request.auth.chat_user_id,
- }).page(current_page, page_size))
-
-
-class ChatRecordView(APIView):
- authentication_classes = [ChatTokenAuth]
-
- @extend_schema(
- methods=['GET'],
- description=_("Get conversation details"),
- summary=_("Get conversation details"),
- operation_id=_("Get conversation details"), # type: ignore
- parameters=PageHistoricalConversationRecordAPI.get_parameters(),
- responses=PageHistoricalConversationRecordAPI.get_response(),
- tags=[_('Chat')] # type: ignore
- )
- def get(self, request: Request, chat_id: str, chat_record_id: str):
- return result.success(ChatRecordOperateSerializer(
- data={
- 'chat_id': chat_id,
- 'chat_record_id': chat_record_id,
- 'application_id': request.auth.application_id,
- 'chat_user_id': request.auth.chat_user_id,
- }).one(False))
diff --git a/apps/chat/views/v2/__init__.py b/apps/chat/views/v2/__init__.py
new file mode 100644
index 00000000000..4cedec9c1d3
--- /dev/null
+++ b/apps/chat/views/v2/__init__.py
@@ -0,0 +1,12 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎
+ @file: __init__.py.py
+ @date:2025/5/29 16:08
+ @desc:
+"""
+from .chat_embed import *
+from .chat import *
+from .chat_record import *
+from .mcp import mcp_view
diff --git a/apps/chat/views/v2/chat.py b/apps/chat/views/v2/chat.py
new file mode 100644
index 00000000000..df7add2982f
--- /dev/null
+++ b/apps/chat/views/v2/chat.py
@@ -0,0 +1,496 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎
+@file: chat.py
+@date:2025/6/6 11:18
+@desc:
+"""
+
+import json
+
+import requests
+from django.core.cache import cache
+from django.http import HttpResponse, StreamingHttpResponse
+from django.utils.translation import gettext_lazy as _
+from drf_spectacular.utils import extend_schema
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter
+from rest_framework.parsers import MultiPartParser
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from application.api.application_api import SpeechToTextAPI, TextToSpeechAPI
+from application.models import ChatUserType, ChatSourceChoices
+from chat.api.chat_api import ChatAPI
+from chat.api.chat_authentication_api import (
+ ChatAuthenticationAPI,
+ ChatAuthenticationProfileAPIV2,
+ ChatOpenAPI,
+ OpenAIAPI,
+)
+from chat.serializers.chat import (
+ ChatSerializers,
+ OpenAIChatSerializer,
+ OpenChatSerializers,
+ SpeechToTextSerializers,
+ TextToSpeechSerializers,
+)
+from chat.serializers.chat_authentication import (
+ AnonymousAuthenticationV2Serializer,
+ ApplicationProfileSerializer,
+ AuthProfileV2Serializer,
+)
+from common.auth import ChatTokenAuth
+from common.auth.authentication import has_permissions
+from common.auth.constants.chat_permission_constants import ChatPermissionConstants
+from common.constants.authentication_type import AuthenticationType
+from common.constants.cache_version import Cache_Version
+from common.exception.app_exception import AppAuthenticationFailed, AppApiException
+from common.log.log import _get_ip_address, log
+from common.result import result
+from common.utils.rsa_util import decrypt
+from knowledge.models import FileSourceType
+from maxkb.const import CONFIG
+from models_provider.api.model import DefaultModelResponse
+from oss.serializers.file import FileSerializer
+from system_manage.serializers.chat_user import RePasswordSerializer, ChatUserProfileSerializer
+from system_manage.serializers.chat_user_serializer import ChatUserAccessTokenSerializer
+from users.api import CaptchaAPI, LoginAPI
+from users.api.user import ResetPasswordAPI, UserProfileAPI
+from users.serializers.login import CaptchaSerializer
+from users.views import get_re_password_details
+
+
+def stream_image(response):
+ """生成器函数,用于流式传输图片数据"""
+ for chunk in response.iter_content(chunk_size=4096):
+ if chunk: # 过滤掉保持连接的空块
+ yield chunk
+
+
+class ResourceProxy(APIView):
+ def get(self, request: Request):
+ image_url = request.query_params.get("url")
+ if not image_url:
+ return result.error("Missing 'url' parameter")
+ try:
+ # 发送GET请求,流式获取图片内容
+ response = requests.get(
+ image_url,
+ stream=True, # 启用流式响应
+ allow_redirects=True,
+ timeout=10,
+ )
+ content_type = response.headers.get("Content-Type", "").split(";")[0]
+ # 创建Django流式响应
+ django_response = StreamingHttpResponse(
+ stream_image(response), # 使用生成器
+ content_type=content_type,
+ )
+
+ return django_response
+ except Exception as e:
+ return result.error(f"Image request failed: {str(e)}")
+
+
+class OpenAIView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("OpenAI Interface Dialogue"),
+ summary=_("OpenAI Interface Dialogue"),
+ operation_id=_("OpenAI Interface Dialogue"), # type: ignore
+ request=OpenAIAPI.get_request(),
+ responses=None,
+ tags=[_("Chat")], # type: ignore
+ )
+ def post(self, request: Request, application_id: str):
+ ip_address = _get_ip_address(request)
+ if application_id != str(request.user.kwargs.get("application_id")):
+ raise AppAuthenticationFailed(500, _("Secret key is invalid"))
+ return OpenAIChatSerializer(
+ data={
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ "chat_user_type": request.user.type,
+ "ip_address": ip_address,
+ "source": {"type": ChatSourceChoices.API_CALL.value},
+ }
+ ).chat(request.data)
+
+
+class AnonymousAuthentication(APIView):
+ def options(self, request, *args, **kwargs):
+ return HttpResponse(
+ headers={
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Methods": "POST",
+ "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token",
+ },
+ )
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("Application Anonymous Certification"),
+ summary=_("Application Anonymous Certification"),
+ operation_id=_("Application Anonymous Certification"), # type: ignore
+ request=AnonymousAuthenticationV2Serializer,
+ responses=None,
+ tags=[_("Chat")], # type: ignore
+ )
+ def post(self, request: Request):
+ token, f_token = AnonymousAuthenticationV2Serializer(data=request.data).auth(request)
+ response = result.success(
+ token,
+ headers={
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Methods": "POST",
+ "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token",
+ },
+ )
+ is_https = request.scheme == "https"
+
+ response.set_cookie(
+ key="mk_file_auth",
+ value=f_token,
+ max_age=7 * 24 * 3600,
+ path=f"{CONFIG.get_chat_path()}/{request.data.get('access_token')}",
+ secure=is_https,
+ httponly=True,
+ samesite="None" if is_https else "Lax",
+ )
+ return response
+
+
+class ApplicationProfile(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get application related information"),
+ summary=_("Get application related information"),
+ operation_id=_("Get application related information"), # type: ignore
+ request=None,
+ responses=None,
+ tags=[_("Chat")], # type: ignore
+ )
+ def get(self, request: Request):
+ return result.success(
+ ApplicationProfileSerializer(data={"application_id": request.user.kwargs.get("application_id")}).profile()
+ )
+
+
+class AuthProfile(APIView):
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get application authentication information"),
+ summary=_("Get application authentication information"),
+ operation_id=_("Get application authentication information"), # type: ignore
+ parameters=ChatAuthenticationProfileAPIV2.get_parameters(),
+ responses=None,
+ tags=[_("Chat")], # type: ignore
+ )
+ def get(self, request: Request):
+ return result.success(
+ AuthProfileV2Serializer(data={"access_token": request.query_params.get("access_token")}).profile()
+ )
+
+
+class ChatView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("dialogue"),
+ summary=_("dialogue"),
+ operation_id=_("dialogue"), # type: ignore
+ request=ChatAPI.get_request(),
+ parameters=ChatAPI.get_parameters(),
+ responses=None,
+ tags=[_("Chat")], # type: ignore
+ )
+ def post(self, request: Request, chat_id: str):
+ ip_address = _get_ip_address(request)
+ return ChatSerializers(
+ data={
+ "chat_id": chat_id,
+ "chat_user_id": request.user.id,
+ "chat_user_type": request.user.type,
+ "application_id": request.user.kwargs.get("application_id"),
+ "debug": False,
+ "ip_address": ip_address,
+ "source": {
+ "type": ChatSourceChoices.API_CALL.value
+ if request.user.type == ChatUserType.APPLICATION_API_KEY.value
+ else ChatSourceChoices.ONLINE.value
+ },
+ }
+ ).chat(request.data)
+
+
+class OpenView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get the session id according to the application id"),
+ summary=_("Get the session id according to the application id"),
+ operation_id=_("Get the session id according to the application id"), # type: ignore
+ parameters=ChatOpenAPI.get_parameters(),
+ responses=None,
+ tags=[_("Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def get(self, request: Request):
+ ip_address = _get_ip_address(request)
+ return result.success(
+ OpenChatSerializers(
+ data={
+ "application_id": request.user.kwargs.get("application_id"),
+ "chat_user_id": request.user.id,
+ "chat_user_type": request.user.type,
+ "ip_address": ip_address,
+ "source": {
+ "type": ChatSourceChoices.API_CALL.value
+ if request.user.type == ChatUserType.APPLICATION_API_KEY.value
+ else ChatSourceChoices.ONLINE.value
+ },
+ "debug": False,
+ }
+ ).open()
+ )
+
+
+class CancelWorkflowView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("Cancel running workflow"),
+ summary=_("Cancel running workflow"),
+ operation_id=_("Cancel running workflow"), # type: ignore
+ parameters=[
+ OpenApiParameter(
+ name="chat_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, description=_("Chat ID")
+ ),
+ ],
+ responses=None,
+ tags=[_("Chat")], # type: ignore
+ )
+ def post(self, request: Request, chat_id: str):
+ from application.workflow.workflow_run_registry import WorkflowRunRegistry, CancelResult
+
+ result_enum = WorkflowRunRegistry.cancel_by_chat_id(chat_id)
+ if result_enum == CancelResult.CANCELLED:
+ return result.success({"status": "cancelled", "chat_id": chat_id})
+ elif result_enum == CancelResult.NOT_FOUND:
+ return result.success({"status": "not_found", "chat_id": chat_id})
+ else:
+ return result.fail(500, _("Failed to cancel workflow"))
+
+
+class CaptchaView(APIView):
+ @extend_schema(
+ methods=["GET"],
+ summary=_("Get Chat captcha"),
+ description=_("Get Chat captcha"),
+ operation_id=_("Get Chat captcha"), # type: ignore
+ tags=[_("Chat")], # type: ignore
+ responses=CaptchaAPI.get_response(),
+ )
+ def get(self, request: Request):
+ username = request.query_params.get("username", None)
+ accessToken = request.query_params.get("accessToken", None)
+ return result.success(CaptchaSerializer().chat_generate(username, "chat", accessToken))
+
+
+class SpeechToText(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("speech to text"),
+ summary=_("speech to text"),
+ operation_id=_("speech to text"), # type: ignore
+ request=SpeechToTextAPI.get_request(),
+ responses=SpeechToTextAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def post(self, request: Request):
+ return result.success(
+ SpeechToTextSerializers(data={"application_id": request.user.kwargs.get("application_id")}).speech_to_text(
+ {"file": request.FILES.get("file")}
+ )
+ )
+
+
+class TextToSpeech(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("text to speech"),
+ summary=_("text to speech"),
+ operation_id=_("text to speech"), # type: ignore
+ request=TextToSpeechAPI.get_request(),
+ responses=TextToSpeechAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def post(self, request: Request):
+ byte_data = TextToSpeechSerializers(
+ data={"application_id": request.user.kwargs.get("application_id")}
+ ).text_to_speech(request.data)
+ return HttpResponse(
+ byte_data,
+ status=200,
+ headers={"Content-Type": "audio/mp3", "Content-Disposition": 'attachment; filename="abc.mp3"'},
+ )
+
+
+class UploadFile(APIView):
+ authentication_classes = [ChatTokenAuth]
+ parser_classes = [MultiPartParser]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("Upload files"),
+ summary=_("Upload files"),
+ operation_id=_("Upload files"), # type: ignore
+ request=TextToSpeechAPI.get_request(),
+ responses=TextToSpeechAPI.get_response(),
+ tags=[_("Application")], # type: ignore
+ )
+ def post(self, request: Request, chat_id: str):
+ files = request.FILES.getlist("file")
+ file_ids = []
+ meta = {}
+ for file in files:
+ file_url = FileSerializer(
+ data={
+ "file": file,
+ "meta": meta,
+ "source_id": chat_id,
+ "source_type": FileSourceType.CHAT,
+ }
+ ).upload(request.user.id)
+ file_ids.append({"name": file.name, "url": file_url, "file_id": file_url.split("/")[-1]})
+ return result.success(file_ids)
+
+
+class ResetCurrentUserPasswordView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ summary=_("Modify current user password"),
+ description=_("Modify current user password"),
+ operation_id=_("Modify current user password"), # type: ignore
+ tags=[_("Chat User")], # type: ignore
+ request=ResetPasswordAPI.get_request(),
+ responses=DefaultModelResponse.get_response(),
+ )
+ @log(
+ menu="Chat User",
+ operate="Modify current user password",
+ get_operation_object=lambda r, k: {"name": r.user.username},
+ get_details=get_re_password_details,
+ )
+ def post(self, request: Request):
+ request_data = request.data
+ encrypted_data = request_data.get("encryptedData", "")
+ if encrypted_data:
+ try:
+ decrypted_raw = decrypt(encrypted_data)
+ # decrypt 可能返回非 JSON 字符串,防护解析异常
+ decrypted_data = json.loads(decrypted_raw) if decrypted_raw else {}
+ if isinstance(decrypted_data, dict):
+ request_data = decrypted_data
+ except Exception as e:
+ raise AppApiException(500, _("Invalid encrypted data"))
+ serializer_obj = RePasswordSerializer(data=request_data)
+ if serializer_obj.reset_password(request.user.id):
+ version, get_key = Cache_Version.CHAT_USER_TOKEN.value
+ auth = request.META.get("HTTP_AUTHORIZATION")
+ cache.delete(get_key(token=auth), version=version)
+ return result.success(True)
+ return result.error(_("Failed to change password"))
+
+
+class ChatUserProfileView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ summary=_("Get current user information"),
+ description=_("Get current user information"),
+ operation_id=_("Get current user information"), # type: ignore
+ tags=[_("Chat User")], # type: ignore
+ responses=UserProfileAPI.get_response(),
+ )
+ def get(self, request: Request):
+ return result.success(ChatUserProfileSerializer().profile(request.user))
+
+
+class BaseAuthView(APIView):
+ @staticmethod
+ def create_token_and_cache(access_token, user, request):
+ token = ChatUserAccessTokenSerializer.create_token_and_cache(access_token, user, request)
+ version, get_key = Cache_Version.CHAT_USER_TOKEN.value
+ cache.set(get_key(token), user, timeout=60 * 60 * 2, version=version)
+ return token
+
+ @classmethod
+ def generate(self, request, f_token: str, response: HttpResponse, path: str = "/chat"):
+ secure = request.is_secure()
+ response.set_cookie(
+ "mk_file_auth",
+ value=f_token,
+ max_age=7 * 24 * 3600,
+ path=path,
+ domain=None,
+ secure=secure,
+ httponly=True,
+ samesite="Lax",
+ )
+ return response
+
+
+class LocalLoginView(BaseAuthView):
+ @extend_schema(
+ methods=["POST"],
+ description=_("Log in"),
+ summary=_("Log in"),
+ operation_id=_("Log in"), # type: ignore
+ tags=[_("Chat User/login")], # type: ignore
+ request=LoginAPI.get_request(),
+ responses=LoginAPI.get_response(),
+ )
+ def post(self, request: Request, access_token: str = None):
+ user = ChatUserAccessTokenSerializer.local_login(request.data, access_token)
+ user.source = "LOCAL"
+ token = self.create_token_and_cache(access_token, user, request)
+ response = result.success({"token": token})
+ return self.generate(request, token, response, path=f"/chat/{access_token}/")
+
+
+class Logout(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ summary=_("Sign out"),
+ description=_("Sign out"),
+ operation_id=_("Sign out"), # type: ignore
+ tags=[_("Chat User")], # type: ignore
+ responses=DefaultModelResponse.get_response(),
+ )
+ @log(menu="Chat User/logout", operate="Sign out", get_operation_object=lambda r, k: {"name": r.user.username})
+ def post(self, request: Request):
+ version, get_key = Cache_Version.CHAT_USER_TOKEN.value
+ auth = request.META.get("HTTP_AUTHORIZATION")
+ cache.delete(get_key(token=auth[7:]), version=version)
+ return result.success(True)
diff --git a/apps/chat/views/chat_embed.py b/apps/chat/views/v2/chat_embed.py
similarity index 100%
rename from apps/chat/views/chat_embed.py
rename to apps/chat/views/v2/chat_embed.py
diff --git a/apps/chat/views/v2/chat_record.py b/apps/chat/views/v2/chat_record.py
new file mode 100644
index 00000000000..38456f977ef
--- /dev/null
+++ b/apps/chat/views/v2/chat_record.py
@@ -0,0 +1,239 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎
+@file: chat_record.py
+@date:2025/6/23 10:42
+@desc:
+"""
+
+from django.utils.translation import gettext_lazy as _
+from drf_spectacular.utils import extend_schema
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from application.serializers.application_chat_record import ChatRecordOperateSerializer
+from chat.api.chat_api import (
+ HistoricalConversationAPI,
+ PageHistoricalConversationAPI,
+ PageHistoricalConversationRecordAPI,
+ HistoricalConversationRecordAPI,
+ HistoricalConversationOperateAPI,
+)
+from chat.api.vote_api import VoteAPI
+from chat.serializers.chat_record import (
+ VoteSerializer,
+ HistoricalConversationSerializer,
+ HistoricalConversationRecordSerializer,
+ HistoricalConversationOperateSerializer,
+)
+from common import result
+from common.auth import ChatTokenAuth
+
+
+class VoteView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["PUT"],
+ description=_("Like, Dislike"),
+ summary=_("Like, Dislike"),
+ operation_id=_("Like, Dislike"), # type: ignore
+ parameters=VoteAPI.get_parameters(),
+ request=VoteAPI.get_request(),
+ responses=VoteAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def put(self, request: Request, chat_id: str, chat_record_id: str):
+ return result.success(
+ VoteSerializer(
+ data={
+ "application_id": request.user.kwargs.get("application_id"),
+ "chat_id": chat_id,
+ "chat_record_id": chat_record_id,
+ }
+ ).vote(request.data)
+ )
+
+
+class HistoricalConversationView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get historical conversation"),
+ summary=_("Get historical conversation"),
+ operation_id=_("Get historical conversation"), # type: ignore
+ parameters=HistoricalConversationAPI.get_parameters(),
+ responses=HistoricalConversationAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def get(self, request: Request):
+ return result.success(
+ HistoricalConversationSerializer(
+ data={
+ "application_id": request.user.kwargs.get("application_id"),
+ "chat_user_id": request.user.id,
+ }
+ ).list()
+ )
+
+ class Operate(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["PUT"],
+ description=_("Modify conversation about"),
+ summary=_("Modify conversation about"),
+ operation_id=_("Modify conversation about"), # type: ignore
+ parameters=HistoricalConversationOperateAPI.get_parameters(),
+ request=HistoricalConversationOperateAPI.get_request(),
+ responses=HistoricalConversationOperateAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def put(self, request: Request, chat_id: str):
+ return result.success(
+ HistoricalConversationOperateSerializer(
+ data={
+ "application_id": request.user.kwargs.get("application_id"),
+ "chat_user_id": request.user.id,
+ "chat_id": chat_id,
+ }
+ ).edit_abstract(request.data)
+ )
+
+ @extend_schema(
+ methods=["DELETE"],
+ description=_("Delete history conversation"),
+ summary=_("Delete history conversation"),
+ operation_id=_("Delete history conversation"), # type: ignore
+ parameters=HistoricalConversationOperateAPI.get_parameters(),
+ responses=HistoricalConversationOperateAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def delete(self, request: Request, chat_id: str):
+ return result.success(
+ HistoricalConversationOperateSerializer(
+ data={
+ "application_id": request.user.kwargs.get("application_id"),
+ "chat_user_id": request.user.id,
+ "chat_id": chat_id,
+ }
+ ).logic_delete()
+ )
+
+ class BatchDelete(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["DELETE"],
+ description=_("Batch delete history conversation"),
+ summary=_("Batch delete history conversation"),
+ operation_id=_("Batch delete history conversation"), # type: ignore
+ parameters=[],
+ responses=HistoricalConversationOperateAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def delete(self, request: Request):
+ return result.success(
+ HistoricalConversationOperateSerializer.Clear(
+ data={
+ "application_id": request.user.kwargs.get("application_id"),
+ "chat_user_id": request.user.id,
+ }
+ ).batch_logic_delete()
+ )
+
+ class PageView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get historical conversation by page"),
+ summary=_("Get historical conversation by page"),
+ operation_id=_("Get historical conversation by page"), # type: ignore
+ parameters=PageHistoricalConversationAPI.get_parameters(),
+ responses=PageHistoricalConversationAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def get(self, request: Request, current_page: int, page_size: int):
+ return result.success(
+ HistoricalConversationSerializer(
+ data={
+ "application_id": request.user.kwargs.get("application_id"),
+ "chat_user_id": request.user.id,
+ }
+ ).page(current_page, page_size)
+ )
+
+
+class HistoricalConversationRecordView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get historical conversation records"),
+ summary=_("Get historical conversation records"),
+ operation_id=_("Get historical conversation records"), # type: ignore
+ parameters=HistoricalConversationRecordAPI.get_parameters(),
+ responses=HistoricalConversationRecordAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def get(self, request: Request, chat_id: str):
+ return result.success(
+ HistoricalConversationRecordSerializer(
+ data={
+ "chat_id": chat_id,
+ "application_id": request.user.kwargs.get("application_id"),
+ "chat_user_id": request.user.id,
+ }
+ ).list()
+ )
+
+ class PageView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get historical conversation records by page "),
+ summary=_("Get historical conversation records by page"),
+ operation_id=_("Get historical conversation records by page"), # type: ignore
+ parameters=PageHistoricalConversationRecordAPI.get_parameters(),
+ responses=PageHistoricalConversationRecordAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def get(self, request: Request, chat_id: str, current_page: int, page_size: int):
+ return result.success(
+ HistoricalConversationRecordSerializer(
+ data={
+ "chat_id": chat_id,
+ "application_id": request.user.kwargs.get("application_id"),
+ "chat_user_id": request.user.id,
+ }
+ ).page(current_page, page_size)
+ )
+
+
+class ChatRecordView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get conversation details"),
+ summary=_("Get conversation details"),
+ operation_id=_("Get conversation details"), # type: ignore
+ parameters=PageHistoricalConversationRecordAPI.get_parameters(),
+ responses=PageHistoricalConversationRecordAPI.get_response(),
+ tags=[_("Chat")], # type: ignore
+ )
+ def get(self, request: Request, chat_id: str, chat_record_id: str):
+ return result.success(
+ ChatRecordOperateSerializer(
+ data={
+ "chat_id": chat_id,
+ "chat_record_id": chat_record_id,
+ "application_id": request.user.kwargs.get("application_id"),
+ "chat_user_id": request.user.id,
+ }
+ ).one(False)
+ )
diff --git a/apps/chat/views/v2/mcp.py b/apps/chat/views/v2/mcp.py
new file mode 100644
index 00000000000..f4733a8c050
--- /dev/null
+++ b/apps/chat/views/v2/mcp.py
@@ -0,0 +1,53 @@
+import json
+
+from django.http import HttpResponse, JsonResponse
+from django.views.decorators.csrf import csrf_exempt
+
+from chat.mcp.tools import MCPToolHandler
+
+
+@csrf_exempt
+def mcp_view(request):
+ request_id = None
+ try:
+ data = json.loads(request.body)
+ method = data.get("method")
+ params = data.get("params", {})
+ request_id = data.get("id")
+
+ if request_id is None:
+ return HttpResponse(status=204)
+
+ auth_header = request.headers.get("Authorization", "").replace("Bearer ", "")
+ handler = MCPToolHandler(
+ auth_header,
+ request.headers.get("X-MaxKB-Chat-Files", ""),
+ request.headers.get("X-MaxKB-Form-Data", ""),
+ )
+
+ # 路由方法
+ if method == "initialize":
+ result = handler.initialize()
+
+ elif method == "tools/list":
+ result = handler.list_tools()
+
+ elif method == "tools/call":
+ result = handler.call_tool(params)
+
+ else:
+ return JsonResponse(
+ {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {"code": -32601, "message": f"Method not found: {method}"},
+ }
+ )
+
+ # 成功响应
+ return JsonResponse({"jsonrpc": "2.0", "id": request_id, "result": result})
+
+ except Exception as e:
+ return JsonResponse(
+ {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32603, "message": f"Internal error: {str(e)}"}}
+ )
diff --git a/apps/chat/views/v3/__init__.py b/apps/chat/views/v3/__init__.py
new file mode 100644
index 00000000000..7220a4c91e4
--- /dev/null
+++ b/apps/chat/views/v3/__init__.py
@@ -0,0 +1,15 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎
+@file: __init__.py
+@date:2025/6/6 11:18
+@desc:
+"""
+
+from .chat_embed import *
+from .chat import *
+from .chat_record import *
+from .chat_user_api_key import *
+from .portal import *
+from .mcp import mcp_view
diff --git a/apps/chat/views/v3/chat.py b/apps/chat/views/v3/chat.py
new file mode 100644
index 00000000000..6a8083ec679
--- /dev/null
+++ b/apps/chat/views/v3/chat.py
@@ -0,0 +1,506 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎
+@file: chat.py
+@date:2025/6/6 11:18
+@desc:
+"""
+
+import json
+
+import requests
+from django.core.cache import cache
+from django.http import HttpResponse, StreamingHttpResponse
+from django.utils.translation import gettext_lazy as _
+from drf_spectacular.utils import extend_schema
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter
+from rest_framework.parsers import MultiPartParser
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from application.api.application_api import SpeechToTextAPI, TextToSpeechAPI
+from application.models import ChatUserType, ChatSourceChoices, ApplicationAccessToken
+from chat.api.chat_api import ChatAPI
+from chat.api.chat_authentication_api import ChatAuthenticationAPI, ChatAuthenticationProfileAPI, ChatOpenAPI, OpenAIAPI
+from chat.serializers.chat import (
+ OpenAIChatSerializer,
+ ChatSerializers,
+ OpenChatSerializers,
+ SpeechToTextSerializers,
+ TextToSpeechSerializers,
+)
+from chat.serializers.chat_authentication import (
+ AnonymousAuthenticationSerializer,
+ ApplicationProfileSerializer,
+ AuthProfileSerializer,
+)
+from common.auth import ChatTokenAuth
+from common.auth.authentication import has_permissions
+from common.auth.common import ChatToken
+from common.auth.constants.chat_permission_constants import ChatPermissionConstants
+from common.auth.constants.operate_constants import Operate
+from common.constants.authentication_type import AuthenticationType
+from common.constants.cache_version import Cache_Version
+from common.exception.app_exception import AppApiException
+from common.log.log import _get_ip_address, log
+from common.result import result
+from common.utils.rsa_util import decrypt
+from knowledge.models import FileSourceType
+from maxkb.const import CONFIG
+from models_provider.api.model import DefaultModelResponse
+from oss.serializers.file import FileSerializer
+from system_manage.serializers.chat_user import RePasswordSerializer, ChatUserProfileSerializer
+from chat.serializers.chat_user_serializer import ChatUserAccessTokenV3Serializer
+from users.api import CaptchaAPI, LoginAPI
+from users.api.user import ResetPasswordAPI, UserProfileAPI
+from users.serializers.login import CaptchaSerializer
+from users.views import get_re_password_details
+
+
+def stream_image(response):
+ """生成器函数,用于流式传输图片数据"""
+ for chunk in response.iter_content(chunk_size=4096):
+ if chunk: # 过滤掉保持连接的空块
+ yield chunk
+
+
+class ResourceProxy(APIView):
+ def get(self, request: Request):
+ image_url = request.query_params.get("url")
+ if not image_url:
+ return result.error("Missing 'url' parameter")
+ try:
+ # 发送GET请求,流式获取图片内容
+ response = requests.get(
+ image_url,
+ stream=True, # 启用流式响应
+ allow_redirects=True,
+ timeout=10,
+ )
+ content_type = response.headers.get("Content-Type", "").split(";")[0]
+ # 创建Django流式响应
+ django_response = StreamingHttpResponse(
+ stream_image(response), # 使用生成器
+ content_type=content_type,
+ )
+
+ return django_response
+ except Exception as e:
+ return result.error(f"Image request failed: {str(e)}")
+
+
+class OpenAIView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("OpenAI Interface Dialogue"),
+ summary=_("OpenAI Interface Dialogue"),
+ operation_id=_("V3 OpenAI Interface Dialogue"), # type: ignore
+ request=OpenAIAPI.get_request(),
+ responses=None,
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ def post(self, request: Request, application_id: str):
+ ip_address = _get_ip_address(request)
+ return OpenAIChatSerializer(
+ data={
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ "chat_user_type": request.user.type,
+ "ip_address": ip_address,
+ "source": {"type": ChatSourceChoices.API_CALL.value},
+ }
+ ).chat(request.data)
+
+
+class AnonymousAuthentication(APIView):
+ def options(self, request, *args, **kwargs):
+ return HttpResponse(
+ headers={
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Methods": "POST",
+ "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token",
+ },
+ )
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("Application Anonymous Certification"),
+ summary=_("Application Anonymous Certification"),
+ operation_id=_("V3 Application Anonymous Certification"), # type: ignore
+ request=ChatAuthenticationAPI.get_request(),
+ parameters=ChatAuthenticationAPI.get_parameters(),
+ responses=None,
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ def post(self, request: Request):
+ serializer = AnonymousAuthenticationSerializer(data=request.query_params)
+ serializer.is_valid(raise_exception=True)
+ token = serializer.auth(request)
+ response = result.success(
+ token,
+ headers={
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Credentials": "true",
+ "Access-Control-Allow-Methods": "POST",
+ "Access-Control-Allow-Headers": "Origin,Content-Type,Cookie,Accept,Token",
+ },
+ )
+ is_https = request.scheme == "https"
+
+ application_id = serializer.validated_data.get("application_id")
+ cookie_path = f"{CONFIG.get_chat_path()}/{application_id}" if application_id else CONFIG.get_chat_path()
+ response.set_cookie(
+ key="mk_file_auth",
+ value=token,
+ max_age=7 * 24 * 3600,
+ path=cookie_path,
+ secure=is_https,
+ httponly=True,
+ samesite="None" if is_https else "Lax",
+ )
+ return response
+
+
+class ApplicationProfile(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get application related information"),
+ summary=_("Get application related information"),
+ operation_id=_("V3 Get application related information"), # type: ignore
+ request=None,
+ responses=None,
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def get(self, request: Request, application_id: str):
+ return result.success(ApplicationProfileSerializer(data={"application_id": application_id}).profile())
+
+
+class AuthProfile(APIView):
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get application authentication information"),
+ summary=_("Get application authentication information"),
+ operation_id=_("V3 Get application authentication information"), # type: ignore
+ parameters=ChatAuthenticationProfileAPI.get_parameters(),
+ responses=None,
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ def get(self, request: Request):
+ return result.success(
+ AuthProfileSerializer(data={"application_id": request.query_params.get("application_id")}).profile()
+ )
+
+
+class ChatView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("dialogue"),
+ summary=_("dialogue"),
+ operation_id=_("V3 dialogue"), # type: ignore
+ request=ChatAPI.get_request(),
+ parameters=ChatAPI.get_parameters(),
+ responses=None,
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def post(self, request: Request, application_id: str, chat_id: str):
+ ip_address = _get_ip_address(request)
+ return ChatSerializers(
+ data={
+ "chat_id": chat_id,
+ "chat_user_id": request.user.id,
+ "chat_user_type": request.user.type,
+ "application_id": application_id,
+ "debug": False,
+ "ip_address": ip_address,
+ "source": {
+ "type": ChatSourceChoices.API_CALL.value
+ if request.user.type == ChatUserType.APPLICATION_API_KEY.value
+ else ChatSourceChoices.ONLINE.value
+ },
+ }
+ ).chat(request.data)
+
+
+class OpenView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get the session id according to the application id"),
+ summary=_("Get the session id according to the application id"),
+ operation_id=_("V3 Get the session id according to the application id"), # type: ignore
+ parameters=ChatOpenAPI.get_parameters(),
+ responses=None,
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def get(self, request: Request, application_id: str):
+ ip_address = _get_ip_address(request)
+ return result.success(
+ OpenChatSerializers(
+ data={
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ "chat_user_type": request.user.type,
+ "ip_address": ip_address,
+ "source": {
+ "type": ChatSourceChoices.API_CALL.value
+ if request.user.type == ChatUserType.APPLICATION_API_KEY.value
+ else ChatSourceChoices.ONLINE.value
+ },
+ "debug": False,
+ }
+ ).open()
+ )
+
+
+class CancelWorkflowView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("Cancel running workflow"),
+ summary=_("Cancel running workflow"),
+ operation_id=_("V3 Cancel running workflow"), # type: ignore
+ parameters=[
+ OpenApiParameter(
+ name="chat_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, description=_("Chat ID")
+ ),
+ ],
+ responses=None,
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ def post(self, request: Request, chat_id: str):
+ from application.workflow.workflow_run_registry import WorkflowRunRegistry, CancelResult
+
+ result_enum = WorkflowRunRegistry.cancel_by_chat_id(chat_id)
+ if result_enum == CancelResult.CANCELLED:
+ return result.success({"status": "cancelled", "chat_id": chat_id})
+ elif result_enum == CancelResult.NOT_FOUND:
+ return result.success({"status": "not_found", "chat_id": chat_id})
+ else:
+ return result.fail(500, _("Failed to cancel workflow"))
+
+
+class CaptchaView(APIView):
+ @extend_schema(
+ methods=["GET"],
+ summary=_("Get Chat captcha"),
+ description=_("Get Chat captcha"),
+ operation_id=_("V3 Get Chat captcha"), # type: ignore
+ tags=[_("V3 Chat")], # type: ignore
+ responses=CaptchaAPI.get_response(),
+ )
+ def get(self, request: Request):
+ username = request.query_params.get("username", None)
+ application_id = request.query_params.get("application_id", None)
+ return result.success(CaptchaSerializer().chat_generate(username, "chat", application_id))
+
+
+class SpeechToText(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("speech to text"),
+ summary=_("speech to text"),
+ operation_id=_("V3 speech to text"), # type: ignore
+ request=SpeechToTextAPI.get_request(),
+ responses=SpeechToTextAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def post(self, request: Request, application_id: str):
+ return result.success(
+ SpeechToTextSerializers(data={"application_id": application_id}).speech_to_text(
+ {"file": request.FILES.get("file")}
+ )
+ )
+
+
+class TextToSpeech(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("text to speech"),
+ summary=_("text to speech"),
+ operation_id=_("V3 text to speech"), # type: ignore
+ request=TextToSpeechAPI.get_request(),
+ responses=TextToSpeechAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def post(self, request: Request, application_id: str):
+ byte_data = TextToSpeechSerializers(data={"application_id": application_id}).text_to_speech(request.data)
+ return HttpResponse(
+ byte_data,
+ status=200,
+ headers={"Content-Type": "audio/mp3", "Content-Disposition": 'attachment; filename="abc.mp3"'},
+ )
+
+
+class UploadFile(APIView):
+ authentication_classes = [ChatTokenAuth]
+ parser_classes = [MultiPartParser]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("Upload files"),
+ summary=_("Upload files"),
+ operation_id=_("V3 Upload files"), # type: ignore
+ request=TextToSpeechAPI.get_request(),
+ responses=TextToSpeechAPI.get_response(),
+ tags=[_("V3 Application")], # type: ignore
+ )
+ def post(self, request: Request, chat_id: str):
+ files = request.FILES.getlist("file")
+ file_ids = []
+ meta = {}
+ for file in files:
+ file_url = FileSerializer(
+ data={
+ "file": file,
+ "meta": meta,
+ "source_id": chat_id,
+ "source_type": FileSourceType.CHAT,
+ }
+ ).upload(request.user.id)
+ file_ids.append({"name": file.name, "url": file_url, "file_id": file_url.split("/")[-1]})
+ return result.success(file_ids)
+
+
+class ResetCurrentUserPasswordView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ summary=_("Modify current user password"),
+ description=_("Modify current user password"),
+ operation_id=_("V3 Modify current user password"), # type: ignore
+ tags=[_("V3 Chat User")], # type: ignore
+ request=ResetPasswordAPI.get_request(),
+ responses=DefaultModelResponse.get_response(),
+ )
+ @log(
+ menu="Chat User",
+ operate="Modify current user password",
+ get_operation_object=lambda r, k: {"name": r.user.username},
+ get_details=get_re_password_details,
+ )
+ def post(self, request: Request):
+ request_data = request.data
+ encrypted_data = request_data.get("encryptedData", "")
+ if encrypted_data:
+ try:
+ decrypted_raw = decrypt(encrypted_data)
+ # decrypt 可能返回非 JSON 字符串,防护解析异常
+ decrypted_data = json.loads(decrypted_raw) if decrypted_raw else {}
+ if isinstance(decrypted_data, dict):
+ request_data = decrypted_data
+ except Exception:
+ raise AppApiException(500, _("Invalid encrypted data"))
+ serializer_obj = RePasswordSerializer(data=request_data)
+ if serializer_obj.reset_password(request.user.id):
+ version, get_key = Cache_Version.CHAT_USER_TOKEN.value
+ auth = request.META.get("HTTP_AUTHORIZATION")
+ cache.delete(get_key(token=auth), version=version)
+ return result.success(True)
+ return result.error(_("Failed to change password"))
+
+
+class ChatUserProfileView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ summary=_("Get current user information"),
+ description=_("Get current user information"),
+ operation_id=_("V3 Get current user information"), # type: ignore
+ tags=[_("V3 Chat User")], # type: ignore
+ responses=UserProfileAPI.get_response(),
+ )
+ def get(self, request: Request):
+ return result.success(ChatUserProfileSerializer().profile(request.user.profile))
+
+
+class BaseAuthView(APIView):
+ @staticmethod
+ def create_token_and_cache(user, access_token, operate):
+ application_id = None
+ if access_token:
+ application_id = (
+ ApplicationAccessToken.objects.filter(access_token=access_token, is_active=True)
+ .values_list("application_id", flat=True)
+ .first()
+ )
+ token = ChatToken(
+ str(user.id), AuthenticationType.CHAT_USER, str(operate), application_id=application_id
+ ).to_token()
+ version, get_key = Cache_Version.CHAT_USER_TOKEN.value
+ cache.set(get_key(token), user, timeout=60 * 60 * 2, version=version)
+ return token
+
+ @classmethod
+ def generate(self, request, token: str, response: HttpResponse, path: str = "/chat"):
+ secure = request.is_secure()
+ response.set_cookie(
+ "mk_file_auth",
+ value=token,
+ max_age=7 * 24 * 3600,
+ path=path,
+ domain=None,
+ secure=secure,
+ httponly=True,
+ samesite="Lax",
+ )
+ return response
+
+
+class LocalLoginView(BaseAuthView):
+ @extend_schema(
+ methods=["POST"],
+ description=_("Log in"),
+ summary=_("Log in"),
+ operation_id=_("V3 Log in"), # type: ignore
+ tags=[_("V3 Chat User/login")], # type: ignore
+ request=LoginAPI.get_request(),
+ responses=LoginAPI.get_response(),
+ )
+ def post(self, request: Request):
+ user = ChatUserAccessTokenV3Serializer.local_login(request.data)
+ user.source = "LOCAL"
+ access_token = request.query_params.get("accessToken")
+ token = self.create_token_and_cache(user, access_token, Operate.LOCAL)
+ response = result.success({"token": token})
+ return self.generate(request, token, response, path=f"/chat/{access_token + '/' if access_token else ''}")
+
+
+class Logout(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ summary=_("Sign out"),
+ description=_("Sign out"),
+ operation_id=_("V3 Sign out"), # type: ignore
+ tags=[_("V3 Chat User")], # type: ignore
+ responses=DefaultModelResponse.get_response(),
+ )
+ @log(menu="Chat User/logout", operate="Sign out", get_operation_object=lambda r, k: {"name": r.user.username})
+ def post(self, request: Request):
+ version, get_key = Cache_Version.CHAT_USER_TOKEN.value
+ auth = request.META.get("HTTP_AUTHORIZATION")
+ cache.delete(get_key(token=auth[7:]), version=version)
+ return result.success(True)
diff --git a/apps/chat/views/v3/chat_embed.py b/apps/chat/views/v3/chat_embed.py
new file mode 100644
index 00000000000..4e5310214b0
--- /dev/null
+++ b/apps/chat/views/v3/chat_embed.py
@@ -0,0 +1,32 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎
+ @file: chat_embed.py
+ @date:2025/5/30 15:22
+ @desc:
+"""
+from django.utils.translation import gettext_lazy as _
+from drf_spectacular.utils import extend_schema
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from chat.api.chat_embed_api import ChatEmbedAPI
+from chat.serializers.chat_embed_serializers import ChatEmbedSerializer
+
+
+class ChatEmbedView(APIView):
+
+ @extend_schema(
+ methods=['GET'],
+ description=_('Get embedded js'),
+ summary=_('Get embedded js'),
+ operation_id=_('V3 Get embedded js'), # type: ignore
+ parameters=ChatEmbedAPI.get_parameters(),
+ responses=ChatEmbedAPI.get_response(),
+ tags=[_('V3 Chat')] # type: ignore
+ )
+ def get(self, request: Request):
+ return ChatEmbedSerializer(
+ data={'protocol': request.query_params.get('protocol'), 'token': request.query_params.get('token'),
+ 'host': request.query_params.get('host'), }).get_embed(params=request.query_params)
diff --git a/apps/chat/views/v3/chat_record.py b/apps/chat/views/v3/chat_record.py
new file mode 100644
index 00000000000..38539992d66
--- /dev/null
+++ b/apps/chat/views/v3/chat_record.py
@@ -0,0 +1,246 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎
+@file: chat_record.py
+@date:2025/6/23 10:42
+@desc: v3 chat record views —— application_id 从 path 获取,用户身份从 request.user(Principal) 获取
+"""
+
+from django.utils.translation import gettext_lazy as _
+from drf_spectacular.utils import extend_schema
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from application.serializers.application_chat_record import ChatRecordOperateSerializer
+from chat.api.chat_api import (
+ HistoricalConversationAPI,
+ PageHistoricalConversationAPI,
+ PageHistoricalConversationRecordAPI,
+ HistoricalConversationRecordAPI,
+ HistoricalConversationOperateAPI,
+)
+from chat.api.vote_api import VoteAPI
+from chat.serializers.chat_record import (
+ VoteSerializer,
+ HistoricalConversationSerializer,
+ HistoricalConversationRecordSerializer,
+ HistoricalConversationOperateSerializer,
+)
+from common import result
+from common.auth import ChatTokenAuth
+from common.auth.authentication import has_permissions
+from common.auth.constants.chat_permission_constants import ChatPermissionConstants
+
+
+class VoteView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["PUT"],
+ description=_("Like, Dislike"),
+ summary=_("Like, Dislike"),
+ operation_id=_("V3 Like, Dislike"), # type: ignore
+ parameters=VoteAPI.get_parameters(),
+ request=VoteAPI.get_request(),
+ responses=VoteAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def put(self, request: Request, application_id: str, chat_id: str, chat_record_id: str):
+ return result.success(
+ VoteSerializer(
+ data={"application_id": application_id, "chat_id": chat_id, "chat_record_id": chat_record_id}
+ ).vote(request.data)
+ )
+
+
+class HistoricalConversationView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get historical conversation"),
+ summary=_("Get historical conversation"),
+ operation_id=_("V3 Get historical conversation"), # type: ignore
+ parameters=HistoricalConversationAPI.get_parameters(),
+ responses=HistoricalConversationAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def get(self, request: Request, application_id: str):
+ return result.success(
+ HistoricalConversationSerializer(
+ data={
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ }
+ ).list()
+ )
+
+ class Operate(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["PUT"],
+ description=_("Modify conversation about"),
+ summary=_("Modify conversation about"),
+ operation_id=_("V3 Modify conversation about"), # type: ignore
+ parameters=HistoricalConversationOperateAPI.get_parameters(),
+ request=HistoricalConversationOperateAPI.get_request(),
+ responses=HistoricalConversationOperateAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def put(self, request: Request, application_id: str, chat_id: str):
+ return result.success(
+ HistoricalConversationOperateSerializer(
+ data={
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ "chat_id": chat_id,
+ }
+ ).edit_abstract(request.data)
+ )
+
+ @extend_schema(
+ methods=["DELETE"],
+ description=_("Delete history conversation"),
+ summary=_("Delete history conversation"),
+ operation_id=_("V3 Delete history conversation"), # type: ignore
+ parameters=HistoricalConversationOperateAPI.get_parameters(),
+ responses=HistoricalConversationOperateAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def delete(self, request: Request, application_id: str, chat_id: str):
+ return result.success(
+ HistoricalConversationOperateSerializer(
+ data={
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ "chat_id": chat_id,
+ }
+ ).logic_delete()
+ )
+
+ class BatchDelete(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["DELETE"],
+ description=_("Batch delete history conversation"),
+ summary=_("Batch delete history conversation"),
+ operation_id=_("V3 Batch delete history conversation"), # type: ignore
+ parameters=[],
+ responses=HistoricalConversationOperateAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def delete(self, request: Request, application_id: str):
+ return result.success(
+ HistoricalConversationOperateSerializer.Clear(
+ data={
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ }
+ ).batch_logic_delete()
+ )
+
+ class PageView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get historical conversation by page"),
+ summary=_("Get historical conversation by page"),
+ operation_id=_("V3 Get historical conversation by page"), # type: ignore
+ parameters=PageHistoricalConversationAPI.get_parameters(),
+ responses=PageHistoricalConversationAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def get(self, request: Request, application_id: str, current_page: int, page_size: int):
+ return result.success(
+ HistoricalConversationSerializer(
+ data={
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ }
+ ).page(current_page, page_size)
+ )
+
+
+class HistoricalConversationRecordView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get historical conversation records"),
+ summary=_("Get historical conversation records"),
+ operation_id=_("V3 Get historical conversation records"), # type: ignore
+ parameters=HistoricalConversationRecordAPI.get_parameters(),
+ responses=HistoricalConversationRecordAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def get(self, request: Request, application_id: str, chat_id: str):
+ return result.success(
+ HistoricalConversationRecordSerializer(
+ data={
+ "chat_id": chat_id,
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ }
+ ).list()
+ )
+
+ class PageView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get historical conversation records by page "),
+ summary=_("Get historical conversation records by page"),
+ operation_id=_("V3 Get historical conversation records by page"), # type: ignore
+ parameters=PageHistoricalConversationRecordAPI.get_parameters(),
+ responses=PageHistoricalConversationRecordAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def get(self, request: Request, application_id: str, chat_id: str, current_page: int, page_size: int):
+ return result.success(
+ HistoricalConversationRecordSerializer(
+ data={
+ "chat_id": chat_id,
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ }
+ ).page(current_page, page_size)
+ )
+
+
+class ChatRecordView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get conversation details"),
+ summary=_("Get conversation details"),
+ operation_id=_("V3 Get conversation details"), # type: ignore
+ parameters=PageHistoricalConversationRecordAPI.get_parameters(),
+ responses=PageHistoricalConversationRecordAPI.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ @has_permissions(ChatPermissionConstants.get_aggregate_permissions())
+ def get(self, request: Request, application_id: str, chat_id: str, chat_record_id: str):
+ return result.success(
+ ChatRecordOperateSerializer(
+ data={
+ "chat_id": chat_id,
+ "chat_record_id": chat_record_id,
+ "application_id": application_id,
+ "chat_user_id": request.user.id,
+ }
+ ).one(False)
+ )
diff --git a/apps/chat/views/v3/chat_user_api_key.py b/apps/chat/views/v3/chat_user_api_key.py
new file mode 100644
index 00000000000..6be48db3dc9
--- /dev/null
+++ b/apps/chat/views/v3/chat_user_api_key.py
@@ -0,0 +1,69 @@
+from common.auth import ChatTokenAuth
+from common.log.log import log
+from common.result import result
+from django.utils.translation import gettext_lazy as _
+from drf_spectacular.utils import extend_schema
+from rest_framework.request import Request
+from rest_framework.views import APIView
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter
+
+from chat.serializers.chat_user_api_key_serializers import ChatUserApiKeySerializer
+
+
+class ChatUserApiKeyView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["POST"],
+ description=_("Create ChatUserAPIKey"),
+ summary=_("Create ChatUserAPIKey"),
+ operation_id="V3 Create ChatUserAPIKey",
+ responses=None,
+ tags=[_("V3 Chat User API Key")], # type: ignore
+ )
+ @log(menu="Chat User API Key", operate="Add chat user API key")
+ def post(self, request: Request):
+ return result.success(ChatUserApiKeySerializer(data={"user_id": request.user.id}).generate())
+
+ class Page(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get ChatUserAPIKey List"),
+ summary=_("Get ChatUserAPIKey List"),
+ operation_id="V3 Get ChatUserAPIKey List",
+ parameters=[
+ OpenApiParameter(name='order_by', type=OpenApiTypes.STR, location=OpenApiParameter.QUERY,
+ description=_('order by'), required=False),
+ ],
+ responses=None,
+ tags=[_("V3 Chat User API Key")], # type: ignore
+ )
+ def get(self, request: Request, current_page, page_size):
+ return result.success(
+ ChatUserApiKeySerializer(
+ data={"user_id": request.user.id, "order_by": request.query_params.get("order_by")}
+ ).page(current_page, page_size)
+ )
+
+ class Operate(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["DELETE"],
+ description=_("Delete ChatUserAPIKey"),
+ summary=_("Delete ChatUserAPIKey"),
+ operation_id="V3 Delete ChatUserAPIKey",
+ responses=None,
+ parameters=None,
+ tags=[_("V3 Chat User API Key")], # type: ignore
+ )
+ @log(menu="Chat User API Key", operate="Delete chat user API key")
+ def delete(self, request: Request, api_key_id: str):
+ return result.success(
+ ChatUserApiKeySerializer.Operate(
+ data={"id": api_key_id, "user_id": request.user.id}
+ ).destroy()
+ )
diff --git a/apps/chat/views/v3/knowledge.py b/apps/chat/views/v3/knowledge.py
new file mode 100644
index 00000000000..34cdade3f4a
--- /dev/null
+++ b/apps/chat/views/v3/knowledge.py
@@ -0,0 +1,115 @@
+"""Public knowledge API/MCP endpoints, following the existing application MCP endpoint."""
+
+import json
+from urllib.parse import urlsplit
+
+from django.http import HttpResponse, JsonResponse
+from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_POST
+from rest_framework.exceptions import ValidationError
+
+from chat.mcp.knowledge import KnowledgeMCPToolHandler, PROTOCOL_VERSIONS
+from knowledge.services.external_retrieval import retrieve
+from knowledge.services.retrieval_access import RetrievalError, authenticate_key, authorize_external
+
+
+def response_headers(response):
+ response["Cache-Control"] = "no-store"
+ response["X-Content-Type-Options"] = "nosniff"
+ if response.status_code == 401:
+ response["WWW-Authenticate"] = "Bearer"
+ return response
+
+
+def read_request(request, knowledge_id):
+ origin = request.headers.get("Origin")
+ if origin:
+ expected = urlsplit(request.build_absolute_uri("/"))
+ if origin != f"{expected.scheme}://{expected.netloc}":
+ raise RetrievalError("invalid_origin", "Origin is not allowed.", 403)
+ identity = authenticate_key(request.headers.get("Authorization"))
+ knowledge = authorize_external(knowledge_id, identity)
+ if request.content_type != "application/json":
+ raise RetrievalError("invalid_content_type", "Use application/json.", 415)
+ if int(request.META.get("CONTENT_LENGTH") or 0) > 65536:
+ raise RetrievalError("request_too_large", "Request body is too large.", 413)
+ body = request.body
+ if len(body) > 65536:
+ raise RetrievalError("request_too_large", "Request body is too large.", 413)
+ return knowledge, identity, json.loads(body)
+
+
+def error_response(error):
+ return response_headers(
+ JsonResponse({"error": {"code": error.code, "message": error.message}}, status=error.status)
+ )
+
+
+@csrf_exempt
+@require_POST
+def retrieve_view(request, knowledge_id):
+ try:
+ knowledge, identity, data = read_request(request, knowledge_id)
+ return response_headers(JsonResponse(retrieve(knowledge.id, identity, data)))
+ except RetrievalError as error:
+ return error_response(error)
+ except (ValueError, UnicodeError, ValidationError):
+ return error_response(RetrievalError("invalid_request", "Invalid retrieval request."))
+ except Exception:
+ return error_response(RetrievalError("retrieval_failed", "Knowledge retrieval failed.", 503))
+
+
+def rpc_response(request_id, result=None, code=None, message=None, status=200):
+ data = {"jsonrpc": "2.0", "id": request_id}
+ data.update({"error": {"code": code, "message": message}} if code is not None else {"result": result})
+ return response_headers(JsonResponse(data, status=status))
+
+
+@csrf_exempt
+@require_POST
+def knowledge_mcp_view(request, knowledge_id):
+ request_id = None
+ try:
+ knowledge, identity, data = read_request(request, knowledge_id)
+ accept = request.headers.get("Accept", "")
+ if not all(value in accept for value in ("application/json", "text/event-stream")):
+ return rpc_response(
+ None, code=-32600, message="Accept must include application/json and text/event-stream.", status=406
+ )
+ if request.headers.get("MCP-Protocol-Version", "2025-03-26") not in PROTOCOL_VERSIONS:
+ return rpc_response(None, code=-32600, message="Unsupported protocol version.", status=400)
+ if not isinstance(data, dict) or data.get("jsonrpc") != "2.0" or not isinstance(data.get("method"), str):
+ return rpc_response(None, code=-32600, message="Invalid Request")
+ request_id = data.get("id")
+ if "id" in data and (isinstance(request_id, bool) or not isinstance(request_id, (str, int))):
+ return rpc_response(None, code=-32600, message="Invalid request ID.")
+ params = data.get("params", {})
+ if not isinstance(params, dict):
+ return (
+ rpc_response(request_id, code=-32602, message="Invalid params.")
+ if "id" in data
+ else HttpResponse(status=400)
+ )
+ if "id" not in data:
+ return response_headers(HttpResponse(status=202))
+ handler = KnowledgeMCPToolHandler(knowledge, identity)
+ method = data["method"]
+ if method == "initialize":
+ output = handler.initialize(params)
+ elif method == "ping":
+ output = {}
+ elif method == "tools/list":
+ output = handler.list_tools()
+ elif method == "tools/call":
+ output = handler.call_tool(params)
+ else:
+ return rpc_response(request_id, code=-32601, message="Method not found.")
+ return rpc_response(request_id, result=output)
+ except RetrievalError as error:
+ return error_response(error)
+ except (ValueError, UnicodeError):
+ return rpc_response(None, code=-32700, message="Parse error.", status=400)
+ except ValidationError:
+ return rpc_response(request_id, code=-32602, message="Invalid params.")
+ except Exception:
+ return rpc_response(request_id, code=-32603, message="Knowledge retrieval failed.", status=503)
diff --git a/apps/chat/views/mcp.py b/apps/chat/views/v3/mcp.py
similarity index 100%
rename from apps/chat/views/mcp.py
rename to apps/chat/views/v3/mcp.py
diff --git a/apps/chat/views/v3/portal.py b/apps/chat/views/v3/portal.py
new file mode 100644
index 00000000000..18a6479ce4b
--- /dev/null
+++ b/apps/chat/views/v3/portal.py
@@ -0,0 +1,63 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:MaxKB
+@file: portal.py
+@date:2026/8/14
+@desc: 门户视图
+"""
+
+from django.utils.translation import gettext_lazy as _
+from drf_spectacular.utils import extend_schema
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from common import result
+from common.auth import ChatTokenAuth
+from common.utils.common import query_params_to_single_dict
+
+from chat.api.portal_api import PortalAPI
+from chat.serializers.portal import (
+ PortalApplicationSerializer,
+ PortalHistoricalConversationSerializer,
+)
+
+
+class PortalApplicationView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get published application list by page"),
+ summary=_("Get published application list by page"),
+ operation_id=_("Get published application list by page"), # type: ignore
+ parameters=PortalAPI.Application.get_parameters(),
+ responses=PortalAPI.Application.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ def get(self, request: Request, current_page: int, page_size: int):
+ return result.success(
+ PortalApplicationSerializer.Query(data={**query_params_to_single_dict(request.query_params)}).page(
+ current_page, page_size, str(request.user.id)
+ )
+ )
+
+
+class PortalHistoricalConversationView(APIView):
+ authentication_classes = [ChatTokenAuth]
+
+ @extend_schema(
+ methods=["GET"],
+ description=_("Get portal historical conversation by page"),
+ summary=_("Get portal historical conversation by page"),
+ operation_id=_("Get portal historical conversation by page"), # type: ignore
+ parameters=PortalAPI.Conversation.get_parameters(),
+ responses=PortalAPI.Conversation.get_response(),
+ tags=[_("V3 Chat")], # type: ignore
+ )
+ def get(self, request: Request, current_page: int, page_size: int):
+ return result.success(
+ PortalHistoricalConversationSerializer.Query(
+ data={**query_params_to_single_dict(request.query_params)}
+ ).page(current_page, page_size, str(request.user.id))
+ )
diff --git a/apps/common/auth/authenticate.py b/apps/common/auth/authenticate.py
index 71ee51bf1f6..9bf27c420aa 100644
--- a/apps/common/auth/authenticate.py
+++ b/apps/common/auth/authenticate.py
@@ -50,9 +50,30 @@ def new_instance_by_class_path(class_path: str):
return HandlerClass()
-handles = [new_instance_by_class_path(class_path) for class_path in settings.AUTH_HANDLES]
-chat_handles = [new_instance_by_class_path(class_path) for class_path in settings.CHAT_AUTH_HANDLES]
-all_handles = handles + chat_handles
+handles = None
+chat_handles = None
+all_handles = None
+
+
+def get_handles():
+ global handles
+ if handles is None:
+ handles = [new_instance_by_class_path(class_path) for class_path in settings.AUTH_HANDLES]
+ return handles
+
+
+def get_chat_handles():
+ global chat_handles
+ if chat_handles is None:
+ chat_handles = [new_instance_by_class_path(class_path) for class_path in settings.CHAT_AUTH_HANDLES]
+ return chat_handles
+
+
+def get_all_handles():
+ global all_handles
+ if all_handles is None:
+ all_handles = get_handles() + get_chat_handles()
+ return all_handles
class TokenDetails:
@@ -85,7 +106,7 @@ def authenticate(self, request):
try:
token = auth[7:]
token_details = TokenDetails(token)
- for handle in handles:
+ for handle in get_handles():
if handle.support(request, token, token_details.get_token_details):
return handle.handle(request, token, token_details.get_token_details)
raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user'))
@@ -111,7 +132,7 @@ def authenticate(self, request):
try:
token = auth[7:]
token_details = TokenDetails(token)
- for handle in chat_handles:
+ for handle in get_chat_handles():
if handle.support(request, token, token_details.get_token_details):
return handle.handle(request, token, token_details.get_token_details)
raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user'))
@@ -137,7 +158,7 @@ def authenticate(self, request):
try:
token = auth[7:]
token_details = TokenDetails(token)
- for handle in all_handles:
+ for handle in get_all_handles():
if handle.support(request, token, token_details.get_token_details):
return handle.handle(request, token, token_details.get_token_details)
raise AppAuthenticationFailed(1002, _('Authentication information is incorrect! illegal user'))
diff --git a/apps/common/auth/authentication.py b/apps/common/auth/authentication.py
index 8ee80f324f5..c3b1c8b5d74 100644
--- a/apps/common/auth/authentication.py
+++ b/apps/common/auth/authentication.py
@@ -1,142 +1,63 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: authentication.py
- @date:2025/4/15 20:12
- @desc:
+@project: MaxKB
+@file: authentication.py
+@desc: 适配层,复用 AggregatePermission
"""
-from typing import List
from django.utils.translation import gettext_lazy as _
-from rest_framework.request import Request
-
-from common.constants.permission_constants import PermissionConstants, RoleConstants, ViewPermission, CompareConstants, \
- Permission, Role
-from common.exception.app_exception import AppUnauthorizedFailed
-
-
-def exist_permissions_by_permission_constants(user_permission: List[PermissionConstants],
- permission_list: List[PermissionConstants]):
- """
- 用户是否拥有 permission_list的权限
- :param user_permission: 用户权限
- :param permission_list: 需要的权限
- :return: 是否拥有
- """
- return any(list(map(lambda up: permission_list.__contains__(up), user_permission)))
-
-
-def exist_role_by_role_constants(user_role: List[RoleConstants],
- role_list: List[RoleConstants]):
- """
- 用户是否拥有这个角色
- :param user_role: 用户角色
- :param role_list: 需要拥有的角色
- :return: 是否拥有
- """
- return any([True for role in role_list if user_role.__contains__(role.value.__str__())])
+from common.auth.constants.compare_constants import CompareConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.aggregate_permission import AggregatePermission
+from common.auth.struct.permission import Role
-def exist_permissions_by_view_permission(user_role: List[RoleConstants],
- user_permission: List[PermissionConstants | object],
- permission: ViewPermission, request, **kwargs):
- """
- 用户是否存在这些权限
- :param request:
- :param user_role: 用户角色
- :param user_permission: 用户权限
- :param permission: 所属权限
- :return: 是否存在 True False
- """
-
- role_list = [user_r(request, kwargs) if callable(user_r) else user_r for user_r in
- permission.roleList]
- role_ok = any(list(map(lambda up: role_list.__contains__(up),
- user_role)))
- permission_list = [user_p(request, kwargs) if callable(user_p) else user_p for user_p in
- permission.permissionList
- ]
- permission_ok = any(list(map(lambda up: permission_list.__contains__(up),
- user_permission)))
- return role_ok | permission_ok if permission.compare == CompareConstants.OR else role_ok & permission_ok
-
-
-def exist_permissions(user_role: List[RoleConstants], user_permission: List[PermissionConstants], permission, request,
- **kwargs):
- if isinstance(permission, ViewPermission):
- return exist_permissions_by_view_permission(user_role, user_permission, permission, request, **kwargs)
- if isinstance(permission, RoleConstants):
- return exist_role_by_role_constants(user_role, [permission])
- if isinstance(permission, PermissionConstants):
- return exist_permissions_by_permission_constants(user_permission, [permission])
- if isinstance(permission, Permission):
- return user_permission.__contains__(permission)
- if isinstance(permission, Role):
- return user_role.__contains__(permission.__str__())
- return False
+from common.exception.app_exception import AppUnauthorizedFailed
-def exist(user_role: List[RoleConstants], user_permission: List[PermissionConstants], permission, request, **kwargs):
- if callable(permission):
- p = permission(request, kwargs)
- return exist_permissions(user_role, user_permission, p, request, **kwargs)
- return exist_permissions(user_role, user_permission, permission, request, **kwargs)
+def _build(items, request, kwargs, compare) -> AggregatePermission:
+ roles, permissions, aggregates = [], [], []
+ for it in items:
+ if callable(it) and not isinstance(it, AggregatePermission):
+ it = it(request, kwargs)
+ if isinstance(it, AggregatePermission):
+ aggregates.append(it)
+ elif isinstance(it, (RoleConstants, Role)):
+ roles.append(it)
+ else:
+ permissions.append(it)
+ return AggregatePermission(roles=roles, permissions=permissions, aggregatePermissions=aggregates, compare=compare)
def get_is_permissions(request, **kwargs):
def is_permissions(*permission, compare=CompareConstants.OR):
- exit_list = list(
- map(lambda p: exist(request.auth.role_list, request.auth.permission_list, p, request, **kwargs),
- permission))
- return any(exit_list) if compare == CompareConstants.OR else all(exit_list)
+ return _build(permission, request, kwargs, compare).hasPermission(request, **kwargs)
return is_permissions
-def check_batch_permissions(request: Request, id_list: List[str], id_key: str, permissions: tuple,
- compare=CompareConstants.OR, **kwargs) -> List[str]:
+def check_batch_permissions(request, id_list, id_key, permissions, compare=CompareConstants.OR, **kwargs):
if not id_list:
return []
-
- # workspace manager 直接放行
- # 预检
- kwargs[id_key] = '__workspace_level_pre_check__'
- pre_check = list(
- map(lambda p: exist(request.auth.role_list, request.auth.permission_list, p, request, **kwargs),
- permissions)
- )
- if any(pre_check) if compare == CompareConstants.OR else all(pre_check):
+ kwargs[id_key] = "__workspace_level_pre_check__"
+ if _build(permissions, request, kwargs, compare).hasPermission(request, **kwargs):
return list(id_list)
- # 逐个资源校验
result_list = []
for resource_id in id_list:
kwargs[id_key] = resource_id
- exit_list = list(
- map(lambda p: exist(request.auth.role_list, request.auth.permission_list, p, request, **kwargs),
- permissions)
- )
- if any(exit_list) if compare == CompareConstants.OR else all(exit_list):
+ if _build(permissions, request, kwargs, compare).hasPermission(request, **kwargs):
result_list.append(resource_id)
return result_list
+
def has_permissions(*permission, compare=CompareConstants.OR):
- """
- 权限 role or permission
- :param compare: 比较符号
- :param permission: 如果是角色 role:roleId
- :return: 权限装饰器函数,用于判断用户是否有权限访问当前接口
- """
+ """接口权限装饰器"""
def inner(func):
def run(view, request, **kwargs):
- exit_list = list(
- map(lambda p: exist(request.auth.role_list, request.auth.permission_list, p, request, **kwargs),
- permission))
- # 判断是否有权限
- if any(exit_list) if compare == CompareConstants.OR else all(exit_list):
+ if _build(permission, request, kwargs, compare).hasPermission(request, **kwargs):
return func(view, request, **kwargs)
- raise AppUnauthorizedFailed(403, _('No permission to access'))
+ raise AppUnauthorizedFailed(403, _("No permission to access"))
return run
diff --git a/apps/common/auth/common.py b/apps/common/auth/common.py
index ad8e0e50a48..98832c885ef 100644
--- a/apps/common/auth/common.py
+++ b/apps/common/auth/common.py
@@ -1,87 +1,63 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: common.py
- @date:2025/6/6 19:55
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: common.py
+@date:2025/6/6 19:55
+@desc:
"""
-import hashlib
-import json
-import threading
-from django.core import signing, cache
+from django.core import signing
-from common.constants.cache_version import Cache_Version
-from common.utils.rsa_util import encrypt, decrypt
+from common.constants.authentication_type import AuthenticationType
+from common.exception.app_exception import AppAuthenticationFailed
-authentication_cache = cache.cache
-lock = threading.Lock()
-
-def _decrypt(authentication: str):
- cache_key = hashlib.sha256(authentication.encode()).hexdigest()
- result = authentication_cache.get(key=cache_key, version=Cache_Version.CHAT.value)
- if result is None:
- with lock:
- result = authentication_cache.get(cache_key, version=Cache_Version.CHAT.value)
- if result is None:
- result = decrypt(authentication)
- authentication_cache.set(cache_key, result, version=Cache_Version.CHAT.value, timeout=60 * 60 * 2)
-
- return result
-
-
-class ChatAuthentication:
- def __init__(self, auth_type: str | None, **kwargs):
- self.auth_type = auth_type
- for k, v in kwargs.items():
- self.__setattr__(k, v)
+class SystemToken:
+ def __init__(self, user_id, _type: AuthenticationType, **kwargs):
+ self.id = user_id
+ self.type = _type
+ self.kwargs = kwargs
def to_dict(self):
- return self.__dict__
+ if self.kwargs:
+ return {"user_id": self.id, "type": str(self.type.value), "kwargs": self.kwargs}
+ return {"id": str(self.id), "type": str(self.type.value)}
- def to_string(self):
- value = json.dumps(self.to_dict())
- authentication = encrypt(value)
- cache_key = hashlib.sha256(authentication.encode()).hexdigest()
- authentication_cache.set(cache_key, value, version=Cache_Version.CHAT.get_version(), timeout=60 * 60 * 2)
- return authentication
-
- @staticmethod
- def new_instance(authentication: str):
- auth = json.loads(_decrypt(authentication))
- return ChatAuthentication(**auth)
+ def to_token(self):
+ return signing.dumps(self.to_dict())
-class ChatUserToken:
- def __init__(self, application_id, user_id, access_token, _type, chat_user_type, chat_user_id,
- authentication: ChatAuthentication):
- self.application_id = application_id
- self.user_id = user_id
- self.access_token = access_token
+class ChatToken:
+ def __init__(self, user_id, _type: AuthenticationType, login_type: str, **kwargs):
+ self.id = user_id
self.type = _type
- self.chat_user_type = chat_user_type
- self.chat_user_id = chat_user_id
- self.authentication = authentication
+ self.login_type = login_type
+ self.kwargs = kwargs
def to_dict(self):
+ if self.kwargs:
+ return {
+ "id": str(self.id),
+ "type": str(self.type.value),
+ "login_type": str(self.login_type),
+ "kwargs": self.kwargs,
+ }
return {
- 'application_id': str(self.application_id),
- 'user_id': str(self.user_id),
- 'access_token': self.access_token,
- 'type': str(self.type.value),
- 'chat_user_type': str(self.chat_user_type),
- 'chat_user_id': str(self.chat_user_id),
- 'authentication': self.authentication.to_string()
+ "id": str(self.id),
+ "type": str(self.type.value),
+ "login_type": str(self.login_type),
}
def to_token(self):
return signing.dumps(self.to_dict())
- @staticmethod
- def new_instance(token_dict):
- return ChatUserToken(token_dict.get('application_id'), token_dict.get('user_id'),
- token_dict.get('access_token'), token_dict.get('type'), token_dict.get('chat_user_type'),
- token_dict.get('chat_user_id'),
- ChatAuthentication.new_instance(token_dict.get('authentication')))
+
+def parse_token(token):
+ details = signing.loads(token)
+ _type = details.get("type")
+ if _type:
+ if _type == AuthenticationType.SYSTEM_USER.value:
+ return SystemToken(details.get("id"), details.get("type"), **details.get("kwargs", {}))
+ return ChatToken(details.get("id"), details.get("type"), details.get("login_type"), **details.get("kwargs", {}))
+ raise AppAuthenticationFailed(1001, "")
diff --git a/apps/common/auth/constants/category_constants.py b/apps/common/auth/constants/category_constants.py
new file mode 100644
index 00000000000..734a2cefb20
--- /dev/null
+++ b/apps/common/auth/constants/category_constants.py
@@ -0,0 +1,36 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: category_constants.py
+ @date:2026/8/3 17:31
+ @desc: 一级目录分类常量(最顶层分类)
+"""
+from enum import Enum
+from django.utils.translation import gettext_lazy as _
+
+
+class Category(Enum):
+ """
+ 一级目录(最顶层分类),用于在 parent_group 之上再加一层分类
+ """
+ # 身份与权限
+ IAM = ("IAM", _("IAM"))
+ # 资源管理
+ RESOURCE = ("RESOURCE", _("Resource"))
+ # 共享资源
+ SHARED = ("SHARED", _("Shared"))
+ # 对话客户端
+ CHAT_CLIENT = ("CHAT_CLIENT", _("Chat Client"))
+ # 操作日志
+ OPERATION_LOG = ("OPERATION_LOG", _("Operation Log"))
+ # 系统设置
+ SYSTEM_SETTING = ("SYSTEM_SETTING", _("System Setting"))
+ # 工作空间
+ WORKSPACE = ("WORKSPACE", _("Workspace"))
+ # 其他
+ OTHER = ("OTHER", _("Other"))
+
+ def __init__(self, value, label):
+ self._value_ = value
+ self.label = label
diff --git a/apps/common/auth/constants/chat_permission_constants.py b/apps/common/auth/constants/chat_permission_constants.py
new file mode 100644
index 00000000000..160639d386a
--- /dev/null
+++ b/apps/common/auth/constants/chat_permission_constants.py
@@ -0,0 +1,53 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: chat_permission_constants.py
+@date:2026/8/6 16:38
+@desc:
+"""
+
+from enum import Enum
+
+from common.auth.constants.group_constants import Group
+from common.auth.constants.operate_constants import Operate
+from common.auth.struct.aggregate_permission import AggregatePermission
+from common.auth.struct.permission import Permission
+
+
+class ChatPermissionConstants(Enum):
+ CHAT_USER_ANONYMOUS = Permission(Group.CHAT_USER, Group.CHAT_USER, Operate.ANNOTATION_AUTH, 0)
+ CHAT_USER_LOCAL = Permission(Group.CHAT_USER, Group.CHAT_USER, Operate.LOCAL, 1)
+ CHAT_USER_CAS = Permission(Group.CHAT_USER, Group.CHAT_USER, Operate.CAS, 2)
+ CHAT_USER_DINGTALK = Permission(Group.CHAT_USER, Group.CHAT_USER, Operate.DINGTALK, 3)
+ CHAT_USER_WECOM = Permission(Group.CHAT_USER, Group.CHAT_USER, Operate.WECOM, 4)
+ CHAT_USER_LARK = Permission(Group.CHAT_USER, Group.CHAT_USER, Operate.LARK, 5)
+ CHAT_USER_OIDC = Permission(Group.CHAT_USER, Group.CHAT_USER, Operate.OIDC, 6)
+ CHAT_USER_LDAP = Permission(Group.CHAT_USER, Group.CHAT_USER, Operate.LDAP, 7)
+ CHAT_USER_OAUTH2 = Permission(Group.CHAT_USER, Group.CHAT_USER, Operate.OAUTH2, 8)
+
+ def get_permission(self):
+ return self._build_workspace_permission("application_id")
+
+ def _build_workspace_permission(self, resource_id_key=None):
+ def permission_factory(_, **kwargs):
+ return Permission(
+ group=self.value.group,
+ sub_group=self.value.sub_group,
+ operate=self.value.operate,
+ bit_index=self.value.bit_index,
+ workspace_id=kwargs.get("workspace_id"),
+ resource_id=kwargs.get(resource_id_key) if resource_id_key else None,
+ )
+
+ return permission_factory
+
+ @staticmethod
+ def get_aggregate_permissions():
+ return AggregatePermission(
+ permissions=[_permission.get_permission() for _permission in ChatPermissionConstants]
+ )
+
+
+# 权限字符串与权限对象的Map
+CHAT_PERMISSION_STR_MAP = {_permission.value.__str__(): _permission for _permission in ChatPermissionConstants}
diff --git a/apps/common/auth/constants/compare_constants.py b/apps/common/auth/constants/compare_constants.py
new file mode 100644
index 00000000000..7c6a34dd0c7
--- /dev/null
+++ b/apps/common/auth/constants/compare_constants.py
@@ -0,0 +1,16 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: compare_constants.py
+ @date:2026/8/5 10:42
+ @desc:
+"""
+from enum import Enum
+
+
+class CompareConstants(Enum):
+ # 或者
+ OR = "OR"
+ # 并且
+ AND = "AND"
diff --git a/apps/common/auth/constants/group_constants.py b/apps/common/auth/constants/group_constants.py
new file mode 100644
index 00000000000..beaeb226d9a
--- /dev/null
+++ b/apps/common/auth/constants/group_constants.py
@@ -0,0 +1,126 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: group_constants.py
+@date:2026/8/3 17:31
+@desc: 权限分组常量,用于菜单和权限分类
+"""
+
+from enum import Enum
+
+from django.utils.translation import gettext_lazy as _
+
+
+class Group(Enum):
+ """
+ 权限组 一个组一般对应前端一个菜单
+ 使用方式:
+ - 无子分组: group=Group.TOOL, sub_group=Group.TOOL
+ - 有子分组: group=Group.TOOL, sub_group=Group.FOLDER
+ """
+
+ # 用户管理
+ USER = ("USER_MANAGEMENT", _("User Management"))
+
+ # 资源主分组
+ APPLICATION = ("APPLICATION", _("Application"))
+ KNOWLEDGE = ("KNOWLEDGE", _("Knowledge"))
+ MODEL = ("MODEL", _("Model"))
+ TOOL = ("TOOL", _("Tool"))
+ TRIGGER = ("TRIGGER", _("Trigger"))
+
+ # 子分组 - 文件夹
+ FOLDER = ("FOLDER", _("Folder"))
+
+ # 子分组 - 知识库
+ DOCUMENT = ("DOCUMENT", _("Document"))
+ WORKFLOW = ("WORKFLOW", _("Workflow"))
+ TAG = ("TAG", _("Tag"))
+ PROBLEM = ("PROBLEM", _("Problem"))
+ TERMBASE = ("TERMBASE", _("Termbase"))
+ HIT_TEST = ("HIT_TEST", _("Hit-Test"))
+
+ # 子分组 - 应用
+ OVERVIEW = ("OVERVIEW", _("Overview"))
+ ACCESS = ("ACCESS", _("Application Access"))
+ CHAT_LOG = ("CHAT_LOG", _("Conversation log"))
+ CHAT_USER = ("CHAT_USER", _("Dialogue users"))
+
+ # 子分组 - 对话用户(知识库)
+ KNOWLEDGE_CHAT_USER = ("KNOWLEDGE_CHAT_USER", _("Dialogue users"))
+
+ # 系统功能分组
+ ROLE = ("ROLE", _("Role Management"))
+ WORKSPACE = ("WORKSPACE", _("Workspace"))
+ USER_GROUP = ("USER_GROUP", _("User Group"))
+ EMAIL_SETTING = ("EMAIL_SETTING", _("Email Setting"))
+ LOGIN_AUTH = ("LOGIN_AUTH", _("Login Auth"))
+ APPEARANCE_SETTINGS = ("APPEARANCE_SETTINGS", _("Appearance Settings"))
+ DISPLAY_SETTINGS = ("DISPLAY_SETTINGS", _("Display Settings"))
+
+ # 对话相关
+ CHAT_USER_GROUP = ("CHAT_USER_GROUP", _("Chat User Group"))
+ CHAT_USER_AUTH = ("CHAT_USER_AUTH", _("Chat User Auth"))
+ PORTAL = ("PORTAL", _("portal"))
+ # 其他
+ OTHER = ("OTHER", _("Other"))
+ HOMEPAGE = ("HOMEPAGE", _("Home page"))
+ OPERATION_LOG = ("OPERATION_LOG", _("Operation Log"))
+
+ # 资源授权分组
+ RESOURCE_PERMISSION = ("RESOURCE_PERMISSION", _("Resource Permission"))
+ APPLICATION_RESOURCE_PERMISSION = (
+ "APPLICATION_RESOURCE_PERMISSION",
+ _("Application"),
+ )
+ KNOWLEDGE_RESOURCE_PERMISSION = ("KNOWLEDGE_RESOURCE_PERMISSION", _("Knowledge"))
+ TOOL_RESOURCE_PERMISSION = ("TOOL_RESOURCE_PERMISSION", _("Tool"))
+ MODEL_RESOURCE_PERMISSION = ("MODEL_RESOURCE_PERMISSION", _("Model"))
+
+ # 工作空间分组
+ WORKSPACE_ROLE = ("WORKSPACE_ROLE", _("Role Management"))
+ WORKSPACE_WORKSPACE = ("WORKSPACE_WORKSPACE", _("Workspace"))
+ WORKSPACE_USER_GROUP = ("WORKSPACE_USER_GROUP", _("User Group"))
+ WORKSPACE_RESOURCE_PERMISSION = ("WORKSPACE_RESOURCE_PERMISSION", _("Resource Permission"))
+ WORKSPACE_CHAT_USER = ("WORKSPACE_CHAT_USER", _("Chat User"))
+ WORKSPACE_CHAT_USER_GROUP = ("WORKSPACE_CHAT_USER_GROUP", _("Chat User Group"))
+
+ # 用户级分组
+ USER_HOMEPAGE = ("USER_HOMEPAGE", _("Home page"))
+ USER_APPLICATION = ("USER_APPLICATION", _("Application"))
+ USER_KNOWLEDGE = ("USER_KNOWLEDGE", _("Knowledge"))
+ USER_MODEL = ("USER_MODEL", _("Model"))
+ USER_TOOL = ("USER_TOOL", _("Tool"))
+ USER_OTHER = ("USER_OTHER", _("Other"))
+
+ # 共享资源分组
+ SYSTEM_KNOWLEDGE = ("SYSTEM_KNOWLEDGE", _("Knowledge"))
+ SYSTEM_MODEL = ("SYSTEM_MODEL", _("Model"))
+ SYSTEM_TOOL = ("SYSTEM_TOOL", _("Tool"))
+
+ # 资源管理分组
+ SYSTEM_RES_APPLICATION = ("SYSTEM_RESOURCE_APPLICATION", _("Application"))
+ SYSTEM_RES_KNOWLEDGE = ("SYSTEM_RESOURCE_KNOWLEDGE", _("Knowledge"))
+ SYSTEM_RES_TOOL = ("SYSTEM_RESOURCE_TOOL", _("Tool"))
+ SYSTEM_RES_MODEL = ("SYSTEM_RESOURCE_MODEL", _("Model"))
+
+ # 系统资源子分组
+ SYSTEM_DOCUMENT = ("SYSTEM_DOCUMENT", _("Document"))
+ SYSTEM_WORKFLOW = ("SYSTEM_WORKFLOW", _("Workflow"))
+ SYSTEM_TAG = ("SYSTEM_TAG", _("Tag"))
+ SYSTEM_PROBLEM = ("SYSTEM_PROBLEM", _("Problem"))
+ SYSTEM_TERMBASE = ("SYSTEM_TERMBASE", _("Termbase"))
+ SYSTEM_HIT_TEST = ("SYSTEM_HIT_TEST", _("Hit-Test"))
+ SYSTEM_CHAT_USER = ("SYSTEM_CHAT_USER", _("Dialogue users"))
+ SYSTEM_OVERVIEW = ("SYSTEM_OVERVIEW", _("Overview"))
+ SYSTEM_ACCESS = ("SYSTEM_ACCESS", _("Application Access"))
+ SYSTEM_CHAT_LOG = ("SYSTEM_CHAT_LOG", _("Conversation log"))
+ CHAT = ("CHAT", _("Chat"))
+
+ def __init__(self, value, label):
+ self._value_ = value
+ self.label = label
+
+ def __str__(self):
+ return self.value
diff --git a/apps/common/auth/constants/operate_constants.py b/apps/common/auth/constants/operate_constants.py
new file mode 100644
index 00000000000..835c5e86935
--- /dev/null
+++ b/apps/common/auth/constants/operate_constants.py
@@ -0,0 +1,96 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: operate_constants.py
+@date:2026/8/3 17:32
+@desc: 操作权限常量
+"""
+
+from enum import Enum
+from django.utils.translation import gettext_lazy as _
+
+
+class Operate(Enum):
+ """
+ 一个权限组的操作权限
+ """
+
+ SELF = ("", "")
+ READ = ("READ", _("Read"))
+ EDIT = ("READ+EDIT", _("Edit"))
+ CREATE = ("READ+CREATE", _("Create"))
+ DELETE = ("READ+DELETE", _("Delete"))
+ """
+ 使用权限
+ """
+ USE = ("USE", _("Use"))
+ IMPORT = ("READ+IMPORT", _("Import"))
+ EXPORT = ("READ+EXPORT", _("Export"))
+ PUBLISH = ("READ+PUBLISH", _("Publish"))
+ SYNC = ("READ+SYNC", _("Sync"))
+ GENERATE = ("READ+GENERATE", _("Generate"))
+ ADD_MEMBER = ("READ+ADD_MEMBER", _("Add Member"))
+ REMOVE_MEMBER = ("READ+REMOVE_MEMBER", _("Remove Member"))
+ VECTOR = ("READ+VECTOR", _("Vector"))
+ MIGRATE = ("READ+MIGRATE", _("Migrate"))
+ RELATE = ("READ+RELATE", _("Relate"))
+ USER_GROUP = ("READ+USER_GROUP", _("User Group"))
+ ANNOTATION = ("READ+ANNOTATION", _("Annotation"))
+ CLEAR_POLICY = ("READ+CLEAR_POLICY", _("Clear Policy"))
+ EMBED = ("READ+EMBED", _("Embed third party"))
+ ACCESS = ("READ+ACCESS", _("Access restrictions"))
+ DISPLAY = ("READ+DISPLAY", _("Display Settings"))
+ API_KEY = ("READ+API_KEY", _("API KEY"))
+ PUBLIC_ACCESS = ("READ+PUBLIC_ACCESS", _("Public access link"))
+ Q_WEIXIN = ("READ+Q_WEIXIN", _("Enterprise WeiXin"))
+ FEISHU = ("READ+FEISHU", _("Feishu"))
+ DD = ("READ+DD", _("Dingding"))
+ WEIXIN_PUBLIC_ACCOUNT = ("READ+WEIXIN_PUBLIC_ACCOUNT", _("Weixin Public Account"))
+ SLACK = ("READ+SLACK", _("Slack"))
+ ADD_KNOWLEDGE = ("READ+ADD_KNOWLEDGE", _("Add to Knowledge Base"))
+ TO_CHAT = ("READ+TO_CHAT", _("To Chat"))
+ SETTING = ("READ+SETTING", _("Setting"))
+ DOWNLOAD = ("READ+DOWNLOAD", _("Download Original Document"))
+ COPY = ("READ+COPY", _("Copy"))
+ AUTH = ("READ+AUTH", _("resource authorization"))
+ TAG = ("READ+TAG", _("Tag Setting"))
+ REPLACE = ("READ+REPLACE", _("Replace Original Document"))
+ UPDATE = ("READ+UPDATE", _("Update License"))
+ RELATE_VIEW = ("READ+RELATE_VIEW", _("View related resources"))
+ RECORD = ("READ+RECORD", _("Read execute record"))
+ TRIGGER_READ = ("READ+TRIGGER_READ", _("Read Trigger"))
+ TRIGGER_EDIT = ("READ+TRIGGER_EDIT", _("Edit Trigger"))
+ TRIGGER_CREATE = ("READ+TRIGGER_CREATE", _("Create Trigger"))
+ TRIGGER_DELETE = ("READ+TRIGGER_DELETE", _("Delete Trigger"))
+ BATCH_DELETE = ("READ+BATCH_DELETE", _("Batch delete"))
+ BATCH_MOVE = ("READ+BATCH_MOVE", _("Batch move"))
+ TOKEN = ("READ+TOKEN", _("Token Index"))
+ TO_WORKSPACE = ("READ+TO_WORKSPACE", _("Authorize to Workspace"))
+ SET_ROLE = ("READ+SET_ROLE", _("Set Role"))
+ QUOTA_SETTING = ("READ+QUOTA_SETTING", _("Quota Setting"))
+
+ ABOUT = ("READ", _("About"))
+ LICENSE = ("READ+UPDATE", _("Update License"))
+ SWITCH_LANGUAGE = ("READ+EDIT", _("Switch Language"))
+ CHANGE_PASSWORD = ("READ+CREATE", _("Change Password"))
+ SYSTEM_API_KEY = ("READ+DELETE", _("System API Key"))
+ PORTAL = ("READ+PORTAL", _("Portal"))
+
+ ANNOTATION_AUTH = ('ANNOTATION', _("Annotation"))
+ PASSWORD = ("PASSWORD", _("Password verification"))
+ LOCAL = ("LOCAL", _("Account login"))
+ CAS = ("CAS", _("CAS"))
+ DINGTALK = ("DINGTALK", _("dingtalk"))
+ WECOM = ("WECOM", _("WeCom"))
+ LARK = ("LARK", _("lark"))
+ OIDC = ("OIDC", _("OIDC"))
+ LDAP = ("LDAP", _("LDAP"))
+ OAUTH2 = ("OAUTH2", _("OAUTH2"))
+
+ def __init__(self, value, label):
+ self._value_ = value
+ self.label = label
+
+ def __str__(self):
+ return self.value
diff --git a/apps/common/auth/constants/permission_constants.py b/apps/common/auth/constants/permission_constants.py
new file mode 100644
index 00000000000..ca7858badd1
--- /dev/null
+++ b/apps/common/auth/constants/permission_constants.py
@@ -0,0 +1,3713 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: permission_constants.py
+@date:2026/8/3 17:28
+@desc: 权限枚举常量(新格式)
+"""
+
+from enum import Enum
+from typing import List, Dict
+
+from common.auth.constants.category_constants import Category
+from common.auth.constants.group_constants import Group
+from common.auth.constants.operate_constants import Operate
+from common.auth.constants.permission_scope_constants import PermissionScopeConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.permission import Permission, PermissionMeta
+
+
+class ResourcePermissionGroup:
+ """资源权限组"""
+
+ def __init__(self, resource: Group, permission: str):
+ self.resource = resource
+ self.permission = permission
+
+ def __eq__(self, other):
+ return str(self.permission) == str(other.permission) and str(self.resource) == str(other.resource)
+
+ def __str__(self):
+ return f"{self.resource}_{self.permission}"
+
+ def __hash__(self):
+ return hash((self.resource, self.permission))
+
+
+class ResourcePermissionConst:
+ """资源权限常量"""
+
+ # 知识库
+ KNOWLEDGE_VIEW = ResourcePermissionGroup(Group.KNOWLEDGE, "VIEW")
+ KNOWLEDGE_MANAGE = ResourcePermissionGroup(Group.KNOWLEDGE, "MANAGE")
+ KNOWLEDGE_FOLDER_VIEW = ResourcePermissionGroup(Group.KNOWLEDGE, "FOLDER_VIEW")
+ KNOWLEDGE_FOLDER_MANAGE = ResourcePermissionGroup(Group.KNOWLEDGE, "FOLDER_MANAGE")
+
+ # 应用
+ APPLICATION_VIEW = ResourcePermissionGroup(Group.APPLICATION, "VIEW")
+ APPLICATION_MANAGE = ResourcePermissionGroup(Group.APPLICATION, "MANAGE")
+ APPLICATION_FOLDER_VIEW = ResourcePermissionGroup(Group.APPLICATION, "FOLDER_VIEW")
+ APPLICATION_FOLDER_MANAGE = ResourcePermissionGroup(Group.APPLICATION, "FOLDER_MANAGE")
+
+ # 工具
+ TOOL_VIEW = ResourcePermissionGroup(Group.TOOL, "VIEW")
+ TOOL_MANAGE = ResourcePermissionGroup(Group.TOOL, "MANAGE")
+ TOOL_FOLDER_VIEW = ResourcePermissionGroup(Group.TOOL, "FOLDER_VIEW")
+ TOOL_FOLDER_MANAGE = ResourcePermissionGroup(Group.TOOL, "FOLDER_MANAGE")
+
+ # 模型
+ MODEL_VIEW = ResourcePermissionGroup(Group.MODEL, "VIEW")
+ MODEL_MANAGE = ResourcePermissionGroup(Group.MODEL, "MANAGE")
+
+
+from maxkb import settings
+
+is_ee: bool = settings.edition == "EE"
+
+
+class PermissionConstants(Enum):
+ """
+ 权限枚举
+ """
+
+ # ==================== 首页 ====================
+ HOMEPAGE_READ = (
+ Permission(group=Group.HOMEPAGE, sub_group=Group.HOMEPAGE, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE],
+ ),
+ )
+
+ HOMEPAGE_EXPORT = (
+ Permission(group=Group.HOMEPAGE, sub_group=Group.HOMEPAGE, operate=Operate.EXPORT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE],
+ ),
+ )
+
+ SYSTEM_HOMEPAGE_READ = (
+ Permission(group=Group.HOMEPAGE, sub_group=Group.HOMEPAGE, operate=Operate.READ, bit_index=0),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], category=Category.IAM, scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ SYSTEM_HOMEPAGE_EXPORT = (
+ Permission(group=Group.HOMEPAGE, sub_group=Group.HOMEPAGE, operate=Operate.EXPORT, bit_index=1),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], category=Category.IAM, scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ # ==================== 资源主分组(无子分组) ====================
+ KNOWLEDGE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.SELF, bit_index=0),
+ PermissionMeta(
+ role_list=[],
+ category=Category.RESOURCE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ APPLICATION = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.SELF, bit_index=0),
+ PermissionMeta(
+ role_list=[],
+ category=Category.RESOURCE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ MODEL = (
+ Permission(group=Group.MODEL, sub_group=Group.MODEL, operate=Operate.SELF, bit_index=0),
+ PermissionMeta(role_list=[], category=Category.RESOURCE),
+ )
+
+ TOOL = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.SELF, bit_index=0),
+ PermissionMeta(role_list=[], category=Category.RESOURCE),
+ )
+
+ # ==================== 用户管理 ====================
+ USER_READ = (
+ Permission(group=Group.USER, sub_group=Group.USER, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ USER_CREATE = (
+ Permission(group=Group.USER, sub_group=Group.USER, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], category=Category.IAM, scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ USER_EDIT = (
+ Permission(group=Group.USER, sub_group=Group.USER, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], category=Category.IAM, scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ USER_DELETE = (
+ Permission(group=Group.USER, sub_group=Group.USER, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], category=Category.IAM, scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ USER_SET_ROLE = (
+ Permission(group=Group.USER, sub_group=Group.USER, operate=Operate.SET_ROLE, bit_index=4),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], category=Category.IAM, scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ USER_IMPORT = (
+ Permission(group=Group.USER, sub_group=Group.USER, operate=Operate.IMPORT, bit_index=5),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], category=Category.IAM, scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ # ==================== 系统用户组 ====================
+ SYSTEM_USER_GROUP_READ = (
+ Permission(group=Group.USER_GROUP, sub_group=Group.USER_GROUP, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SYSTEM_USER_GROUP_CREATE = (
+ Permission(group=Group.USER_GROUP, sub_group=Group.USER_GROUP, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SYSTEM_USER_GROUP_EDIT = (
+ Permission(group=Group.USER_GROUP, sub_group=Group.USER_GROUP, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SYSTEM_USER_GROUP_DELETE = (
+ Permission(group=Group.USER_GROUP, sub_group=Group.USER_GROUP, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SYSTEM_USER_GROUP_ADD_MEMBER = (
+ Permission(group=Group.USER_GROUP, sub_group=Group.USER_GROUP, operate=Operate.ADD_MEMBER, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SYSTEM_USER_GROUP_REMOVE_MEMBER = (
+ Permission(group=Group.USER_GROUP, sub_group=Group.USER_GROUP, operate=Operate.REMOVE_MEMBER, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 模型 ====================
+ MODEL_READ = (
+ Permission(group=Group.MODEL, sub_group=Group.MODEL, operate=Operate.READ, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.MODEL_VIEW],
+ ),
+ )
+
+ MODEL_CREATE = (
+ Permission(group=Group.MODEL, sub_group=Group.MODEL, operate=Operate.CREATE, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE],
+ resource_permission_group_list=[ResourcePermissionConst.MODEL_MANAGE],
+ ),
+ )
+
+ MODEL_EDIT = (
+ Permission(group=Group.MODEL, sub_group=Group.MODEL, operate=Operate.EDIT, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.MODEL_MANAGE],
+ ),
+ )
+
+ MODEL_DELETE = (
+ Permission(group=Group.MODEL, sub_group=Group.MODEL, operate=Operate.DELETE, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.MODEL_MANAGE],
+ ),
+ )
+
+ MODEL_RESOURCE_AUTHORIZATION = (
+ Permission(group=Group.MODEL, sub_group=Group.MODEL, operate=Operate.AUTH, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.MODEL_MANAGE],
+ ),
+ )
+
+ MODEL_RELATE_RESOURCE_VIEW = (
+ Permission(group=Group.MODEL, sub_group=Group.MODEL, operate=Operate.RELATE_VIEW, bit_index=6),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.MODEL_MANAGE],
+ ),
+ )
+
+ # ==================== 触发器 ====================
+ TRIGGER_READ = (
+ Permission(group=Group.TRIGGER, sub_group=Group.TRIGGER, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TRIGGER_CREATE = (
+ Permission(group=Group.TRIGGER, sub_group=Group.TRIGGER, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE],
+ ),
+ )
+
+ TRIGGER_EDIT = (
+ Permission(group=Group.TRIGGER, sub_group=Group.TRIGGER, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TRIGGER_DELETE = (
+ Permission(group=Group.TRIGGER, sub_group=Group.TRIGGER, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TRIGGER_RECORD = (
+ Permission(group=Group.TRIGGER, sub_group=Group.TRIGGER, operate=Operate.RECORD, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # ==================== 工具 ====================
+ TOOL_READ = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.READ, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_CREATE = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.CREATE, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE],
+ ),
+ )
+
+ TOOL_BATCH_MOVE = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.BATCH_MOVE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_BATCH_DELETE = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.BATCH_DELETE, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_EDIT = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.EDIT, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_DELETE = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.DELETE, bit_index=6),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_IMPORT = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.IMPORT, bit_index=7),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_EXPORT = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.EXPORT, bit_index=8),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_RESOURCE_AUTHORIZATION = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.AUTH, bit_index=9),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_RELATE_RESOURCE_VIEW = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.RELATE_VIEW, bit_index=10),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_PUBLISH = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.PUBLISH, bit_index=11),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_EXECUTE_RECORD = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.RECORD, bit_index=12),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # 工具触发器
+ TOOL_TRIGGER_READ = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.TRIGGER_READ, bit_index=13),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_TRIGGER_CREATE = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.TRIGGER_CREATE, bit_index=14),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_TRIGGER_EDIT = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.TRIGGER_EDIT, bit_index=15),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_TRIGGER_DELETE = (
+ Permission(group=Group.TOOL, sub_group=Group.TOOL, operate=Operate.TRIGGER_DELETE, bit_index=16),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # ==================== 工具文件夹 ====================
+ TOOL_FOLDER_READ = (
+ Permission(group=Group.TOOL, sub_group=Group.FOLDER, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_FOLDER_CREATE = (
+ Permission(group=Group.TOOL, sub_group=Group.FOLDER, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE],
+ ),
+ )
+
+ TOOL_FOLDER_EDIT = (
+ Permission(group=Group.TOOL, sub_group=Group.FOLDER, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_FOLDER_DELETE = (
+ Permission(group=Group.TOOL, sub_group=Group.FOLDER, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ TOOL_FOLDER_AUTH = (
+ Permission(group=Group.TOOL, sub_group=Group.FOLDER, operate=Operate.AUTH, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.TOOL_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # ==================== 知识库 ====================
+ KNOWLEDGE_READ = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.READ, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
+ ),
+ )
+
+ KNOWLEDGE_CREATE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.CREATE, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
+ ),
+ )
+
+ KNOWLEDGE_EDIT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.EDIT, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_DELETE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.DELETE, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_SYNC = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.SYNC, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_EXPORT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.EXPORT, bit_index=6),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_VECTOR = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.VECTOR, bit_index=7),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_GENERATE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.GENERATE, bit_index=8),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_BATCH_DELETE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.BATCH_DELETE, bit_index=9),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_BATCH_MOVE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.BATCH_MOVE, bit_index=10),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_RESOURCE_AUTHORIZATION = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.AUTH, bit_index=11),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_RELATE_RESOURCE_VIEW = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.KNOWLEDGE, operate=Operate.RELATE_VIEW, bit_index=12),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ # ==================== 知识库文件夹 ====================
+ KNOWLEDGE_FOLDER_READ = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.FOLDER, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
+ ),
+ )
+
+ KNOWLEDGE_FOLDER_CREATE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.FOLDER, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_FOLDER_EDIT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.FOLDER, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_FOLDER_DELETE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.FOLDER, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_FOLDER_AUTH = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.FOLDER, operate=Operate.AUTH, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ # ==================== 知识库工作流 ====================
+ KNOWLEDGE_WORKFLOW_READ = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.WORKFLOW, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
+ ),
+ )
+
+ KNOWLEDGE_WORKFLOW_EDIT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.WORKFLOW, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_WORKFLOW_EXPORT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.WORKFLOW, operate=Operate.EXPORT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ KNOWLEDGE_WORKFLOW_PUBLISH = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.WORKFLOW, operate=Operate.PUBLISH, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ ),
+ )
+
+ # ==================== 知识库文档 ====================
+ KNOWLEDGE_DOCUMENT_READ = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_CREATE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_EDIT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_DELETE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_SYNC = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.SYNC, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_EXPORT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.EXPORT, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_DOWNLOAD_SOURCE_FILE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.DOWNLOAD, bit_index=6),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_GENERATE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.GENERATE, bit_index=7),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_VECTOR = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.VECTOR, bit_index=8),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_MIGRATE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.MIGRATE, bit_index=9),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_TAG = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.TAG, bit_index=10),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_REPLACE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.REPLACE, bit_index=11),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_DOCUMENT_TOKEN = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.DOCUMENT, operate=Operate.TOKEN, bit_index=12),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # ==================== 知识库命中测试 ====================
+ KNOWLEDGE_HIT_TEST = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.HIT_TEST, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # ==================== 知识库问题 ====================
+ KNOWLEDGE_PROBLEM_READ = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.PROBLEM, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_PROBLEM_CREATE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.PROBLEM, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE],
+ ),
+ )
+
+ KNOWLEDGE_PROBLEM_EDIT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.PROBLEM, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_PROBLEM_DELETE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.PROBLEM, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_PROBLEM_RELATE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.PROBLEM, operate=Operate.RELATE, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # ==================== 知识库术语库 ====================
+ KNOWLEDGE_TERMBASE_READ = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.TERMBASE, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_TERMBASE_CREATE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.TERMBASE, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE],
+ ),
+ )
+
+ KNOWLEDGE_TERMBASE_EDIT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.TERMBASE, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_TERMBASE_DELETE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.TERMBASE, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # ==================== 知识库标签 ====================
+ KNOWLEDGE_TAG_READ = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.TAG, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_TAG_CREATE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.TAG, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE],
+ ),
+ )
+
+ KNOWLEDGE_TAG_EDIT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.TAG, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_TAG_DELETE = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.TAG, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # ==================== 知识库对话用户 ====================
+ KNOWLEDGE_CHAT_USER_READ = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.CHAT_USER, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ KNOWLEDGE_CHAT_USER_EDIT = (
+ Permission(group=Group.KNOWLEDGE, sub_group=Group.CHAT_USER, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANAGE],
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # ==================== 资源授权 ====================
+ APPLICATION_RESOURCE_PERMISSION_READ = (
+ Permission(
+ group=Group.RESOURCE_PERMISSION,
+ sub_group=Group.APPLICATION_RESOURCE_PERMISSION,
+ operate=Operate.READ,
+ bit_index=0,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ APPLICATION_RESOURCE_PERMISSION_EDIT = (
+ Permission(
+ group=Group.RESOURCE_PERMISSION,
+ sub_group=Group.APPLICATION_RESOURCE_PERMISSION,
+ operate=Operate.EDIT,
+ bit_index=1,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ KNOWLEDGE_RESOURCE_PERMISSION_READ = (
+ Permission(
+ group=Group.RESOURCE_PERMISSION,
+ sub_group=Group.KNOWLEDGE_RESOURCE_PERMISSION,
+ operate=Operate.READ,
+ bit_index=2,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ KNOWLEDGE_RESOURCE_PERMISSION_EDIT = (
+ Permission(
+ group=Group.RESOURCE_PERMISSION,
+ sub_group=Group.KNOWLEDGE_RESOURCE_PERMISSION,
+ operate=Operate.EDIT,
+ bit_index=3,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ TOOL_RESOURCE_PERMISSION_READ = (
+ Permission(
+ group=Group.RESOURCE_PERMISSION, sub_group=Group.TOOL_RESOURCE_PERMISSION, operate=Operate.READ, bit_index=4
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ TOOL_RESOURCE_PERMISSION_EDIT = (
+ Permission(
+ group=Group.RESOURCE_PERMISSION,
+ sub_group=Group.TOOL_RESOURCE_PERMISSION,
+ operate=Operate.EDIT,
+ bit_index=5,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ MODEL_RESOURCE_PERMISSION_READ = (
+ Permission(
+ group=Group.RESOURCE_PERMISSION,
+ sub_group=Group.MODEL_RESOURCE_PERMISSION,
+ operate=Operate.READ,
+ bit_index=6,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ MODEL_RESOURCE_PERMISSION_EDIT = (
+ Permission(
+ group=Group.RESOURCE_PERMISSION,
+ sub_group=Group.MODEL_RESOURCE_PERMISSION,
+ operate=Operate.EDIT,
+ bit_index=7,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 邮件设置 ====================
+ EMAIL_SETTING_READ = (
+ Permission(group=Group.EMAIL_SETTING, sub_group=Group.EMAIL_SETTING, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.SYSTEM_SETTING, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ EMAIL_SETTING_EDIT = (
+ Permission(group=Group.EMAIL_SETTING, sub_group=Group.EMAIL_SETTING, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.SYSTEM_SETTING, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ # ==================== 角色管理 ====================
+ ROLE_READ = (
+ Permission(group=Group.ROLE, sub_group=Group.ROLE, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ ROLE_CREATE = (
+ Permission(group=Group.ROLE, sub_group=Group.ROLE, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], category=Category.IAM, scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ ROLE_EDIT = (
+ Permission(group=Group.ROLE, sub_group=Group.ROLE, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], category=Category.IAM, scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ ROLE_DELETE = (
+ Permission(group=Group.ROLE, sub_group=Group.ROLE, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], category=Category.IAM, scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ ROLE_ADD_MEMBER = (
+ Permission(group=Group.ROLE, sub_group=Group.ROLE, operate=Operate.ADD_MEMBER, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ ROLE_REMOVE_MEMBER = (
+ Permission(group=Group.ROLE, sub_group=Group.ROLE, operate=Operate.REMOVE_MEMBER, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 工作空间管理 ====================
+ WORKSPACE_READ = (
+ Permission(group=Group.WORKSPACE, sub_group=Group.WORKSPACE, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ WORKSPACE_CREATE = (
+ Permission(group=Group.WORKSPACE, sub_group=Group.WORKSPACE, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.IAM, is_ee=is_ee, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ WORKSPACE_EDIT = (
+ Permission(group=Group.WORKSPACE, sub_group=Group.WORKSPACE, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.IAM, is_ee=is_ee, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ WORKSPACE_DELETE = (
+ Permission(group=Group.WORKSPACE, sub_group=Group.WORKSPACE, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.IAM, is_ee=is_ee, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ WORKSPACE_ADD_MEMBER = (
+ Permission(group=Group.WORKSPACE, sub_group=Group.WORKSPACE, operate=Operate.ADD_MEMBER, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ WORKSPACE_REMOVE_MEMBER = (
+ Permission(group=Group.WORKSPACE, sub_group=Group.WORKSPACE, operate=Operate.REMOVE_MEMBER, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.IAM,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 登录认证 ====================
+ LOGIN_AUTH_READ = (
+ Permission(group=Group.LOGIN_AUTH, sub_group=Group.LOGIN_AUTH, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.SYSTEM_SETTING, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ LOGIN_AUTH_EDIT = (
+ Permission(group=Group.LOGIN_AUTH, sub_group=Group.LOGIN_AUTH, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.SYSTEM_SETTING, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ # ==================== 应用 ====================
+ APPLICATION_READ = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.READ, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
+ ),
+ )
+
+ APPLICATION_CREATE = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.CREATE, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_COPY = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.COPY, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_EDIT = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.EDIT, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_DELETE = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.DELETE, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_IMPORT = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.IMPORT, bit_index=6),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_EXPORT = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.EXPORT, bit_index=7),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_PUBLISH = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.PUBLISH, bit_index=8),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_BATCH_DELETE = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.BATCH_DELETE, bit_index=9),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_BATCH_MOVE = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.BATCH_MOVE, bit_index=10),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_RESOURCE_AUTHORIZATION = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.AUTH, bit_index=11),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_RELATE_RESOURCE_VIEW = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.RELATE_VIEW, bit_index=12),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ # 应用触发器
+ APPLICATION_TRIGGER_READ = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.TRIGGER_READ, bit_index=13),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ APPLICATION_TRIGGER_CREATE = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.TRIGGER_CREATE, bit_index=14),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE],
+ ),
+ )
+
+ APPLICATION_TRIGGER_EDIT = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.TRIGGER_EDIT, bit_index=15),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ APPLICATION_TRIGGER_DELETE = (
+ Permission(group=Group.APPLICATION, sub_group=Group.APPLICATION, operate=Operate.TRIGGER_DELETE, bit_index=16),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ ),
+ )
+
+ # ==================== 应用文件夹 ====================
+ APPLICATION_FOLDER_READ = (
+ Permission(group=Group.APPLICATION, sub_group=Group.FOLDER, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
+ ),
+ )
+
+ APPLICATION_FOLDER_CREATE = (
+ Permission(group=Group.APPLICATION, sub_group=Group.FOLDER, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_FOLDER_EDIT = (
+ Permission(group=Group.APPLICATION, sub_group=Group.FOLDER, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_FOLDER_DELETE = (
+ Permission(group=Group.APPLICATION, sub_group=Group.FOLDER, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_FOLDER_AUTH = (
+ Permission(group=Group.APPLICATION, sub_group=Group.FOLDER, operate=Operate.AUTH, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ # ==================== 应用概览 ====================
+ APPLICATION_OVERVIEW_READ = (
+ Permission(group=Group.APPLICATION, sub_group=Group.OVERVIEW, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
+ ),
+ )
+
+ APPLICATION_OVERVIEW_EMBED = (
+ Permission(group=Group.APPLICATION, sub_group=Group.OVERVIEW, operate=Operate.EMBED, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_OVERVIEW_ACCESS = (
+ Permission(group=Group.APPLICATION, sub_group=Group.OVERVIEW, operate=Operate.ACCESS, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_OVERVIEW_DISPLAY = (
+ Permission(group=Group.APPLICATION, sub_group=Group.OVERVIEW, operate=Operate.DISPLAY, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_OVERVIEW_API_KEY = (
+ Permission(group=Group.APPLICATION, sub_group=Group.OVERVIEW, operate=Operate.API_KEY, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_OVERVIEW_PUBLIC = (
+ Permission(group=Group.APPLICATION, sub_group=Group.OVERVIEW, operate=Operate.PUBLIC_ACCESS, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ # ==================== 应用接入 ====================
+ APPLICATION_ACCESS_READ = (
+ Permission(group=Group.APPLICATION, sub_group=Group.ACCESS, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
+ ),
+ )
+
+ APPLICATION_ACCESS_EDIT = (
+ Permission(group=Group.APPLICATION, sub_group=Group.ACCESS, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ # ==================== 应用对话用户 ====================
+ APPLICATION_CHAT_USER_READ = (
+ Permission(group=Group.APPLICATION, sub_group=Group.CHAT_USER, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
+ ),
+ )
+
+ APPLICATION_CHAT_USER_EDIT = (
+ Permission(group=Group.APPLICATION, sub_group=Group.CHAT_USER, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ # ==================== 应用对话日志 ====================
+ APPLICATION_CHAT_LOG_READ = (
+ Permission(group=Group.APPLICATION, sub_group=Group.CHAT_LOG, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
+ ),
+ )
+
+ APPLICATION_CHAT_LOG_ANNOTATION = (
+ Permission(group=Group.APPLICATION, sub_group=Group.CHAT_LOG, operate=Operate.ANNOTATION, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_CHAT_LOG_EXPORT = (
+ Permission(group=Group.APPLICATION, sub_group=Group.CHAT_LOG, operate=Operate.EXPORT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_CHAT_LOG_CLEAR_POLICY = (
+ Permission(group=Group.APPLICATION, sub_group=Group.CHAT_LOG, operate=Operate.CLEAR_POLICY, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ APPLICATION_CHAT_LOG_ADD_KNOWLEDGE = (
+ Permission(group=Group.APPLICATION, sub_group=Group.CHAT_LOG, operate=Operate.ADD_KNOWLEDGE, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.WORKSPACE_MANAGE, RoleConstants.USER],
+ category=Category.WORKSPACE,
+ scope=[PermissionScopeConstants.WORKSPACE_RESOURCE],
+ resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANAGE],
+ ),
+ )
+
+ # ==================== 其他 ====================
+ ABOUT_READ = (
+ Permission(group=Group.OTHER, sub_group=Group.OTHER, operate=Operate.ABOUT, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.USER, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.SYSTEM_SETTING,
+ scope=[PermissionScopeConstants.SYSTEM],
+ role_category_map={
+ RoleConstants.ADMIN.name: Category.SYSTEM_SETTING,
+ RoleConstants.USER.name: Category.OTHER,
+ RoleConstants.WORKSPACE_MANAGE.name: Category.OTHER,
+ },
+ ),
+ )
+
+ LICENSE_UPDATE = (
+ Permission(group=Group.OTHER, sub_group=Group.OTHER, operate=Operate.LICENSE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SYSTEM_SETTING,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SWITCH_LANGUAGE = (
+ Permission(group=Group.OTHER, sub_group=Group.OTHER, operate=Operate.SWITCH_LANGUAGE, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.USER, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.SYSTEM_SETTING,
+ scope=[PermissionScopeConstants.SYSTEM],
+ role_category_map={
+ RoleConstants.ADMIN.name: Category.SYSTEM_SETTING,
+ RoleConstants.USER.name: Category.OTHER,
+ RoleConstants.WORKSPACE_MANAGE.name: Category.OTHER,
+ },
+ ),
+ )
+
+ CHANGE_PASSWORD = (
+ Permission(group=Group.OTHER, sub_group=Group.OTHER, operate=Operate.CHANGE_PASSWORD, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.USER, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.SYSTEM_SETTING,
+ scope=[PermissionScopeConstants.SYSTEM],
+ role_category_map={
+ RoleConstants.ADMIN.name: Category.SYSTEM_SETTING,
+ RoleConstants.USER.name: Category.OTHER,
+ RoleConstants.WORKSPACE_MANAGE.name: Category.OTHER,
+ },
+ ),
+ )
+
+ SYSTEM_API_KEY_EDIT = (
+ Permission(group=Group.OTHER, sub_group=Group.OTHER, operate=Operate.SYSTEM_API_KEY, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.USER, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.SYSTEM_SETTING,
+ scope=[PermissionScopeConstants.SYSTEM],
+ role_category_map={
+ RoleConstants.ADMIN.name: Category.SYSTEM_SETTING,
+ RoleConstants.USER.name: Category.OTHER,
+ RoleConstants.WORKSPACE_MANAGE.name: Category.OTHER,
+ },
+ ),
+ )
+
+ PORTAL = (
+ Permission(group=Group.OTHER, sub_group=Group.OTHER, operate=Operate.PORTAL, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.USER, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.SYSTEM_SETTING,
+ scope=[PermissionScopeConstants.SYSTEM],
+ role_category_map={
+ RoleConstants.ADMIN.name: Category.SYSTEM_SETTING,
+ RoleConstants.USER.name: Category.OTHER,
+ RoleConstants.WORKSPACE_MANAGE.name: Category.OTHER,
+ },
+ ),
+ )
+
+ # ==================== 外观设置 ====================
+ APPEARANCE_SETTINGS_READ = (
+ Permission(
+ group=Group.APPEARANCE_SETTINGS, sub_group=Group.APPEARANCE_SETTINGS, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.SYSTEM_SETTING, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ APPEARANCE_SETTINGS_EDIT = (
+ Permission(
+ group=Group.APPEARANCE_SETTINGS, sub_group=Group.APPEARANCE_SETTINGS, operate=Operate.EDIT, bit_index=1
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.SYSTEM_SETTING, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ # ==================== 对话用户 ====================
+ CHAT_USER_READ = (
+ Permission(group=Group.CHAT_USER, sub_group=Group.CHAT_USER, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ CHAT_USER_CREATE = (
+ Permission(group=Group.CHAT_USER, sub_group=Group.CHAT_USER, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ CHAT_USER_SYNC = (
+ Permission(group=Group.CHAT_USER, sub_group=Group.CHAT_USER, operate=Operate.SYNC, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ CHAT_USER_EDIT = (
+ Permission(group=Group.CHAT_USER, sub_group=Group.CHAT_USER, operate=Operate.EDIT, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ CHAT_USER_DELETE = (
+ Permission(group=Group.CHAT_USER, sub_group=Group.CHAT_USER, operate=Operate.DELETE, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ CHAT_USER_GROUP = (
+ Permission(group=Group.CHAT_USER, sub_group=Group.CHAT_USER, operate=Operate.USER_GROUP, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ CHAT_USER_QUOTA_SETTING = (
+ Permission(group=Group.CHAT_USER, sub_group=Group.CHAT_USER, operate=Operate.QUOTA_SETTING, bit_index=6),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 对话用户组 ====================
+ USER_GROUP_READ = (
+ Permission(group=Group.CHAT_USER_GROUP, sub_group=Group.CHAT_USER_GROUP, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ USER_GROUP_CREATE = (
+ Permission(group=Group.CHAT_USER_GROUP, sub_group=Group.CHAT_USER_GROUP, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ USER_GROUP_EDIT = (
+ Permission(group=Group.CHAT_USER_GROUP, sub_group=Group.CHAT_USER_GROUP, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ USER_GROUP_DELETE = (
+ Permission(group=Group.CHAT_USER_GROUP, sub_group=Group.CHAT_USER_GROUP, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ USER_GROUP_ADD_MEMBER = (
+ Permission(
+ group=Group.CHAT_USER_GROUP, sub_group=Group.CHAT_USER_GROUP, operate=Operate.ADD_MEMBER, bit_index=4
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ USER_GROUP_REMOVE_MEMBER = (
+ Permission(
+ group=Group.CHAT_USER_GROUP, sub_group=Group.CHAT_USER_GROUP, operate=Operate.REMOVE_MEMBER, bit_index=5
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
+ category=Category.CHAT_CLIENT,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 对话用户认证 ====================
+ CHAT_USER_AUTH_READ = (
+ Permission(group=Group.CHAT_USER_AUTH, sub_group=Group.CHAT_USER_AUTH, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.CHAT_CLIENT, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ CHAT_USER_AUTH_EDIT = (
+ Permission(group=Group.CHAT_USER_AUTH, sub_group=Group.CHAT_USER_AUTH, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.CHAT_CLIENT, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ PORTAL_READ = (
+ Permission(group=Group.PORTAL, sub_group=Group.PORTAL, operate=Operate.READ, bit_index=0),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ PORTAL_EDIT = (
+ Permission(group=Group.PORTAL, sub_group=Group.PORTAL, operate=Operate.EDIT, bit_index=0),
+ PermissionMeta(role_list=[RoleConstants.ADMIN], scope=[PermissionScopeConstants.SYSTEM]),
+ )
+
+ # ==================== 共享工具 ====================
+ SHARED_TOOL_READ = (
+ Permission(group=Group.SYSTEM_TOOL, sub_group=Group.SYSTEM_TOOL, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_TOOL_CREATE = (
+ Permission(group=Group.SYSTEM_TOOL, sub_group=Group.SYSTEM_TOOL, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_TOOL_EDIT = (
+ Permission(group=Group.SYSTEM_TOOL, sub_group=Group.SYSTEM_TOOL, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_TOOL_DELETE = (
+ Permission(group=Group.SYSTEM_TOOL, sub_group=Group.SYSTEM_TOOL, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_TOOL_IMPORT = (
+ Permission(group=Group.SYSTEM_TOOL, sub_group=Group.SYSTEM_TOOL, operate=Operate.IMPORT, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_TOOL_EXPORT = (
+ Permission(group=Group.SYSTEM_TOOL, sub_group=Group.SYSTEM_TOOL, operate=Operate.EXPORT, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_TOOL_PUBLISH = (
+ Permission(group=Group.SYSTEM_TOOL, sub_group=Group.SYSTEM_TOOL, operate=Operate.PUBLISH, bit_index=6),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_TOOL_RELATE_RESOURCE_VIEW = (
+ Permission(group=Group.SYSTEM_TOOL, sub_group=Group.SYSTEM_TOOL, operate=Operate.RELATE_VIEW, bit_index=7),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_TOOL_EXECUTE_RECORD = (
+ Permission(group=Group.SYSTEM_TOOL, sub_group=Group.SYSTEM_TOOL, operate=Operate.RECORD, bit_index=8),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_TOOL_TO_WORKSPACE = (
+ Permission(group=Group.SYSTEM_TOOL, sub_group=Group.SYSTEM_TOOL, operate=Operate.TO_WORKSPACE, bit_index=9),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 共享知识库 ====================
+ SHARED_KNOWLEDGE_READ = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_KNOWLEDGE, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_CREATE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_KNOWLEDGE, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_EDIT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_KNOWLEDGE, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_SYNC = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_KNOWLEDGE, operate=Operate.SYNC, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_VECTOR = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_KNOWLEDGE, operate=Operate.VECTOR, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_EXPORT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_KNOWLEDGE, operate=Operate.EXPORT, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_GENERATE = (
+ Permission(
+ group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_KNOWLEDGE, operate=Operate.GENERATE, bit_index=6
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DELETE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_KNOWLEDGE, operate=Operate.DELETE, bit_index=7),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_RELATE_RESOURCE_VIEW = (
+ Permission(
+ group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_KNOWLEDGE, operate=Operate.RELATE_VIEW, bit_index=8
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_TO_WORKSPACE = (
+ Permission(
+ group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_KNOWLEDGE, operate=Operate.TO_WORKSPACE, bit_index=9
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 共享知识库工作流
+ SHARED_KNOWLEDGE_WORKFLOW_READ = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_WORKFLOW, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_WORKFLOW_EDIT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_WORKFLOW, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_WORKFLOW_EXPORT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_WORKFLOW, operate=Operate.EXPORT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_WORKFLOW_PUBLISH = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_WORKFLOW, operate=Operate.PUBLISH, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 共享知识库文档
+ SHARED_KNOWLEDGE_DOCUMENT_READ = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_CREATE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_EDIT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_DELETE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_SYNC = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.SYNC, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_EXPORT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.EXPORT, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_DOWNLOAD_SOURCE_FILE = (
+ Permission(
+ group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.DOWNLOAD, bit_index=6
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_GENERATE = (
+ Permission(
+ group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.GENERATE, bit_index=7
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_VECTOR = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.VECTOR, bit_index=8),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_MIGRATE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.MIGRATE, bit_index=9),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_TAG = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.TAG, bit_index=10),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_REPLACE = (
+ Permission(
+ group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.REPLACE, bit_index=11
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_DOCUMENT_TOKEN = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.TOKEN, bit_index=12),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 共享知识库标签
+ SHARED_KNOWLEDGE_TAG_READ = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_TAG, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_TAG_CREATE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_TAG, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_TAG_EDIT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_TAG, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_TAG_DELETE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_TAG, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 共享知识库问题
+ SHARED_KNOWLEDGE_PROBLEM_READ = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_PROBLEM, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_PROBLEM_CREATE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_PROBLEM, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_PROBLEM_EDIT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_PROBLEM, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_PROBLEM_DELETE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_PROBLEM, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_PROBLEM_RELATE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_PROBLEM, operate=Operate.RELATE, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 共享知识库术语库
+ SHARED_KNOWLEDGE_TERMBASE_READ = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_TERMBASE, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_TERMBASE_CREATE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_TERMBASE, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_TERMBASE_EDIT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_TERMBASE, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_TERMBASE_DELETE = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_TERMBASE, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_TERMBASE_EXPORT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_TERMBASE, operate=Operate.EXPORT, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 共享知识库命中测试
+ SHARED_KNOWLEDGE_HIT_TEST = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_HIT_TEST, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 共享知识库对话用户
+ SHARED_KNOWLEDGE_CHAT_USER_READ = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_CHAT_USER, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_KNOWLEDGE_CHAT_USER_EDIT = (
+ Permission(group=Group.SYSTEM_KNOWLEDGE, sub_group=Group.SYSTEM_CHAT_USER, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 共享模型 ====================
+ SHARED_MODEL_READ = (
+ Permission(group=Group.SYSTEM_MODEL, sub_group=Group.SYSTEM_MODEL, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_MODEL_CREATE = (
+ Permission(group=Group.SYSTEM_MODEL, sub_group=Group.SYSTEM_MODEL, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_MODEL_EDIT = (
+ Permission(group=Group.SYSTEM_MODEL, sub_group=Group.SYSTEM_MODEL, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_MODEL_DELETE = (
+ Permission(group=Group.SYSTEM_MODEL, sub_group=Group.SYSTEM_MODEL, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_MODEL_RELATE_RESOURCE_VIEW = (
+ Permission(group=Group.SYSTEM_MODEL, sub_group=Group.SYSTEM_MODEL, operate=Operate.RELATE_VIEW, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ SHARED_MODEL_TO_WORKSPACE = (
+ Permission(group=Group.SYSTEM_MODEL, sub_group=Group.SYSTEM_MODEL, operate=Operate.TO_WORKSPACE, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.SHARED,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 资源管理 - 应用 ====================
+ RESOURCE_APPLICATION_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.READ,
+ bit_index=0,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_EDIT = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.EDIT,
+ bit_index=1,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_DELETE = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.DELETE,
+ bit_index=2,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_EXPORT = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.EXPORT,
+ bit_index=3,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_COPY = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.COPY,
+ bit_index=4,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_AUTH = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.AUTH,
+ bit_index=5,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_PUBLISH = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.PUBLISH,
+ bit_index=6,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_TRIGGER_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.TRIGGER_READ,
+ bit_index=7,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_TRIGGER_CREATE = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.TRIGGER_CREATE,
+ bit_index=8,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_TRIGGER_EDIT = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.TRIGGER_EDIT,
+ bit_index=9,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_TRIGGER_DELETE = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.TRIGGER_DELETE,
+ bit_index=10,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_RELATE_RESOURCE_VIEW = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_RES_APPLICATION,
+ operate=Operate.RELATE_VIEW,
+ bit_index=11,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 应用概览
+ RESOURCE_APPLICATION_OVERVIEW_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_OVERVIEW, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_OVERVIEW_EMBED = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_OVERVIEW, operate=Operate.EMBED, bit_index=1
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_OVERVIEW_ACCESS = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_OVERVIEW, operate=Operate.ACCESS, bit_index=2
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_OVERVIEW_DISPLAY = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_OVERVIEW, operate=Operate.DISPLAY, bit_index=3
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_OVERVIEW_API_KEY = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_OVERVIEW, operate=Operate.API_KEY, bit_index=4
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_OVERVIEW_PUBLIC = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_OVERVIEW,
+ operate=Operate.PUBLIC_ACCESS,
+ bit_index=5,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 应用接入
+ RESOURCE_APPLICATION_ACCESS_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_ACCESS, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_ACCESS_EDIT = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_ACCESS, operate=Operate.EDIT, bit_index=1
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 应用对话用户
+ RESOURCE_APPLICATION_CHAT_USER_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_CHAT_USER, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_CHAT_USER_EDIT = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_CHAT_USER, operate=Operate.EDIT, bit_index=1
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 应用对话日志
+ RESOURCE_APPLICATION_CHAT_LOG_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_CHAT_LOG, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_CHAT_LOG_ADD_KNOWLEDGE = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_CHAT_LOG,
+ operate=Operate.ADD_KNOWLEDGE,
+ bit_index=1,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_CHAT_LOG_ANNOTATION = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_CHAT_LOG, operate=Operate.ANNOTATION, bit_index=2
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_CHAT_LOG_EXPORT = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION, sub_group=Group.SYSTEM_CHAT_LOG, operate=Operate.EXPORT, bit_index=3
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_APPLICATION_CHAT_LOG_CLEAR_POLICY = (
+ Permission(
+ group=Group.SYSTEM_RES_APPLICATION,
+ sub_group=Group.SYSTEM_CHAT_LOG,
+ operate=Operate.CLEAR_POLICY,
+ bit_index=4,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 资源管理 - 知识库 ====================
+ RESOURCE_KNOWLEDGE_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_EDIT = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.EDIT, bit_index=1
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DELETE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.DELETE, bit_index=2
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_SYNC = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.SYNC, bit_index=3
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_EXPORT = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.EXPORT, bit_index=4
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_PUBLISH = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.PUBLISH, bit_index=5
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_VECTOR = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.VECTOR, bit_index=6
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_GENERATE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE,
+ sub_group=Group.SYSTEM_RES_KNOWLEDGE,
+ operate=Operate.GENERATE,
+ bit_index=7,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_AUTH = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.AUTH, bit_index=8
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_RELATE_RESOURCE_VIEW = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE,
+ sub_group=Group.SYSTEM_RES_KNOWLEDGE,
+ operate=Operate.RELATE_VIEW,
+ bit_index=9,
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 知识库工作流
+ RESOURCE_KNOWLEDGE_WORKFLOW_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_WORKFLOW, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_WORKFLOW_EDIT = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_WORKFLOW, operate=Operate.EDIT, bit_index=1
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_WORKFLOW_EXPORT = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_WORKFLOW, operate=Operate.EXPORT, bit_index=2
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_WORKFLOW_PUBLISH = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_WORKFLOW, operate=Operate.PUBLISH, bit_index=3
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 知识库文档
+ RESOURCE_KNOWLEDGE_DOCUMENT_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_CREATE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.CREATE, bit_index=1
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_EDIT = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.EDIT, bit_index=2
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_DELETE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.DELETE, bit_index=3
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_SYNC = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.SYNC, bit_index=4
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_EXPORT = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.EXPORT, bit_index=5
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_DOWNLOAD_SOURCE_FILE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.DOWNLOAD, bit_index=6
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_GENERATE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.GENERATE, bit_index=7
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_VECTOR = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.VECTOR, bit_index=8
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_MIGRATE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.MIGRATE, bit_index=9
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_TAG = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.TAG, bit_index=10
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_REPLACE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.REPLACE, bit_index=11
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_DOCUMENT_TOKEN = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_DOCUMENT, operate=Operate.TOKEN, bit_index=12
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 知识库命中测试
+ RESOURCE_KNOWLEDGE_HIT_TEST = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_HIT_TEST, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 知识库问题
+ RESOURCE_KNOWLEDGE_PROBLEM_READ = (
+ Permission(group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_PROBLEM, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_PROBLEM_CREATE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_PROBLEM, operate=Operate.CREATE, bit_index=1
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_PROBLEM_EDIT = (
+ Permission(group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_PROBLEM, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_PROBLEM_DELETE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_PROBLEM, operate=Operate.DELETE, bit_index=3
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_PROBLEM_RELATE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_PROBLEM, operate=Operate.RELATE, bit_index=4
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 知识库术语库
+ RESOURCE_KNOWLEDGE_TERMBASE_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_TERMBASE, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_TERMBASE_CREATE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_TERMBASE, operate=Operate.CREATE, bit_index=1
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_TERMBASE_EDIT = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_TERMBASE, operate=Operate.EDIT, bit_index=2
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_TERMBASE_DELETE = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_TERMBASE, operate=Operate.DELETE, bit_index=3
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_TERMBASE_EXPORT = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_TERMBASE, operate=Operate.EXPORT, bit_index=4
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 知识库标签
+ RESOURCE_KNOWLEDGE_TAG_READ = (
+ Permission(group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_TAG, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_TAG_CREATE = (
+ Permission(group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_TAG, operate=Operate.CREATE, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_TAG_EDIT = (
+ Permission(group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_TAG, operate=Operate.EDIT, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_TAG_DELETE = (
+ Permission(group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_TAG, operate=Operate.DELETE, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # 资源管理 - 知识库对话用户
+ RESOURCE_KNOWLEDGE_CHAT_USER_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_CHAT_USER, operate=Operate.READ, bit_index=0
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_KNOWLEDGE_CHAT_USER_EDIT = (
+ Permission(
+ group=Group.SYSTEM_RES_KNOWLEDGE, sub_group=Group.SYSTEM_CHAT_USER, operate=Operate.EDIT, bit_index=1
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 资源管理 - 工具 ====================
+ RESOURCE_TOOL_READ = (
+ Permission(group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_EDIT = (
+ Permission(group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_DELETE = (
+ Permission(group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.DELETE, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_EXPORT = (
+ Permission(group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.EXPORT, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_PUBLISH = (
+ Permission(group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.PUBLISH, bit_index=4),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_AUTH = (
+ Permission(group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.AUTH, bit_index=5),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_RELATE_RESOURCE_VIEW = (
+ Permission(
+ group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.RELATE_VIEW, bit_index=6
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_EXECUTE_RECORD = (
+ Permission(group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.RECORD, bit_index=7),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_TRIGGER_READ = (
+ Permission(
+ group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.TRIGGER_READ, bit_index=8
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_TRIGGER_CREATE = (
+ Permission(
+ group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.TRIGGER_CREATE, bit_index=9
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_TRIGGER_EDIT = (
+ Permission(
+ group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.TRIGGER_EDIT, bit_index=10
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_TOOL_TRIGGER_DELETE = (
+ Permission(
+ group=Group.SYSTEM_RES_TOOL, sub_group=Group.SYSTEM_RES_TOOL, operate=Operate.TRIGGER_DELETE, bit_index=11
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 资源管理 - 模型 ====================
+ RESOURCE_MODEL_READ = (
+ Permission(group=Group.SYSTEM_RES_MODEL, sub_group=Group.SYSTEM_RES_MODEL, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_MODEL_EDIT = (
+ Permission(group=Group.SYSTEM_RES_MODEL, sub_group=Group.SYSTEM_RES_MODEL, operate=Operate.EDIT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_MODEL_DELETE = (
+ Permission(group=Group.SYSTEM_RES_MODEL, sub_group=Group.SYSTEM_RES_MODEL, operate=Operate.DELETE, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_MODEL_AUTH = (
+ Permission(group=Group.SYSTEM_RES_MODEL, sub_group=Group.SYSTEM_RES_MODEL, operate=Operate.AUTH, bit_index=3),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ RESOURCE_MODEL_RELATE_RESOURCE_VIEW = (
+ Permission(
+ group=Group.SYSTEM_RES_MODEL, sub_group=Group.SYSTEM_RES_MODEL, operate=Operate.RELATE_VIEW, bit_index=4
+ ),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN],
+ category=Category.RESOURCE,
+ is_ee=is_ee,
+ scope=[PermissionScopeConstants.SYSTEM],
+ ),
+ )
+
+ # ==================== 操作日志 ====================
+ OPERATION_LOG_READ = (
+ Permission(group=Group.OPERATION_LOG, sub_group=Group.OPERATION_LOG, operate=Operate.READ, bit_index=0),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.OPERATION_LOG, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ OPERATION_LOG_EXPORT = (
+ Permission(group=Group.OPERATION_LOG, sub_group=Group.OPERATION_LOG, operate=Operate.EXPORT, bit_index=1),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.OPERATION_LOG, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ OPERATION_LOG_CLEAR_POLICY = (
+ Permission(group=Group.OPERATION_LOG, sub_group=Group.OPERATION_LOG, operate=Operate.CLEAR_POLICY, bit_index=2),
+ PermissionMeta(
+ role_list=[RoleConstants.ADMIN], category=Category.OPERATION_LOG, scope=[PermissionScopeConstants.SYSTEM]
+ ),
+ )
+
+ def __init__(self, value, meta):
+ self._value_ = value
+ self.meta = meta
+
+ def _build_workspace_permission(self, resource_id_key=None):
+ def permission_factory(_, kwargs):
+ return Permission(
+ group=self.value.group,
+ sub_group=self.value.sub_group,
+ operate=self.value.operate,
+ bit_index=self.value.bit_index,
+ workspace_id=kwargs.get("workspace_id"),
+ resource_id=kwargs.get(resource_id_key) if resource_id_key else None,
+ )
+
+ return permission_factory
+
+ def get_workspace_application_permission(self):
+ return self._build_workspace_permission(resource_id_key="application_id")
+
+ def get_workspace_knowledge_permission(self):
+ return self._build_workspace_permission(resource_id_key="knowledge_id")
+
+ def get_workspace_model_permission(self):
+ return self._build_workspace_permission(resource_id_key="model_id")
+
+ def get_workspace_tool_permission(self):
+ return self._build_workspace_permission(resource_id_key="tool_id")
+
+ def get_workspace_permission(self):
+ return self._build_workspace_permission()
+
+ def get_workspace_permission_workspace_manage_role(self):
+ """
+ 工作空间管理员的特权权限
+ @return: 工作空间管理员特权权限
+ """
+
+ def permission_factory(_, kwargs):
+ return Permission(
+ group=self.value.group,
+ sub_group=self.value.sub_group,
+ operate=self.value.operate,
+ bit_index=self.value.bit_index,
+ workspace_id=kwargs.get("workspace_id"),
+ flag=RoleConstants.WORKSPACE_MANAGE.value,
+ )
+
+ return permission_factory
+
+
+def group_by_all_resource_permissions() -> Dict[str, List[Permission]]:
+ grouped = {}
+
+ for _permission in PermissionConstants:
+ meta = _permission.meta
+ if meta.resource_permission_group_list:
+ for group in meta.resource_permission_group_list:
+ _array = grouped.get(str(group)) or []
+ _array.append(_permission)
+ grouped[str(group)] = _array
+ return dict(grouped)
+
+
+def group_permissions_by_scope() -> Dict[str, List[Permission]]:
+ grouped = {}
+
+ for _permission in PermissionConstants:
+ permission = _permission.value
+ meta = _permission.meta
+ if meta.scope:
+ for scope_item in meta.scope:
+ _array = grouped.get(scope_item) or []
+ _array.append(_permission)
+ grouped[scope_item] = _array
+ return dict(grouped)
+
+
+# 权限字符串与权限对象的Map
+PERMISSION_STR_MAP = {_permission.value.__str__(): _permission for _permission in PermissionConstants}
+
+# 资源组Map
+RESOURCE_PERMISSION_MAP = group_by_all_resource_permissions()
+
+# 权限 SCOPE Map
+SCOPE_PERMISSION_MAP = group_permissions_by_scope()
diff --git a/apps/common/auth/constants/permission_scope_constants.py b/apps/common/auth/constants/permission_scope_constants.py
new file mode 100644
index 00000000000..122057f7825
--- /dev/null
+++ b/apps/common/auth/constants/permission_scope_constants.py
@@ -0,0 +1,15 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: permission_scope_constants.py
+ @date:2026/8/4 11:50
+ @desc:
+"""
+from enum import Enum
+
+
+class PermissionScopeConstants(Enum):
+ SYSTEM = 'SYSTEM'
+ WORKSPACE = 'WORKSPACE'
+ WORKSPACE_RESOURCE = 'WORKSPACE_RESOURCE'
diff --git a/apps/common/auth/constants/resource_auth_type_constants.py b/apps/common/auth/constants/resource_auth_type_constants.py
new file mode 100644
index 00000000000..c8079fa0eca
--- /dev/null
+++ b/apps/common/auth/constants/resource_auth_type_constants.py
@@ -0,0 +1,21 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: resource_auth_type_constants.py
+ @date:2026/8/4 15:31
+ @desc:
+"""
+
+from django.db import models
+
+
+class ResourceAuthType(models.TextChoices):
+ """
+ 资源授权类型
+ """
+ "当授权类型是Role时候"
+ ROLE = "ROLE"
+
+ """资源权限组"""
+ RESOURCE_PERMISSION_GROUP = "RESOURCE_PERMISSION_GROUP"
diff --git a/apps/common/auth/constants/role_constants.py b/apps/common/auth/constants/role_constants.py
new file mode 100644
index 00000000000..94b250b9522
--- /dev/null
+++ b/apps/common/auth/constants/role_constants.py
@@ -0,0 +1,34 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: role_constants.py
+ @date:2026/8/4 9:50
+ @desc:
+"""
+from enum import Enum
+
+from common.auth.constants.role_group import RoleGroup
+from common.auth.struct.permission import Role, RoleMeta
+
+
+class RoleConstants(Enum):
+ ADMIN = (Role("ADMIN"), RoleMeta('系统管理员', RoleGroup.SYSTEM_USER))
+ WORKSPACE_MANAGE = (Role("WORKSPACE_MANAGE"), RoleMeta('工作空间管理员', RoleGroup.SYSTEM_USER))
+ USER = (Role("USER"), RoleMeta('普通用户', RoleGroup.SYSTEM_USER))
+ EXTENDS_ADMIN = (Role("EXTENDS_ADMIN"), RoleMeta('继承系统管理员', RoleGroup.SYSTEM_USER))
+ EXTENDS_WORKSPACE_MANAGE = (Role("EXTENDS_WORKSPACE_MANAGE"), RoleMeta('继承工作空间管理员', RoleGroup.SYSTEM_USER))
+ EXTENDS_USER = (Role("EXTENDS_USER"), RoleMeta('继承普通用户', RoleGroup.SYSTEM_USER))
+
+ CHAT_ANONYMOUS_USER = (Role("CHAT_ANONYMOUS_USER"), RoleMeta('对话匿名用户', RoleGroup.CHAT_USER))
+ CHAT_USER = (Role("CHAT_USER"), RoleMeta('对话用户', RoleGroup.CHAT_USER))
+
+ def __init__(self, value, meta):
+ self._value_ = value
+ self.meta = meta
+
+ def __str__(self):
+ return self.value.__str__()
+
+ def get_workspace_role(self):
+ return lambda r, kwargs: Role(self.value.name, kwargs.get('workspace_id'))
diff --git a/apps/common/auth/constants/role_group.py b/apps/common/auth/constants/role_group.py
new file mode 100644
index 00000000000..95abccd2ef1
--- /dev/null
+++ b/apps/common/auth/constants/role_group.py
@@ -0,0 +1,16 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: role_group.py
+ @date:2026/8/4 9:57
+ @desc:
+"""
+from enum import Enum
+
+
+class RoleGroup(Enum):
+ # 系统用户
+ SYSTEM_USER = "SYSTEM_USER"
+ # 对话用户
+ CHAT_USER = "CHAT_USER"
diff --git a/apps/common/auth/handle/impl/application_key.py b/apps/common/auth/handle/impl/application_key.py
index 259f21b872d..661f3b5ca98 100644
--- a/apps/common/auth/handle/impl/application_key.py
+++ b/apps/common/auth/handle/impl/application_key.py
@@ -1,18 +1,20 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: application_key.py
- @date:2025/7/10 03:02
- @desc: 应用api key认证
+@project: MaxKB
+@Author:虎虎
+@file: application_key.py
+@date:2025/7/10 03:02
+@desc: 应用api key认证
"""
+
from django.db.models import QuerySet
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from application.models import ApplicationApiKey, ChatUserType, ApplicationAccessToken
+from common.auth.constants.group_constants import Group
from common.auth.handle.auth_base_handle import AuthBaseHandle
-from common.constants.permission_constants import Permission, Group, Operate, RoleConstants, ChatAuth
+from common.auth.struct.auth import Principal, Auth
from common.exception.app_exception import AppAuthenticationFailed
@@ -20,26 +22,25 @@ class ApplicationKey(AuthBaseHandle):
def handle(self, request, token: str, get_token_details):
application_api_key = QuerySet(ApplicationApiKey).filter(secret_key=token).first()
if application_api_key is None:
- raise AppAuthenticationFailed(500, _('Secret key is invalid'))
+ raise AppAuthenticationFailed(500, _("Secret key is invalid"))
if not application_api_key.is_active:
- raise AppAuthenticationFailed(500, _('Secret key is invalid'))
+ raise AppAuthenticationFailed(500, _("Secret key is invalid"))
if application_api_key.is_permanent is False and application_api_key.expire_time < timezone.now():
- raise AppAuthenticationFailed(500, _('Secret key is expired'))
- application_access_token = QuerySet(ApplicationAccessToken).filter(
- application_id=application_api_key.application_id).first()
+ raise AppAuthenticationFailed(500, _("Secret key is expired"))
+ application_access_token = (
+ QuerySet(ApplicationAccessToken).filter(application_id=application_api_key.application_id).first()
+ )
if application_access_token is not None:
if application_access_token.authentication:
- if application_access_token.authentication_value.get('type',
- 'password') != 'password':
- raise AppAuthenticationFailed(1002, _('Authentication information is incorrect'))
- return None, ChatAuth(
- current_role_list=[RoleConstants.CHAT_ANONYMOUS_USER],
- permission_list=[
- Permission(group=Group.APPLICATION,
- operate=Operate.READ)],
- application_id=application_api_key.application_id,
- chat_user_id=str(application_api_key.id),
- chat_user_type=ChatUserType.APPLICATION_API_KEY.value)
+ if application_access_token.authentication_value.get("type", "password") != "password":
+ raise AppAuthenticationFailed(1002, _("Authentication information is incorrect"))
+
+ k = f"{Group.CHAT_USER}:r:{application_access_token.application_id}"
+ return Principal(
+ str(application_api_key.id),
+ ChatUserType.APPLICATION_API_KEY,
+ application_id=str(application_api_key.application_id),
+ ), Auth(set(), {k: 1})
def support(self, request, token: str, get_token_details):
- return str(token).startswith("application-") or str(token).startswith('agent-')
+ return str(token).startswith("application-") or str(token).startswith("agent-")
diff --git a/apps/common/auth/handle/impl/chat_anonymous_user_token.py b/apps/common/auth/handle/impl/chat_anonymous_user_token.py
deleted file mode 100644
index 7d8cc56e533..00000000000
--- a/apps/common/auth/handle/impl/chat_anonymous_user_token.py
+++ /dev/null
@@ -1,56 +0,0 @@
-# coding=utf-8
-"""
- @project: MaxKB
- @Author:虎虎
- @file: chat_anonymous_user_token.py
- @date:2025/6/6 15:08
- @desc:
-"""
-from django.db.models import QuerySet
-from django.utils.translation import gettext_lazy as _
-
-from application.models import ApplicationAccessToken
-from common.auth.common import ChatUserToken
-from common.auth.handle.auth_base_handle import AuthBaseHandle
-from common.constants.authentication_type import AuthenticationType
-from common.constants.permission_constants import RoleConstants, Permission, Group, Operate, ChatAuth
-from common.database_model_manage.database_model_manage import DatabaseModelManage
-from common.exception.app_exception import AppAuthenticationFailed
-from maxkb.settings import edition
-
-
-class ChatAnonymousUserToken(AuthBaseHandle):
- def support(self, request, token: str, get_token_details):
- token_details = get_token_details()
- if token_details is None:
- return False
- return (
- 'application_id' in token_details and
- 'access_token' in token_details and
- token_details.get('type') == AuthenticationType.CHAT_ANONYMOUS_USER.value)
-
- def handle(self, request, token: str, get_token_details):
- auth_details = get_token_details()
- chat_user_token = ChatUserToken.new_instance(auth_details)
- application_id = chat_user_token.application_id
- access_token = chat_user_token.access_token
- application_access_token = QuerySet(ApplicationAccessToken).filter(
- application_id=application_id).first()
- if application_access_token is None:
- raise AppAuthenticationFailed(1002, _('Authentication information is incorrect'))
- if not application_access_token.is_active:
- raise AppAuthenticationFailed(1002, _('Authentication information is incorrect'))
- if not application_access_token.access_token == access_token:
- raise AppAuthenticationFailed(1002, _('Authentication information is incorrect'))
- if application_access_token.authentication and ['PE', 'EE'].__contains__(edition):
- if chat_user_token.authentication.auth_type != application_access_token.authentication_value.get('type',
- ''):
- raise AppAuthenticationFailed(1002, _('Authentication information is incorrect'))
- return None, ChatAuth(
- current_role_list=[RoleConstants.CHAT_ANONYMOUS_USER],
- permission_list=[
- Permission(group=Group.APPLICATION,
- operate=Operate.USE)],
- application_id=application_access_token.application_id,
- chat_user_id=chat_user_token.chat_user_id,
- chat_user_type=chat_user_token.chat_user_type)
diff --git a/apps/common/auth/handle/impl/chat_user_token.py b/apps/common/auth/handle/impl/chat_user_token.py
new file mode 100644
index 00000000000..2dbc91d3edd
--- /dev/null
+++ b/apps/common/auth/handle/impl/chat_user_token.py
@@ -0,0 +1,130 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎
+@file: chat_anonymous_user_token.py
+@date:2025/6/6 15:08
+@desc:
+"""
+
+from functools import reduce
+
+from django.db.models import QuerySet, Q
+from django.utils.translation import gettext_lazy as _
+
+from application.models import ApplicationAccessToken, ChatUserType
+from common.auth.constants.chat_permission_constants import ChatPermissionConstants, CHAT_PERMISSION_STR_MAP
+from common.auth.constants.group_constants import Group
+from common.auth.constants.operate_constants import Operate
+from common.auth.handle.auth_base_handle import AuthBaseHandle
+from common.auth.struct.auth import Principal, Auth
+from common.constants.authentication_type import AuthenticationType
+from common.exception.app_exception import AppUnauthorizedFailed
+from system_manage.models import (
+ ResourceChatUserGroupAuthorize,
+ ResourceType,
+ ResourceChatUserAuthorize,
+ UserGroupRelation,
+ ChatUser,
+)
+
+login_type_list = [
+ Operate.LOCAL.value,
+ Operate.CAS.value,
+ Operate.DINGTALK.value,
+ Operate.WECOM.value,
+ Operate.LARK.value,
+ Operate.OIDC.value,
+ Operate.LDAP.value,
+ Operate.OAUTH2.value,
+]
+
+
+def get_auth(login_type, user_id, application_id):
+ application_access_token_list = QuerySet(ApplicationAccessToken).filter(is_active=True)
+ if login_type.upper() == str(Operate.ANNOTATION_AUTH):
+ application_access_token_list = application_access_token_list.filter(authentication=False)
+ elif login_type.upper() == str(Operate.PASSWORD):
+ application_access_token_list = application_access_token_list.filter(
+ authentication=True, authentication_value__type="password"
+ )
+
+ elif login_type_list.__contains__(login_type.upper()):
+ user_group_ids = (
+ QuerySet(UserGroupRelation)
+ .filter(
+ user_id=user_id,
+ )
+ .values_list("group_id", flat=True)
+ )
+
+ group_qs = (
+ QuerySet(ResourceChatUserGroupAuthorize)
+ .filter(
+ resource_type=ResourceType.APPLICATION,
+ is_auth=True,
+ user_group_id__in=user_group_ids,
+ )
+ .values_list("resource_id", flat=True)
+ )
+
+ user_qs = (
+ QuerySet(ResourceChatUserAuthorize)
+ .filter(
+ resource_type=ResourceType.APPLICATION,
+ is_auth=True,
+ user_id=user_id,
+ )
+ .values_list("resource_id", flat=True)
+ )
+ application_access_token_list = application_access_token_list.filter(
+ Q(authentication_value__type="login"),
+ Q(authentication_value__login_value__contains=login_type),
+ Q(application_id__in=group_qs) | Q(application_id__in=user_qs),
+ )
+ if application_id:
+ application_access_token_list = application_access_token_list.filter(application_id=application_id)
+ permissions = {}
+ for application_access_token in application_access_token_list:
+ permission_list = []
+ if application_access_token.authentication:
+ authentication_value = application_access_token.authentication_value
+ if authentication_value.get("type") == "login":
+ login_value = authentication_value.get("login_value") or []
+ for _value in login_value:
+ permission_str = f"{Group.CHAT_USER}_{_value.upper()}"
+ permission = CHAT_PERMISSION_STR_MAP.get(permission_str)
+ if permission:
+ permission_list.append(permission.value)
+
+ else:
+ permission_list.append(ChatPermissionConstants.CHAT_USER_ANONYMOUS.value)
+ k = f"{Group.CHAT_USER}:r:{application_access_token.application_id}"
+ permissions[k] = reduce(lambda x, y: x | y, [p.bit() for p in permission_list], 0)
+ return Auth(set(), permissions)
+
+
+class ChatUserToken(AuthBaseHandle):
+ def support(self, request, token: str, get_token_details):
+ token_details = get_token_details()
+ if token_details is None:
+ return False
+ return token_details.get("type") == AuthenticationType.CHAT_USER.value
+
+ def handle(self, request, token: str, get_token_details):
+ auth_details = get_token_details()
+ login_type = auth_details.get("login_type")
+ user_id = auth_details.get("id")
+ application_id = (auth_details.get("kwargs") or {}).get("application_id")
+ _type = (
+ ChatUserType.CHAT_USER if login_type_list.__contains__(login_type.upper()) else ChatUserType.ANONYMOUS_USER
+ )
+ auth = get_auth(login_type, user_id, application_id)
+ chat_user = QuerySet(ChatUser).filter(id=user_id).first()
+ if application_id:
+ # 指定了 application_id(v2 流程)时,直接校验该应用是否有权限,无权限直接抛错,
+ # 避免返回一个空权限的 Principal 造成静默失败。
+ if not auth.permissions.get(f"{Group.CHAT_USER}:r:{application_id}"):
+ raise AppUnauthorizedFailed(403, _("No permission to access"))
+ return Principal(user_id, _type, application_id=application_id, profile=chat_user), auth
+ return Principal(user_id, _type, profile=chat_user), auth
diff --git a/apps/common/auth/handle/impl/user_token.py b/apps/common/auth/handle/impl/user_token.py
index 18dd9d074df..cd65515dfab 100644
--- a/apps/common/auth/handle/impl/user_token.py
+++ b/apps/common/auth/handle/impl/user_token.py
@@ -1,233 +1,192 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: authenticate.py
- @date:2024/3/14 03:02
- @desc: 用户认证
+@project: MaxKB
+@Author:虎虎
+@file: authenticate.py
+@date:2024/3/14 03:02
+@desc: 用户认证
"""
+
from functools import reduce
-from typing import List
from django.core.cache import cache
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
+from common.auth.constants.permission_constants import RESOURCE_PERMISSION_MAP, PERMISSION_STR_MAP
+from common.auth.constants.permission_scope_constants import PermissionScopeConstants
+from common.constants.resource_permission_constants import ResourceAuthType
+from common.auth.constants.role_constants import RoleConstants
from common.auth.handle.auth_base_handle import AuthBaseHandle
-from common.constants.authentication_type import AuthenticationType
+from common.auth.struct.auth import Auth, Principal
+from common.constants.authentication_type import AuthenticationType, UserType
from common.constants.cache_version import Cache_Version
-from common.constants.permission_constants import Auth, PermissionConstants, ResourcePermissionGroup, \
- get_permission_list_by_resource_group, ResourceAuthType, \
- ResourcePermissionRole, get_default_role_permission_mapping_list, get_default_workspace_user_role_mapping_list, \
- RoleConstants, ResourcePermission, Resource, WorkspaceGroup
+
from common.database_model_manage.database_model_manage import DatabaseModelManage
from common.exception.app_exception import AppAuthenticationFailed
-from common.utils.common import group_by
+from common.utils.common import group_by, flat_map
from maxkb.const import CONFIG
+from system_manage.models.workspace_user_group_permission import WorkspaceUserGroupResourcePermission
from system_manage.models.workspace_user_permission import WorkspaceUserResourcePermission
from users.models import User
-permission_constants_dict = {p.value.__str__(): p for p in PermissionConstants}
-
-def get_permission(permission_id):
- """
- 获取权限字符串
- @param permission_id: 权限id
- @return: 权限字符串
- """
- if isinstance(permission_id, PermissionConstants):
- permission_id = permission_id.value
- return f"{permission_id}"
-
-
-def get_workspace_permission(permission_id, workspace_id, role=None):
- """
- 获取工作空间权限字符串
- @param permission_id: 权限id
- @param workspace_id: 工作空间id
- @param role: 角色
- @return:
- """
- if isinstance(permission_id, PermissionConstants):
- permission_id = permission_id.value
- if role and role.type == RoleConstants.WORKSPACE_MANAGE.value.__str__():
- return [f"{permission_id}:/WORKSPACE/{workspace_id}:ROLE/{role.type}",
- f"{permission_id}:/WORKSPACE/{workspace_id}"]
- return [f"{permission_id}:/WORKSPACE/{workspace_id}"]
-
-
-def get_role_permission(role, workspace_id):
- """
- 获取工作空间角色
- @param role: 角色
- @param workspace_id: 工作空间id
- @return:
- """
- if isinstance(role, RoleConstants):
- role = role.value
- return f"{role}:/WORKSPACE/{workspace_id}"
-
-
-def get_workspace_permission_list(role_permission_mapping_dict, workspace_user_role_mapping_list, role_model_dict):
- """
- 获取工作空间下所有的权限
- @param role_permission_mapping_dict: 角色权限关联字典
- @param workspace_user_role_mapping_list: 工作空间用户角色关联列表
- @param role_model_dict: 角色字典
- @return: 工作空间下的权限
- """
- workspace_permission_list = [
- [get_workspace_permission(role_permission_mapping.permission_id, w_u_r.workspace_id,
- role_model_dict.get(w_u_r.role_id, None)) for role_permission_mapping
- in
- role_permission_mapping_dict.get(w_u_r.role_id, [])] for w_u_r in workspace_user_role_mapping_list]
- return reduce(lambda x, y: [*x, *y], reduce(lambda x, y: [*x, *y], workspace_permission_list, []), [])
-
-
-def get_workspace_resource_permission_list(
- workspace_user_resource_permission_list: List[WorkspaceUserResourcePermission],
- role_permission_mapping_dict,
- workspace_user_role_mapping_dict):
- """
-
- @param workspace_user_resource_permission_list: 工作空间用户资源权限列表
- @param role_permission_mapping_dict: 角色权限关联字典 key为role_id
- @param workspace_user_role_mapping_dict: 工作空间用户角色映射字典 key为role_id
- @return: 工作空间资源权限列表
- """
- resource_permission_list = [
- get_workspace_resource_permission_list_by_workspace_user_permission(workspace_user_resource_permission,
- role_permission_mapping_dict,
- workspace_user_role_mapping_dict) for
- workspace_user_resource_permission in workspace_user_resource_permission_list]
- # 将二维数组扁平为一维
- return reduce(lambda x, y: [*x, *y], resource_permission_list, [])
-
-
-def get_workspace_resource_permission_list_by_workspace_user_permission(
- workspace_user_resource_permission: WorkspaceUserResourcePermission,
- role_permission_mapping_dict,
- workspace_user_role_mapping_dict):
- """
-
- @param workspace_user_resource_permission: 工作空间用户资源权限对象
- @param role_permission_mapping_dict: 角色权限关联字典 key为role_id
- @param workspace_user_role_mapping_dict: 工作空间用户角色关联字典 key为role_id
- @return: 工作空间用户资源的权限列表
- """
- # 判断用户在当前工作空间是否为内置USER
- workspace_role_ids = [
- wur.role_id
- for wur in
- workspace_user_role_mapping_dict.get(workspace_user_resource_permission.workspace_id,[])
- ]
- is_builtin_user = RoleConstants.USER.value.__str__() in workspace_role_ids
-
- role_permission_mapping_list = [role_permission_mapping_dict.get(workspace_user_role_mapping.role_id, []) for
- workspace_user_role_mapping in
- workspace_user_role_mapping_dict.get(
- workspace_user_resource_permission.workspace_id)]
- role_permission_mapping_list = reduce(lambda x, y: [*x, *y], role_permission_mapping_list, [])
- # 如果是根据角色
- if (workspace_user_resource_permission.auth_type == ResourceAuthType.ROLE
- and workspace_user_resource_permission.permission_list.__contains__(
- ResourcePermissionRole.ROLE)):
- per_op_permissions = [
- f"{role_permission_mapping.permission_id}:/WORKSPACE/{workspace_user_resource_permission.workspace_id}/{workspace_user_resource_permission.auth_target_type}/{workspace_user_resource_permission.target}"
- for role_permission_mapping in role_permission_mapping_list if (permission_constants_dict.get(role_permission_mapping.permission_id).value.parent_group or []).__contains__(
- WorkspaceGroup(workspace_user_resource_permission.auth_target_type))]
- if is_builtin_user:
- per_op_permissions.append(
- f"{workspace_user_resource_permission.auth_target_type}:/WORKSPACE/{workspace_user_resource_permission.workspace_id}/{workspace_user_resource_permission.auth_target_type}/{workspace_user_resource_permission.target}"
- )
- return per_op_permissions
- elif workspace_user_resource_permission.auth_type == ResourceAuthType.RESOURCE_PERMISSION_GROUP:
- resource_permission_list = [
- [
- f"{permission}:/WORKSPACE/{workspace_user_resource_permission.workspace_id}/{workspace_user_resource_permission.auth_target_type}/{workspace_user_resource_permission.target}"
- for permission in get_permission_list_by_resource_group(
- ResourcePermissionGroup(Resource(workspace_user_resource_permission.auth_target_type),
- ResourcePermission(resource_permission)))]
- for resource_permission in workspace_user_resource_permission.permission_list if
- ResourcePermission.values.__contains__(resource_permission)]
- # 将二维数组扁平为一维
- return reduce(lambda x, y: [*x, *y], resource_permission_list, [])
- return []
-
-def get_permission_list(user,
- workspace_user_role_mapping_model,
- workspace_model,
- role_model,
- role_permission_mapping_model):
+def get_permissions(
+ user, workspace_user_role_mapping_model, workspace_model, role_model, role_permission_mapping_model
+):
user_id = user.id
version = Cache_Version.PERMISSION_LIST.get_version()
key = Cache_Version.PERMISSION_LIST.get_key(user_id=user_id)
# 获取权限列表
- is_query_model = workspace_user_role_mapping_model is not None and workspace_model is not None and role_model is not None and role_permission_mapping_model is not None
- permission_list = cache.get(key, version=version)
- if permission_list is None:
+ is_query_model = (
+ workspace_user_role_mapping_model is not None
+ and workspace_model is not None
+ and role_model is not None
+ and role_permission_mapping_model is not None
+ )
+ permission_map = cache.get(key, version=version)
+ if permission_map is None:
+ permission_map = {}
if is_query_model:
# 获取工作空间 用户 角色映射数据
workspace_user_role_mapping_list = QuerySet(workspace_user_role_mapping_model).filter(user_id=user_id)
- workspace_user_role_mapping_dict = group_by(workspace_user_role_mapping_list,
- lambda item: item.workspace_id)
- role_id_list = list(set([workspace_user_role_mapping.role_id for workspace_user_role_mapping in
- workspace_user_role_mapping_list]))
+
+ role_id_list = list(
+ set(
+ [
+ workspace_user_role_mapping.role_id
+ for workspace_user_role_mapping in workspace_user_role_mapping_list
+ ]
+ )
+ )
# 获取角色权限映射数据
- role_permission_mapping_list = QuerySet(role_permission_mapping_model).filter(
- role_id__in=role_id_list)
+ role_permission_mapping_list = QuerySet(role_permission_mapping_model).filter(role_id__in=role_id_list)
role_model_list = QuerySet(role_model).filter(id__in=role_id_list)
role_model_dict = {role_model.id: role_model for role_model in role_model_list}
- role_permission_mapping_dict = group_by(
- role_permission_mapping_list, lambda item: item.role_id)
+ role_permission_mapping_dict = group_by(role_permission_mapping_list, lambda item: str(item.role_id))
workspace_user_permission_list = QuerySet(WorkspaceUserResourcePermission).filter(
- workspace_id__in=[workspace_user_role.workspace_id for workspace_user_role in
- workspace_user_role_mapping_list if
- (role_model_dict.get(workspace_user_role.role_id).type == 'USER' if
- role_model_dict.get(workspace_user_role.role_id) else False)],
- user_id=user_id)
-
- # 资源权限
- workspace_resource_permission_list = get_workspace_resource_permission_list(workspace_user_permission_list,
- role_permission_mapping_dict,
- workspace_user_role_mapping_dict)
+ workspace_id__in=[
+ workspace_user_role.workspace_id
+ for workspace_user_role in workspace_user_role_mapping_list
+ if (
+ role_model_dict.get(workspace_user_role.role_id).type == "USER"
+ if role_model_dict.get(workspace_user_role.role_id)
+ else False
+ )
+ ],
+ user_id=user_id,
+ )
- workspace_permission_list = get_workspace_permission_list(role_permission_mapping_dict,
- workspace_user_role_mapping_list, role_model_dict)
- # 系统权限
- system_permission_list = [role_permission_mapping.permission_id for role_permission_mapping in
- role_permission_mapping_list]
- # 合并权限
- permission_list = system_permission_list + workspace_permission_list + workspace_resource_permission_list
- permission_list = list(set(permission_list))
- cache.set(key, permission_list, version=version)
+ workspace_user_group_resource_permission_list = (
+ QuerySet(WorkspaceUserGroupResourcePermission)
+ .filter(user_group__user_relations__user_id=user_id)
+ .select_related("user_group")
+ .distinct()
+ )
+ "----------------------处理资源权限--------------------------------------------------"
+ for _ in list(workspace_user_permission_list) + list(workspace_user_group_resource_permission_list):
+ if _.auth_type == ResourceAuthType.RESOURCE_PERMISSION_GROUP:
+ all_permissions = flat_map(
+ [
+ RESOURCE_PERMISSION_MAP.get(f"{_.auth_target_type}_{_resource_permission}")
+ for _resource_permission in _.permission_list
+ if _resource_permission in ["VIEW", "MANAGE"]
+ ]
+ )
+ all_permissions = [_permission for _permission in all_permissions if _permission is not None]
+ for group, permissions in group_by(
+ all_permissions, lambda _permission: _permission.value.group
+ ).items():
+ k = f"{group}:{_.workspace_id}:{_.target}"
+ bits = reduce(lambda x, y: x | y, [_permission.value.bit() for _permission in permissions], 0)
+ permission_map[k] = permission_map.get(k, 0) | bits
+ elif _.auth_type == ResourceAuthType.ROLE:
+ role_ids = [m.role_id for m in workspace_user_role_mapping_list if m.workspace_id == _.workspace_id]
+
+ permissions = []
+ for role_id in role_ids:
+ for m in role_permission_mapping_dict.get(str(role_id)) or []:
+ p = PERMISSION_STR_MAP.get(m.permission_id)
+ if p is not None and PermissionScopeConstants.WORKSPACE in p.meta.scope:
+ permissions.append(p)
+
+ for group, ps in group_by(permissions, lambda p: p.value.group).items():
+ k = f"{group}:w:{_.workspace_id}:r:{_.target}"
+ bits = reduce(lambda x, y: x | y, [p.value.bit() for p in ps], 0)
+ permission_map[k] = permission_map.get(k, 0) | bits
+
+ "----------------------处理工作空间权限--------------------------------------------------"
+ for _ in workspace_user_role_mapping_list:
+ _role_permission_mapping_list = role_permission_mapping_dict.get(str(_.role_id)) or []
+ permissions = [
+ PERMISSION_STR_MAP.get(_role_permission_mapping.permission_id)
+ for _role_permission_mapping in _role_permission_mapping_list
+ ]
+ # 过滤工作空间权限
+ permissions = [
+ _permission
+ for _permission in permissions
+ if _permission is not None and PermissionScopeConstants.WORKSPACE in _permission.meta.scope
+ ]
+ for group, ps in group_by(permissions, lambda p: p.value.group).items():
+ k = f"{group}:w:{_.workspace_id}"
+ bits = reduce(lambda x, y: x | y, [p.value.bit() for p in ps], 0)
+ permission_map[k] = permission_map.get(k, 0) | bits
+ "----------------------处理系统权限--------------------------------------------------"
+ system_permissions = [
+ PERMISSION_STR_MAP.get(_role_permission_mapping.permission_id)
+ for _role_permission_mapping in role_permission_mapping_list
+ ]
+ system_permissions = [
+ _permission
+ for _permission in system_permissions
+ if _permission is not None and PermissionScopeConstants.SYSTEM in _permission.meta.scope
+ ]
+ for group, permissions in group_by(system_permissions, lambda _permission: _permission.value.group).items():
+ permission_map[f"{group}"] = reduce(
+ lambda x, y: x | y, [_permission.value.bit() for _permission in permissions], 0
+ )
+ cache.set(key, permission_map, version=version)
else:
- workspace_id_list = ['default']
- workspace_user_resource_permission_list = QuerySet(WorkspaceUserResourcePermission).filter(
- workspace_id__in=workspace_id_list, user_id=user_id)
- role_permission_mapping_list = get_default_role_permission_mapping_list()
- role_permission_mapping_dict = group_by(role_permission_mapping_list, lambda item: item.role_id)
- workspace_user_role_mapping_list = get_default_workspace_user_role_mapping_list([user.role])
- workspace_user_role_mapping_dict = group_by(workspace_user_role_mapping_list,
- lambda item: item.workspace_id)
- # 资源权限
- workspace_resource_permission_list = get_workspace_resource_permission_list(
- workspace_user_resource_permission_list,
- role_permission_mapping_dict,
- workspace_user_role_mapping_dict)
- # 合并权限
- permission_list = workspace_resource_permission_list
- permission_list = list(set(permission_list))
- cache.set(key, permission_list, version=version)
- return permission_list
-
+ workspace_id_list = ["default"]
+ workspace_user_permission_list = QuerySet(WorkspaceUserResourcePermission).filter(
+ workspace_id__in=workspace_id_list, user_id=user_id
+ )
+ workspace_user_group_resource_permission_list = (
+ QuerySet(WorkspaceUserGroupResourcePermission)
+ .filter(user_group__user_relations__user_id=user_id)
+ .select_related("user_group")
+ .distinct()
+ )
-system_role_list = [RoleConstants.ADMIN.value.name, RoleConstants.WORKSPACE_MANAGE.value.name,
- RoleConstants.USER.value.name]
+ for _ in list(workspace_user_permission_list) + list(workspace_user_group_resource_permission_list):
+ if _.auth_type == ResourceAuthType.RESOURCE_PERMISSION_GROUP:
+ all_permissions = flat_map(
+ [
+ RESOURCE_PERMISSION_MAP.get(f"{_.auth_target_type}_{_resource_permission}")
+ for _resource_permission in _.permission_list
+ if _resource_permission in ["VIEW", "MANAGE"]
+ ]
+ )
+ for group, permissions in group_by(
+ all_permissions, lambda _permission: _permission.value.group
+ ).items():
+ permission_map[f"{group}:w:{_.workspace_id}:r:{_.target}"] = reduce(
+ lambda x, y: x | y, [_permission.value.bit() for _permission in permissions], 0
+ )
+ cache.set(key, permission_map, version=version)
+
+ return permission_map
+
+
+system_role_list = [
+ RoleConstants.ADMIN.value.name,
+ RoleConstants.WORKSPACE_MANAGE.value.name,
+ RoleConstants.USER.value.name,
+]
system_role = RoleConstants.ADMIN.value.name
@@ -237,22 +196,18 @@ def reset_workspace_role(role_id, workspace_id, role_dict):
if system_role == role_id:
return [role_id]
else:
- return [f"{role_id}:/WORKSPACE/{workspace_id}", role_id]
+ return [f"{role_id}:w:{workspace_id}", role_id]
else:
r = role_dict.get(role_id)
if r is None:
- return ''
+ return []
role_type = role_dict.get(role_id).type
if system_role == role_type:
return [RoleConstants.EXTENDS_ADMIN.value.name]
- return [f"EXTENDS_{role_type}:/WORKSPACE/{workspace_id}", f"EXTENDS_{role_type}"]
+ return [f"EXTENDS_{role_type}:w:{workspace_id}"]
-def get_role_list(user,
- workspace_user_role_mapping_model,
- workspace_model,
- role_model,
- role_permission_mapping_model):
+def get_role_list(user, workspace_user_role_mapping_model, workspace_model, role_model, role_permission_mapping_model):
"""
获取当前用户的角色列表
"""
@@ -260,7 +215,12 @@ def get_role_list(user,
key = Cache_Version.ROLE_LIST.get_key(user_id=user.id)
role_list = cache.get(key, version=version)
# 获取权限列表
- is_query_model = workspace_user_role_mapping_model is not None and workspace_model is not None and role_model is not None and role_permission_mapping_model is not None
+ is_query_model = (
+ workspace_user_role_mapping_model is not None
+ and workspace_model is not None
+ and role_model is not None
+ and role_permission_mapping_model is not None
+ )
if role_list is None:
if is_query_model:
# 获取工作空间 用户 角色映射数据
@@ -268,18 +228,25 @@ def get_role_list(user,
role_list = QuerySet(role_model).filter(id__in=[wurm.role_id for wurm in workspace_user_role_mapping_list])
role_dict = {r.id: r for r in role_list}
role_list = list(
- set(reduce(lambda x, y: [*x, *y], [reset_workspace_role(workspace_user_role_mapping.role_id,
- workspace_user_role_mapping.workspace_id,
- role_dict)
- for
- workspace_user_role_mapping in
- workspace_user_role_mapping_list], [])))
+ set(
+ reduce(
+ lambda x, y: [*x, *y],
+ [
+ reset_workspace_role(
+ workspace_user_role_mapping.role_id, workspace_user_role_mapping.workspace_id, role_dict
+ )
+ for workspace_user_role_mapping in workspace_user_role_mapping_list
+ ],
+ [],
+ )
+ )
+ )
cache.set(key, role_list, version=version)
else:
if user.role == RoleConstants.ADMIN.value.__str__():
- role_list = [user.role, get_role_permission(RoleConstants.WORKSPACE_MANAGE, 'default')]
+ role_list = [user.role, f"{RoleConstants.WORKSPACE_MANAGE}:w:default"]
else:
- role_list = [user.role, get_role_permission(RoleConstants.USER, 'default')]
+ role_list = [user.role, f"{RoleConstants.USER}:w:default"]
cache.set(key, role_list, version=version)
return role_list
@@ -290,11 +257,13 @@ def get_auth(user):
role_model = DatabaseModelManage.get_model("role_model")
role_permission_mapping_model = DatabaseModelManage.get_model("role_permission_mapping_model")
- permission_list = get_permission_list(user, workspace_user_role_mapping_model, workspace_model,
- role_model, role_permission_mapping_model)
- role_list = get_role_list(user, workspace_user_role_mapping_model, workspace_model,
- role_model, role_permission_mapping_model)
- return Auth(role_list, permission_list)
+ permissions = get_permissions(
+ user, workspace_user_role_mapping_model, workspace_model, role_model, role_permission_mapping_model
+ )
+ role_list = get_role_list(
+ user, workspace_user_role_mapping_model, workspace_model, role_model, role_permission_mapping_model
+ )
+ return Auth(set(role_list), permissions)
class UserToken(AuthBaseHandle):
@@ -302,18 +271,18 @@ def support(self, request, token: str, get_token_details):
auth_details = get_token_details()
if auth_details is None:
return False
- return 'id' in auth_details and auth_details.get('type') == AuthenticationType.SYSTEM_USER.value
+ return "id" in auth_details and auth_details.get("type") == AuthenticationType.SYSTEM_USER.value
def handle(self, request, token: str, get_token_details):
version, get_key = Cache_Version.TOKEN.value
cache_token = cache.get(get_key(token), version=version)
if cache_token is None:
- raise AppAuthenticationFailed(1002, _('Login expired'))
+ raise AppAuthenticationFailed(1002, _("Login expired"))
auth_details = get_token_details()
timeout = CONFIG.get_session_timeout()
cache.touch(token, timeout=timeout, version=version)
- user = QuerySet(User).get(id=auth_details['id'])
+ user = QuerySet(User).get(id=auth_details["id"])
if not user.is_active or user.password != cache_token.password:
- raise AppAuthenticationFailed(1002, _('Authentication information is incorrect'))
+ raise AppAuthenticationFailed(1002, _("Authentication information is incorrect"))
auth = get_auth(user)
- return user, auth
+ return Principal(user.id, UserType.SYSTEM_USER, user), auth
diff --git a/apps/common/auth/struct/aggregate_permission.py b/apps/common/auth/struct/aggregate_permission.py
new file mode 100644
index 00000000000..cb1c7e92098
--- /dev/null
+++ b/apps/common/auth/struct/aggregate_permission.py
@@ -0,0 +1,85 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: aggregate_permission.py
+@date:2026/8/5 10:11
+@desc:
+"""
+
+from typing import Protocol, List, Union
+
+from rest_framework.request import Request
+
+from common.auth.constants.compare_constants import CompareConstants
+from common.auth.constants.permission_constants import PermissionConstants
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.permission import Role, Permission
+
+
+class RoleFunc(Protocol):
+ def __call__(self, request: Request, kwargs) -> RoleConstants | Role: ...
+
+
+class PermissionFunc(Protocol):
+ def __call__(self, request: Request, kwargs) -> PermissionConstants | Permission: ...
+
+
+class AggregatePermission:
+ def __init__(
+ self,
+ roles: List[Union[RoleConstants, RoleFunc]] = None,
+ permissions: List[Union[PermissionConstants, PermissionFunc]] = None,
+ aggregatePermissions: List["AggregatePermission"] = None,
+ compare: CompareConstants = CompareConstants.OR,
+ ):
+ # 不能用可变默认值;原 stub 的 `= list` 其实是把类型对象赋进去了
+ self.roles = roles if roles is not None else []
+ self.permissions = permissions if permissions is not None else []
+ self.aggregatePermissions = aggregatePermissions if aggregatePermissions is not None else []
+ self.compare = compare
+
+ def hasPermission(self, request: Request, **kwargs) -> bool:
+ user_roles = request.auth.roles
+ user_permissions = request.auth.permissions
+
+ # 无任何约束 => 放行(对应 Java 里五个集合全空的判断)
+ if not (self.roles or self.permissions or self.aggregatePermissions):
+ return True
+
+ is_and = self.compare == CompareConstants.AND
+
+ # 惰性产出每一项的命中结果,保证 OR/AND 的短路语义
+ # (return 后生成器不再前进,后面的 permission/role 不会被求值)
+ def results():
+ for role in self.roles:
+ resolved = role(request, kwargs) if callable(role) else role
+ yield self._match_role(resolved, user_roles)
+ for permission in self.permissions:
+ resolved = permission(request, kwargs) if callable(permission) else permission
+ yield self._match_permission(resolved, user_permissions)
+ for aggregate in self.aggregatePermissions:
+ yield aggregate.hasPermission(request, **kwargs)
+
+ for has in results():
+ if has and not is_and: # OR:命中一个即通过
+ return True
+ if not has and is_and: # AND:缺一个即失败
+ return False
+
+ # AND 全部通过 => True;OR 一个都没命中 => False
+ return is_and
+
+ @staticmethod
+ def _match_permission(permission: Union[PermissionConstants, Permission], user_permissions: dict) -> bool:
+ p = permission.value if isinstance(permission, PermissionConstants) else permission
+ key = p.get_resource_permission_key(p.resource_id) if p.resource_id else str(p)
+ return key in user_permissions and (user_permissions[key] & p.bit()) > 0
+
+ @staticmethod
+ def _match_role(role: Union[RoleConstants, Role], user_roles) -> bool:
+ r = role.value if isinstance(role, RoleConstants) else role
+ return str(r) in user_roles
+
+
+ViewPermission = AggregatePermission
diff --git a/apps/common/auth/struct/auth.py b/apps/common/auth/struct/auth.py
new file mode 100644
index 00000000000..2c87af82a19
--- /dev/null
+++ b/apps/common/auth/struct/auth.py
@@ -0,0 +1,41 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: auth.py
+ @date:2026/8/5 9:45
+ @desc:
+"""
+from typing import Dict
+
+from application.models import ChatUserType
+from common.auth.constants.role_constants import RoleConstants
+from common.auth.struct.permission import Role
+from common.constants.authentication_type import UserType
+
+
+class Auth:
+ """
+ 用于存储当前用户的角色和权限
+ """
+
+ def __init__(self,
+ roles: set[RoleConstants | Role | str],
+ permissions: Dict[str, int],
+ **kwargs):
+ # 权限列表
+ self.permissions = permissions
+ # 角色列表
+ self.roles = roles
+ self.kwargs = kwargs
+
+
+class Principal:
+ def __init__(self, _id,
+ _type: ChatUserType | UserType,
+ profile=None,
+ **kwargs):
+ self.id = _id
+ self.type = _type
+ self.profile = profile
+ self.kwargs = kwargs
diff --git a/apps/common/auth/struct/permission.py b/apps/common/auth/struct/permission.py
new file mode 100644
index 00000000000..5f3de1bac59
--- /dev/null
+++ b/apps/common/auth/struct/permission.py
@@ -0,0 +1,75 @@
+# coding=utf-8
+"""
+@project: MaxKB
+@Author:虎虎虎
+@file: permission.py
+@date:2026/8/3 17:29
+@desc:
+"""
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from common.auth.constants.category_constants import Category
+from common.auth.constants.group_constants import Group
+from common.auth.constants.operate_constants import Operate
+from common.auth.constants.permission_scope_constants import PermissionScopeConstants
+from common.auth.constants.role_group import RoleGroup
+
+
+@dataclass(frozen=True)
+class Permission:
+ """
+ 权限信息
+ """
+
+ group: Group | str
+ sub_group: Group | str
+ operate: Operate
+ bit_index: int
+ workspace_id: Optional[str] = None
+ resource_id: Optional[str] = None
+ flag: str = None
+
+ def bit(self):
+ return 1 << self.bit_index
+
+ def get_resource_permission_key(self, resource_id):
+ workspace = f":w:{self.workspace_id}" if self.workspace_id else ""
+ resource = f":r:{self.resource_id}" if self.resource_id else ""
+ return f"{self.group}{workspace}{resource}"
+
+ def __str__(self):
+ sub = f"_{self.sub_group}" if self.sub_group != self.group else ""
+ flag = f"_{self.flag}" if self.flag else ""
+ operate = f":{self.operate}" if self.operate else ""
+ return f"{self.group}{sub}{operate}{flag}"
+
+
+@dataclass
+class PermissionMeta:
+ role_list: list = field(default_factory=list)
+ category: Optional[Category] = None
+ resource_permission_group_list: Optional[list] = None
+ scope: list[PermissionScopeConstants] = field(default_factory=list)
+ is_ee: bool = True
+ # 角色 -> 分类 覆盖表;未列出的角色默认归入 OTHER
+ role_category_map: Optional[dict] = None
+
+
+@dataclass(frozen=True)
+class Role:
+ name: str
+ workspace_id: str = None
+
+ def __str__(self):
+ return f"{self.name}{(':w:' + self.workspace_id) if self.workspace_id else ''}"
+
+ def __eq__(self, other):
+ return str(self) == str(other)
+
+
+@dataclass(frozen=True)
+class RoleMeta:
+ desc: str
+ group: RoleGroup
diff --git a/apps/common/constants/authentication_type.py b/apps/common/constants/authentication_type.py
index 1880fe4d3cd..fe0696363ad 100644
--- a/apps/common/constants/authentication_type.py
+++ b/apps/common/constants/authentication_type.py
@@ -8,13 +8,15 @@
"""
from enum import Enum
+from django.db import models
+
class AuthenticationType(Enum):
# 系统用户
SYSTEM_USER = "SYSTEM_USER"
# 对话用户
CHAT_USER = "CHAT_USER"
- # 对话匿名用户
- CHAT_ANONYMOUS_USER = "CHAT_ANONYMOUS_USER"
- # APIKEY
- API_KEY = "API_KEY"
+
+
+class UserType(models.TextChoices):
+ SYSTEM_USER = "SYSTEM_USER", '系统用户'
diff --git a/apps/common/constants/cache_version.py b/apps/common/constants/cache_version.py
index 0aed4715e2e..64c29498515 100644
--- a/apps/common/constants/cache_version.py
+++ b/apps/common/constants/cache_version.py
@@ -1,11 +1,12 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: cache_version.py
- @date:2025/4/14 19:09
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: cache_version.py
+@date:2025/4/14 19:09
+@desc:
"""
+
from enum import Enum
@@ -32,6 +33,9 @@ class Cache_Version(Enum):
CHAT_INFO = "CHAT_INFO", lambda key: key
+ # 会话历史滚动窗口缓存(只存最近 N 条已完成记录,append-only)
+ CHAT_HISTORY = "CHAT_HISTORY", lambda key: key
+
CHAT_VARIABLE = "CHAT_VARIABLE", lambda key: key
# 应用API KEY
@@ -41,6 +45,8 @@ class Cache_Version(Enum):
TOOL_WORKFLOW_EXECUTE = "TOOL_WORKFLOW_EXECUTE", lambda key: key
+ DEBUG_WORKFLOW_CONTEXT = "DEBUG_WORKFLOW_CONTEXT", lambda chat_record_id: chat_record_id
+
def get_version(self):
return self.value[0]
diff --git a/apps/common/constants/permission_constants.py b/apps/common/constants/permission_constants.py
deleted file mode 100644
index b1d40debdbc..00000000000
--- a/apps/common/constants/permission_constants.py
+++ /dev/null
@@ -1,2134 +0,0 @@
-"""
- @project: qabot
- @Author:虎虎
- @file: permission_constants.py
- @date:2023/9/13 18:23
- @desc: 权限,角色 常量
-"""
-from enum import Enum
-from functools import reduce
-from typing import List
-
-from django.db import models
-from django.utils.translation import gettext_lazy as _
-
-from maxkb import settings
-
-
-class Group(Enum):
- """
- 权限组 一个组一般对应前端一个菜单
- """
-
- USER = "USER_MANAGEMENT"
- # 应用
- APPLICATION = "APPLICATION"
- # 应用概览
- APPLICATION_OVERVIEW = "APPLICATION_OVERVIEW"
- # 应用接入
- APPLICATION_ACCESS = "APPLICATION_ACCESS"
- # 应用 对话用户
- APPLICATION_CHAT_USER = "APPLICATION_CHAT_USER"
- # 知识库 对话用户
- KNOWLEDGE_CHAT_USER = "KNOWLEDGE_CHAT_USER"
- # 应用对话日志
- APPLICATION_CHAT_LOG = "APPLICATION_CHAT_LOG"
-
- KNOWLEDGE = "KNOWLEDGE"
- SYSTEM_KNOWLEDGE = "SYSTEM_KNOWLEDGE"
- SYSTEM_RES_KNOWLEDGE = "SYSTEM_RESOURCE_KNOWLEDGE"
- KNOWLEDGE_HIT_TEST = "KNOWLEDGE_HIT_TEST"
- KNOWLEDGE_DOCUMENT = "KNOWLEDGE_DOCUMENT"
- KNOWLEDGE_WORKFLOW = "KNOWLEDGE_WORKFLOW"
- KNOWLEDGE_TAG = "KNOWLEDGE_TAG"
- SYSTEM_KNOWLEDGE_DOCUMENT = "SYSTEM_KNOWLEDGE_DOCUMENT"
- SYSTEM_KNOWLEDGE_WORKFLOW = "SYSTEM_KNOWLEDGE_WORKFLOW"
- SYSTEM_RES_KNOWLEDGE_DOCUMENT = "SYSTEM_RESOURCE_KNOWLEDGE_DOCUMENT"
- SYSTEM_RES_KNOWLEDGE_WORKFLOW = "SYSTEM_RESOURCE_KNOWLEDGE_WORKFLOW"
- SYSTEM_RES_KNOWLEDGE_TAG = "SYSTEM_RES_KNOWLEDGE_TAG"
- SYSTEM_KNOWLEDGE_TAG = "SYSTEM_KNOWLEDGE_TAG"
-
- KNOWLEDGE_PROBLEM = "KNOWLEDGE_PROBLEM"
- KNOWLEDGE_TERMBASE = "KNOWLEDGE_TERMBASE"
- SYSTEM_KNOWLEDGE_PROBLEM = "SYSTEM_KNOWLEDGE_PROBLEM"
- SYSTEM_KNOWLEDGE_TERMBASE = "SYSTEM_KNOWLEDGE_TERMBASE"
- SYSTEM_RES_KNOWLEDGE_PROBLEM = "SYSTEM_RESOURCE_KNOWLEDGE_PROBLEM"
- SYSTEM_RES_KNOWLEDGE_TERMBASE = "SYSTEM_RESOURCE_KNOWLEDGE_TERMBASE"
-
- SYSTEM_KNOWLEDGE_HIT_TEST = "SYSTEM_KNOWLEDGE_HIT_TEST"
- SYSTEM_RES_KNOWLEDGE_HIT_TEST = "SYSTEM_RESOURCE_KNOWLEDGE_HIT_TEST"
- SYSTEM_KNOWLEDGE_CHAT_USER = "SYSTEM_KNOWLEDGE_CHAT_USER"
- SYSTEM_RES_KNOWLEDGE_CHAT_USER = "SYSTEM_RESOURCE_KNOWLEDGE_CHAT_USER"
-
- MODEL = "MODEL"
- SYSTEM_MODEL = "SYSTEM_MODEL"
- SYSTEM_RES_MODEL = "SYSTEM_RESOURCE_MODEL"
- SYSTEM_RES_APPLICATION = "SYSTEM_RESOURCE_APPLICATION"
- SYSTEM_RES_APPLICATION_OVERVIEW = "SYSTEM_RESOURCE_APPLICATION_OVERVIEW"
- SYSTEM_RES_APPLICATION_ACCESS = "SYSTEM_RESOURCE_APPLICATION_ACCESS"
- SYSTEM_RES_APPLICATION_CHAT_USER = "SYSTEM_RESOURCE_APPLICATION_CHAT_USER"
- SYSTEM_RES_APPLICATION_CHAT_LOG = "SYSTEM_RESOURCE_APPLICATION_CHAT_LOG"
-
- TOOL = "TOOL"
- SYSTEM_TOOL = "SYSTEM_TOOL"
- SYSTEM_RES_TOOL = "SYSTEM_RESOURCE_TOOL"
-
- TRIGGER = "TRIGGER"
-
- APPLICATION_WORKSPACE_USER_RESOURCE_PERMISSION = "APPLICATION_WORKSPACE_USER_RESOURCE_PERMISSION"
- KNOWLEDGE_WORKSPACE_USER_RESOURCE_PERMISSION = "KNOWLEDGE_WORKSPACE_USER_RESOURCE_PERMISSION"
- TOOL_WORKSPACE_USER_RESOURCE_PERMISSION = "TOOL_WORKSPACE_USER_RESOURCE_PERMISSION"
- MODEL_WORKSPACE_USER_RESOURCE_PERMISSION = "MODEL_WORKSPACE_USER_RESOURCE_PERMISSION"
-
- EMAIL_SETTING = "EMAIL_SETTING"
- ROLE = "ROLE"
- WORKSPACE_ROLE = "WORKSPACE_ROLE"
- WORKSPACE = "WORKSPACE"
- WORKSPACE_WORKSPACE = "WORKSPACE_WORKSPACE"
-
- DISPLAY_SETTINGS = "DISPLAY_SETTINGS"
- LOGIN_AUTH = "LOGIN_AUTH"
- SYSTEM_API_KEY = "SYSTEM_API_KEY"
- APPEARANCE_SETTINGS = "APPEARANCE_SETTINGS"
- CHAT_USER = "CHAT_USER"
- WORKSPACE_CHAT_USER = "WORKSPACE_CHAT_USER"
- USER_GROUP = "USER_GROUP"
- WORKSPACE_USER_GROUP = "WORKSPACE_USER_GROUP"
- CHAT_USER_AUTH = "CHAT_USER_AUTH"
- OTHER = "OTHER"
- OVERVIEW = "OVERVIEW"
- OPERATION_LOG = "OPERATION_LOG"
-
- APPLICATION_FOLDER = "APPLICATION_FOLDER"
- KNOWLEDGE_FOLDER = "KNOWLEDGE_FOLDER"
- TOOL_FOLDER = "TOOL_FOLDER"
-
-
-class SystemGroup(Enum):
- """
- 一级菜单
- """
- USER_MANAGEMENT = "USER_MANAGEMENT"
- ROLE = "ROLE"
- WORKSPACE = "WORKSPACE"
- # RESOURCE = "RESOURCE"
- RESOURCE_APPLICATION = "RESOURCE_APPLICATION"
- RESOURCE_KNOWLEDGE = "RESOURCE_KNOWLEDGE"
- RESOURCE_TOOL = "RESOURCE_TOOL"
- RESOURCE_MODEL = "RESOURCE_MODEL"
- RESOURCE_PERMISSION = "RESOURCE_PERMISSION"
- SHARED_KNOWLEDGE = "SHARED_KNOWLEDGE"
- SHARED_MODEL = "SHARED_MODEL"
- SHARED_TOOL = "SHARED_TOOL"
- CHAT_USER = "CHAT_USER"
- SYSTEM_SETTING = "SYSTEM_SETTING"
- OPERATION_LOG = "OPERATION_LOG"
- OTHER = "OTHER"
-
-
-class WorkspaceGroup(Enum):
- SYSTEM_MANAGEMENT = "SYSTEM_MANAGEMENT"
- APPLICATION = "APPLICATION"
- KNOWLEDGE = "KNOWLEDGE"
- MODEL = "MODEL"
- TOOL = "TOOL"
- TRIGGER = "TRIGGER"
- RESOURCE_PERMISSION = "RESOURCE_PERMISSION"
- OTHER = "OTHER"
-
-
-class UserGroup(Enum):
- APPLICATION = "APPLICATION"
- KNOWLEDGE = "KNOWLEDGE"
- MODEL = "MODEL"
- TOOL = "TOOL"
- OTHER = "OTHER"
-
-
-class Operate(Enum):
- """
- 一个权限组的操作权限
- """
- SELF = ""
- READ = 'READ'
- EDIT = "READ+EDIT"
- CREATE = "READ+CREATE"
- DELETE = "READ+DELETE"
- """
- 使用权限
- """
- USE = "USE"
- IMPORT = "READ+IMPORT"
- EXPORT = "READ+EXPORT" # 导入导出
- PUBLISH = "READ+PUBLISH" # 发布
- SYNC = "READ+SYNC" # 同步
- GENERATE = "READ+GENERATE" # 生成
- ADD_MEMBER = "READ+ADD_MEMBER" # 添加成员
- REMOVE_MEMBER = "READ+REMOVE_MEMBER" # 添加成员
- VECTOR = "READ+VECTOR" # 向量化
- MIGRATE = "READ+MIGRATE" # 迁移
- RELATE = "READ+RELATE" # 关联
- USER_GROUP = "READ+USER_GROUP" # 用户组
- ANNOTATION = "READ+ANNOTATION" # 标注
- CLEAR_POLICY = "READ+CLEAR_POLICY"
- EMBED = "READ+EMBED" # 嵌入
- ACCESS = "READ+ACCESS" # 访问限制
- DISPLAY = "READ+DISPLAY" # 显示设置
- API_KEY = "READ+API_KEY" # API_KEY
- PUBLIC_ACCESS = "READ+PUBLIC_ACCESS" # 公共访问链接
- Q_WEIXIN = "READ+Q_WEIXIN" # 企业微信
- FEISHU = "READ+FEISHU" # 飞书
- DD = "READ+DD" # 钉钉
- WEIXIN_PUBLIC_ACCOUNT = "READ+WEIXIN_PUBLIC_ACCOUNT" # 微信公众号
- SLACK = "READ+SLACK" # SLACK
- ADD_KNOWLEDGE = "READ+ADD_KNOWLEDGE" # 添加到知识库
- TO_CHAT = "READ+TO_CHAT" # 去对话
- SETTING = "READ+SETTING" # 管理
- DOWNLOAD = "READ+DOWNLOAD" # 下载
- COPY = "READ+COPY"
- AUTH = "READ+AUTH" # 资源授权
- TAG = "READ+TAG" # 标签设置
- REPLACE = "READ+REPLACE" # 标签设置
- UPDATE = "READ+UPDATE" # 更新license
- RELATE_VIEW = "READ+RELATE_VIEW"
- RECORD = "READ+RECORD"
- TRIGGER_READ = "READ+TRIGGER_READ"
- TRIGGER_EDIT = "READ+TRIGGER_EDIT"
- TRIGGER_CREATE = "READ+TRIGGER_CREATE"
- TRIGGER_DELETE = "READ+TRIGGER_DELETE"
- BATCH_DELETE = "READ+BATCH_DELETE"
- BATCH_MOVE = "READ+BATCH_MOVE"
-
-
-class RoleGroup(Enum):
- # 系统用户
- SYSTEM_USER = "SYSTEM_USER"
- # 对话用户
- CHAT_USER = "CHAT_USER"
-
-
-class ResourcePermissionRole(models.TextChoices):
- """
- 资源权限根据角色
- """
- ROLE = "ROLE"
-
- def __eq__(self, other):
- return str(self) == str(other)
-
-
-class ResourcePermission(models.TextChoices):
- """
- 资源权限组
- """
- # 查看
- VIEW = "VIEW"
- # 管理
- MANAGE = "MANAGE"
-
- def __eq__(self, other):
- return str(self) == str(other)
-
-
-class Resource(models.TextChoices):
- KNOWLEDGE = Group.KNOWLEDGE.value
- KNOWLEDGE_FOLDER = Group.KNOWLEDGE_FOLDER.value
- APPLICATION = Group.APPLICATION.value
- APPLICATION_FOLDER = Group.APPLICATION_FOLDER.value
- TOOL = Group.TOOL.value
- TOOL_FOLDER = Group.TOOL_FOLDER.value
- MODEL = Group.MODEL.value
-
- def __eq__(self, other):
- return str(self) == str(other)
-
-
-class ResourcePermissionGroup:
- def __init__(self, resource: Resource, permission: ResourcePermission):
- self.permission = permission
- self.resource = resource
-
- def __eq__(self, other):
- return str(self.permission) == str(other.permission) and str(self.resource) == str(other.resource)
-
-
-class ResourcePermissionConst:
- KNOWLEDGE_MANGE = ResourcePermissionGroup(Resource.KNOWLEDGE, ResourcePermission.MANAGE)
- KNOWLEDGE_FOLDER_MANGE = ResourcePermissionGroup(Resource.KNOWLEDGE_FOLDER, ResourcePermission.MANAGE)
- KNOWLEDGE_FOLDER_VIEW = ResourcePermissionGroup(Resource.KNOWLEDGE_FOLDER, ResourcePermission.VIEW)
- KNOWLEDGE_VIEW = ResourcePermissionGroup(Resource.KNOWLEDGE, ResourcePermission.VIEW)
- APPLICATION_MANGE = ResourcePermissionGroup(Resource.APPLICATION, ResourcePermission.MANAGE)
- APPLICATION_FOLDER_MANGE = ResourcePermissionGroup(Resource.APPLICATION_FOLDER, ResourcePermission.MANAGE)
- APPLICATION_FOLDER_VIEW = ResourcePermissionGroup(Resource.APPLICATION_FOLDER, ResourcePermission.VIEW)
- APPLICATION_VIEW = ResourcePermissionGroup(Resource.APPLICATION, ResourcePermission.VIEW)
- TOOL_MANGE = ResourcePermissionGroup(Resource.TOOL, ResourcePermission.MANAGE)
- TOOL_FOLDER_MANGE = ResourcePermissionGroup(Resource.TOOL_FOLDER, ResourcePermission.MANAGE)
- TOOL_FOLDER_VIEW = ResourcePermissionGroup(Resource.TOOL_FOLDER, ResourcePermission.VIEW)
- TOOL_VIEW = ResourcePermissionGroup(Resource.TOOL, ResourcePermission.VIEW)
- MODEL_MANGE = ResourcePermissionGroup(Resource.MODEL, ResourcePermission.MANAGE)
- MODEL_VIEW = ResourcePermissionGroup(Resource.MODEL, ResourcePermission.VIEW)
-
-
-class ResourceAuthType(models.TextChoices):
- """
- 资源授权类型
- """
- "当授权类型是Role时候"
- ROLE = "ROLE"
-
- """资源权限组"""
- RESOURCE_PERMISSION_GROUP = "RESOURCE_PERMISSION_GROUP"
-
-
-class Role:
- def __init__(self, name: str, decs: str, group: RoleGroup, resource_path=None):
- self.name = name
- self.decs = decs
- self.group = group
- self.resource_path = resource_path
-
- def __str__(self):
- return self.name + (
- (":" + self.resource_path) if self.resource_path is not None else '')
-
- def __eq__(self, other):
- return str(self) == str(other)
-
- def get_workspace_role(self):
- return lambda r, kwargs: Role(self.name, self.decs, self.group,
- resource_path=f"/WORKSPACE/{kwargs.get('workspace_id')}")
-
-
-class RoleConstants(Enum):
- ADMIN = Role("ADMIN", '超级管理员', RoleGroup.SYSTEM_USER)
- WORKSPACE_MANAGE = Role("WORKSPACE_MANAGE", '工作空间管理员', RoleGroup.SYSTEM_USER)
- USER = Role("USER", '普通用户', RoleGroup.SYSTEM_USER)
- CHAT_ANONYMOUS_USER = Role("CHAT_ANONYMOUS_USER", "对话匿名用户", RoleGroup.CHAT_USER)
- CHAT_USER = Role("CHAT_USER", "对话用户", RoleGroup.CHAT_USER)
-
- EXTENDS_ADMIN = Role("EXTENDS_ADMIN", '继承超级管理员', RoleGroup.SYSTEM_USER)
- EXTENDS_WORKSPACE_MANAGE = Role("EXTENDS_WORKSPACE_MANAGE", "继承工作空间管理员", RoleGroup.CHAT_USER)
- EXTENDS_USER = Role("EXTENDS_USER", "继承普通用户", RoleGroup.CHAT_USER)
-
- def get_workspace_role(self):
- return lambda r, kwargs: Role(name=self.value.name,
- decs=self.value.decs,
- group=self.value.group,
- resource_path=
- f"/WORKSPACE/{kwargs.get('workspace_id')}")
-
-
-Permission_Label = {
- SystemGroup.SYSTEM_SETTING.value: _("System Setting"),
- SystemGroup.USER_MANAGEMENT.value: _("User Management"),
- SystemGroup.ROLE.value: _("Role"),
- SystemGroup.WORKSPACE.value: _("Workspace"),
- SystemGroup.RESOURCE_APPLICATION.value: _("Resource Application"),
- SystemGroup.RESOURCE_KNOWLEDGE.value: _("Resource Knowledge"),
- SystemGroup.RESOURCE_TOOL.value: _("Resource Tool"),
- SystemGroup.RESOURCE_MODEL.value: _("Resource Model"),
- SystemGroup.RESOURCE_PERMISSION.value: _("Resource Permission"),
- SystemGroup.SHARED_KNOWLEDGE.value: _("Shared Knowledge"),
- SystemGroup.SHARED_MODEL.value: _("Shared Model"),
- SystemGroup.SHARED_TOOL.value: _("Shared Tool"),
- SystemGroup.OPERATION_LOG.value: _("Operation Log"),
- SystemGroup.OTHER.value: _("Other"),
- WorkspaceGroup.SYSTEM_MANAGEMENT.value: _("System Management"),
- WorkspaceGroup.APPLICATION.value: _("Application"),
- WorkspaceGroup.KNOWLEDGE.value: _("Knowledge"),
- WorkspaceGroup.MODEL.value: _("Model"),
- WorkspaceGroup.TOOL.value: _("Tool"),
- WorkspaceGroup.TRIGGER.value: _("Trigger"),
- WorkspaceGroup.OTHER.value: _("Other"),
- Operate.READ.value: _("Read"),
- Operate.EDIT.value: _("Edit"),
- Operate.COPY.value: _('Copy'),
- Operate.PUBLISH.value: _("Publish"),
- Operate.CREATE.value: _("Create"),
- Operate.DELETE.value: _("Delete"),
- Group.EMAIL_SETTING.value: _("Email Setting"),
- Group.APPLICATION.value: _("Application"),
- Group.KNOWLEDGE.value: _("Knowledge"),
- Group.KNOWLEDGE_DOCUMENT.value: _("Document"),
- Group.KNOWLEDGE_TERMBASE.value: _("Termbase"),
- Group.KNOWLEDGE_WORKFLOW.value: _("Workflow"),
- Group.KNOWLEDGE_TAG.value: _("Tag"),
- Group.KNOWLEDGE_PROBLEM.value: _("Problem"),
- Group.KNOWLEDGE_HIT_TEST.value: _("Hit-Test"),
- Operate.IMPORT.value: _("Import"),
- Operate.EXPORT.value: _("Export"),
- Operate.SYNC.value: _("Sync"),
- Operate.GENERATE.value: _("Generate"),
- Operate.ADD_MEMBER.value: _("Add Member"),
- Operate.REMOVE_MEMBER.value: _("Remove Member"),
- Operate.VECTOR.value: _("Vector"),
- Operate.MIGRATE.value: _("Migrate"),
- Operate.RELATE.value: _("Relate"),
- Operate.ANNOTATION.value: _("Annotation"),
- Operate.CLEAR_POLICY.value: _("Clear Policy"),
- Operate.DOWNLOAD.value: _('Download Original Document'),
- Operate.EMBED.value: _('Embed third party'),
- Operate.ACCESS.value: _('Access restrictions'),
- Operate.DISPLAY.value: _('Display Settings'),
- Operate.API_KEY.value: _('API KEY'),
- Operate.PUBLIC_ACCESS.value: _('Public access link'),
- Operate.Q_WEIXIN.value: _('Enterprise WeiXin'),
- Operate.FEISHU.value: _('Feishu'),
- Operate.DD.value: _('Dingding'),
- Operate.WEIXIN_PUBLIC_ACCOUNT.value: _('Weixin Public Account'),
- Operate.ADD_KNOWLEDGE.value: _('Add to Knowledge Base'),
- Operate.AUTH.value: _('resource authorization'),
- Operate.TAG.value: _('Tag Setting'),
- Operate.REPLACE.value: _('Replace Original Document'),
- Operate.RELATE_VIEW.value: _('View related resources'),
- Operate.TRIGGER_READ.value: _('Read Trigger'),
- Operate.TRIGGER_CREATE.value: _('Create Trigger'),
- Operate.TRIGGER_EDIT.value: _('Edit Trigger'),
- Operate.TRIGGER_DELETE.value: _('Delete Trigger'),
- Operate.RECORD.value: _('Read execute record'),
- Operate.BATCH_DELETE.value: _('Batch delete'),
- Operate.BATCH_MOVE.value: _('Batch move'),
-
- Group.APPLICATION_OVERVIEW.value: _('Overview'),
- Group.APPLICATION_ACCESS.value: _('Application Access'),
- Group.APPLICATION_CHAT_USER.value: _('Dialogue users'),
- Group.APPLICATION_CHAT_LOG.value: _('Conversation log'),
- Group.KNOWLEDGE_CHAT_USER.value: _('Dialogue users'),
-
- Group.LOGIN_AUTH.value: _("Login Auth"),
- Group.DISPLAY_SETTINGS.value: _("Display Settings"),
- Group.SYSTEM_API_KEY.value: _("System API Key"),
- Group.APPEARANCE_SETTINGS.value: _("Appearance Settings"),
- Group.CHAT_USER.value: _("Chat User"),
- Group.USER_GROUP.value: _("User Group"),
- Group.CHAT_USER_AUTH.value: _("Chat User Auth"),
- Group.OVERVIEW.value: _("Overview"),
- Group.SYSTEM_TOOL.value: _("Tool"),
- Group.SYSTEM_MODEL.value: _("Model"),
- Group.SYSTEM_KNOWLEDGE.value: _("Knowledge"),
- Group.SYSTEM_KNOWLEDGE_DOCUMENT.value: _("Document"),
- Group.SYSTEM_KNOWLEDGE_TERMBASE.value: _("Termbase"),
- Group.SYSTEM_KNOWLEDGE_WORKFLOW.value: _("Workflow"),
- Group.SYSTEM_KNOWLEDGE_TAG.value: _("Tag"),
- Group.SYSTEM_KNOWLEDGE_PROBLEM.value: _("Problem"),
- Group.SYSTEM_KNOWLEDGE_HIT_TEST.value: _("Hit-Test"),
- Group.SYSTEM_KNOWLEDGE_CHAT_USER.value: _("Dialogue users"),
- Group.SYSTEM_RES_TOOL.value: _("Tool"),
- Group.SYSTEM_RES_MODEL.value: _("Model"),
- Group.SYSTEM_RES_KNOWLEDGE.value: _("Knowledge"),
- Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT.value: _("Document"),
- Group.SYSTEM_RES_KNOWLEDGE_TERMBASE.value: _("Termbase"),
- Group.SYSTEM_RES_KNOWLEDGE_WORKFLOW.value: _("Workflow"),
- Group.SYSTEM_RES_KNOWLEDGE_TAG.value: _("Tag"),
- Group.SYSTEM_RES_KNOWLEDGE_PROBLEM.value: _("Problem"),
- Group.SYSTEM_RES_KNOWLEDGE_HIT_TEST.value: _("Hit-Test"),
- Group.SYSTEM_RES_KNOWLEDGE_CHAT_USER.value: _("Dialogue users"),
- Group.WORKSPACE_USER_GROUP.value: _("User Group"),
- Group.WORKSPACE_CHAT_USER.value: _("Chat User"),
- Group.WORKSPACE_WORKSPACE.value: _("Workspace"),
- Group.WORKSPACE_ROLE.value: _("Role"),
- Group.APPLICATION_WORKSPACE_USER_RESOURCE_PERMISSION.value: _("Application"),
- Group.KNOWLEDGE_WORKSPACE_USER_RESOURCE_PERMISSION.value: _("Knowledge"),
- Group.MODEL_WORKSPACE_USER_RESOURCE_PERMISSION.value: _("Model"),
- Group.TOOL_WORKSPACE_USER_RESOURCE_PERMISSION.value: _("Tool"),
- Group.SYSTEM_RES_APPLICATION.value: _("Application"),
- Group.SYSTEM_RES_APPLICATION_OVERVIEW.value: _("Overview"),
- Group.SYSTEM_RES_APPLICATION_ACCESS.value: _("Application Access"),
- Group.SYSTEM_RES_APPLICATION_CHAT_USER.value: _("Dialogue users"),
- Group.SYSTEM_RES_APPLICATION_CHAT_LOG.value: _("Conversation log"),
- Group.APPLICATION_FOLDER.value: _("Folder"),
- Group.KNOWLEDGE_FOLDER.value: _("Folder"),
- Group.TOOL_FOLDER.value: _("Folder"),
- # SystemGroup.RESOURCE.value: _("Resource"),
-}
-
-
-class Permission:
- """
- 权限信息
- """
-
- def __init__(self, group: Group, operate: Operate, resource_path=None, role_list=None,
- resource_permission_group_list=None, parent_group=None, label=None, is_ee=True):
- if role_list is None:
- role_list = []
- if resource_permission_group_list is None:
- resource_permission_group_list = []
- self.group = group
- self.operate = operate
- self.resource_path = resource_path
- # 用于获取角色与权限的关系,只适用于没有权限管理的
- self.role_list = role_list
- # 用于资源权限权限分组
- self.resource_permission_group_list = resource_permission_group_list
- self.parent_group = parent_group # 新增字段:父级组
- self.label = label
- self.is_ee = is_ee # 是否是企业版权限
-
- @staticmethod
- def new_instance(permission_str: str):
- permission_split = permission_str.split(":")
- group = Group[permission_split[0]]
- operate = Operate[permission_split[1]]
- if len(permission_split) > 2:
- dynamic_tag = ":".join(permission_split[2:])
- return Permission(group, operate, dynamic_tag)
- return Permission(group, operate)
-
- def __str__(self):
-
- return self.group.value + (
- (":" + self.operate.value) if self.operate.value else '') + (
- (":" + self.resource_path) if self.resource_path is not None else '')
-
- def __eq__(self, other):
- return str(self) == str(other)
-
-
-class PermissionConstants(Enum):
- """
- 权限枚举
- """
- KNOWLEDGE = Permission(
- group=Group.KNOWLEDGE, operate=Operate.SELF, role_list=[RoleConstants.ADMIN, RoleConstants.USER]
- )
- APPLICATION = Permission(
- group=Group.APPLICATION, operate=Operate.SELF, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- )
- MODEL = Permission(
- group=Group.MODEL, operate=Operate.SELF, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- )
- TOOL = Permission(
- group=Group.TOOL, operate=Operate.SELF, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- )
- USER_READ = Permission(
- group=Group.USER, operate=Operate.READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[SystemGroup.USER_MANAGEMENT]
- )
-
- USER_CREATE = Permission(
- group=Group.USER, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.USER_MANAGEMENT]
- )
-
- USER_EDIT = Permission(
- group=Group.USER, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.USER_MANAGEMENT]
- )
-
- USER_DELETE = Permission(
- group=Group.USER, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.USER_MANAGEMENT]
- )
-
- MODEL_READ = Permission(
- group=Group.MODEL, operate=Operate.READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.MODEL, UserGroup.MODEL],
- resource_permission_group_list=[ResourcePermissionConst.MODEL_VIEW]
- )
-
- MODEL_CREATE = Permission(
- group=Group.MODEL, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.MODEL, UserGroup.MODEL],
- resource_permission_group_list=[ResourcePermissionConst.MODEL_MANGE]
- )
-
- MODEL_EDIT = Permission(
- group=Group.MODEL, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.MODEL, UserGroup.MODEL],
- resource_permission_group_list=[ResourcePermissionConst.MODEL_MANGE]
- )
- MODEL_DELETE = Permission(
- group=Group.MODEL, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.MODEL, UserGroup.MODEL],
- resource_permission_group_list=[ResourcePermissionConst.MODEL_MANGE]
- )
- MODEL_RESOURCE_AUTHORIZATION = Permission(
- group=Group.MODEL, operate=Operate.AUTH, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.MODEL, UserGroup.MODEL],
- resource_permission_group_list=[ResourcePermissionConst.MODEL_MANGE]
- )
- MODEL_RELATE_RESOURCE_VIEW = Permission(
- group=Group.MODEL, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.MODEL, UserGroup.MODEL],
- resource_permission_group_list=[ResourcePermissionConst.MODEL_MANGE]
- )
- # trigger
- TRIGGER_READ = Permission(
- group=Group.TRIGGER, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.TRIGGER],
- )
- TRIGGER_CREATE = Permission(
- group=Group.TRIGGER, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.TRIGGER],
- )
- TRIGGER_EDIT = Permission(
- group=Group.TRIGGER, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.TRIGGER],
- )
- TRIGGER_DELETE = Permission(
- group=Group.TRIGGER, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.TRIGGER],
- )
- TRIGGER_RECORD = Permission(
- group=Group.TRIGGER, operate=Operate.RECORD, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.TRIGGER],
- )
- TOOL_READ = Permission(
- group=Group.TOOL, operate=Operate.READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_VIEW]
- )
-
- TOOL_CREATE = Permission(
- group=Group.TOOL, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_BATCH_MOVE = Permission(
- group=Group.TOOL, operate=Operate.BATCH_MOVE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_BATCH_DELETE = Permission(
- group=Group.TOOL, operate=Operate.BATCH_DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_EDIT = Permission(
- group=Group.TOOL, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
-
- TOOL_DELETE = Permission(
- group=Group.TOOL, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_IMPORT = Permission(
- group=Group.TOOL, operate=Operate.IMPORT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_EXPORT = Permission(
- group=Group.TOOL, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_RESOURCE_AUTHORIZATION = Permission(
- group=Group.TOOL, operate=Operate.AUTH, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_RELATE_RESOURCE_VIEW = Permission(
- group=Group.TOOL, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_PUBLISH = Permission(
- group=Group.TOOL, operate=Operate.PUBLISH, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_EXECUTE_RECORD = Permission(
- group=Group.TOOL, operate=Operate.RECORD, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- # source point trigger
- TOOL_TRIGGER_READ = Permission(
- group=Group.TOOL, operate=Operate.TRIGGER_READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_TRIGGER_CREATE = Permission(
- group=Group.TOOL, operate=Operate.TRIGGER_CREATE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_VIEW]
- )
- TOOL_TRIGGER_EDIT = Permission(
- group=Group.TOOL, operate=Operate.TRIGGER_EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_VIEW]
- )
- TOOL_TRIGGER_DELETE = Permission(
- group=Group.TOOL, operate=Operate.TRIGGER_DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_VIEW]
- )
- TOOL_FOLDER_READ = Permission(
- group=Group.TOOL_FOLDER, operate=Operate.READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_VIEW]
- )
- TOOL_FOLDER_CREATE = Permission(
- group=Group.TOOL_FOLDER, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_FOLDER_EDIT = Permission(
- group=Group.TOOL_FOLDER, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_FOLDER_DELETE = Permission(
- group=Group.TOOL_FOLDER, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- TOOL_FOLDER_AUTH = Permission(
- group=Group.TOOL_FOLDER, operate=Operate.AUTH, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.TOOL, UserGroup.TOOL],
- resource_permission_group_list=[ResourcePermissionConst.TOOL_MANGE]
- )
- KNOWLEDGE_READ = Permission(
- group=Group.KNOWLEDGE, operate=Operate.READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_CREATE = Permission(
- group=Group.KNOWLEDGE, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_EDIT = Permission(
- group=Group.KNOWLEDGE, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DELETE = Permission(
- group=Group.KNOWLEDGE, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_SYNC = Permission(
- group=Group.KNOWLEDGE, operate=Operate.SYNC, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_EXPORT = Permission(
- group=Group.KNOWLEDGE, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_VECTOR = Permission(
- group=Group.KNOWLEDGE, operate=Operate.VECTOR, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_GENERATE = Permission(
- group=Group.KNOWLEDGE, operate=Operate.GENERATE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_BATCH_DELETE = Permission(group=Group.KNOWLEDGE, operate=Operate.BATCH_DELETE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE],
- )
- KNOWLEDGE_BATCH_MOVE = Permission(group=Group.KNOWLEDGE, operate=Operate.BATCH_MOVE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE],
- )
- KNOWLEDGE_RESOURCE_AUTHORIZATION = Permission(
- group=Group.KNOWLEDGE, operate=Operate.AUTH, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_RELATE_RESOURCE_VIEW = Permission(
- group=Group.KNOWLEDGE, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE]
- )
- KNOWLEDGE_FOLDER_READ = Permission(
- group=Group.KNOWLEDGE_FOLDER, operate=Operate.READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
- parent_group=[UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_FOLDER_CREATE = Permission(
- group=Group.KNOWLEDGE_FOLDER, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_FOLDER_EDIT = Permission(
- group=Group.KNOWLEDGE_FOLDER, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_FOLDER_DELETE = Permission(
- group=Group.KNOWLEDGE_FOLDER, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_FOLDER_AUTH = Permission(
- group=Group.KNOWLEDGE_FOLDER, operate=Operate.AUTH, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_WORKFLOW_READ = Permission(
- group=Group.KNOWLEDGE_WORKFLOW, operate=Operate.READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_WORKFLOW_EDIT = Permission(
- group=Group.KNOWLEDGE_WORKFLOW, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_WORKFLOW_EXPORT = Permission(
- group=Group.KNOWLEDGE_WORKFLOW, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_WORKFLOW_PUBLISH = Permission(
- group=Group.KNOWLEDGE_WORKFLOW, operate=Operate.PUBLISH, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_READ = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_CREATE = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_EDIT = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_DELETE = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_SYNC = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.SYNC, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_EXPORT = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.EXPORT,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_DOWNLOAD_SOURCE_FILE = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.DOWNLOAD,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_GENERATE = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.GENERATE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_VECTOR = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.VECTOR,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_MIGRATE = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.MIGRATE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_TAG = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.TAG,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_DOCUMENT_REPLACE = Permission(
- group=Group.KNOWLEDGE_DOCUMENT, operate=Operate.REPLACE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_HIT_TEST = Permission(
- group=Group.KNOWLEDGE_HIT_TEST, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_PROBLEM_READ = Permission(
- group=Group.KNOWLEDGE_PROBLEM, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_PROBLEM_CREATE = Permission(
- group=Group.KNOWLEDGE_PROBLEM, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_PROBLEM_EDIT = Permission(
- group=Group.KNOWLEDGE_PROBLEM, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_PROBLEM_DELETE = Permission(
- group=Group.KNOWLEDGE_PROBLEM, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_PROBLEM_RELATE = Permission(
- group=Group.KNOWLEDGE_PROBLEM, operate=Operate.RELATE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_TERMBASE_READ = Permission(
- group=Group.KNOWLEDGE_TERMBASE, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_TERMBASE_CREATE = Permission(
- group=Group.KNOWLEDGE_TERMBASE, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_TERMBASE_EDIT = Permission(
- group=Group.KNOWLEDGE_TERMBASE, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_TERMBASE_DELETE = Permission(
- group=Group.KNOWLEDGE_TERMBASE, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_TAG_READ = Permission(
- group=Group.KNOWLEDGE_TAG, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_TAG_CREATE = Permission(
- group=Group.KNOWLEDGE_TAG, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_TAG_EDIT = Permission(
- group=Group.KNOWLEDGE_TAG, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- KNOWLEDGE_TAG_DELETE = Permission(
- group=Group.KNOWLEDGE_TAG, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE]
- )
- APPLICATION_WORKSPACE_USER_RESOURCE_PERMISSION_READ = Permission(
- group=Group.APPLICATION_WORKSPACE_USER_RESOURCE_PERMISSION, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
- parent_group=[SystemGroup.RESOURCE_PERMISSION, WorkspaceGroup.RESOURCE_PERMISSION]
- )
- APPLICATION_WORKSPACE_USER_RESOURCE_PERMISSION_EDIT = Permission(
- group=Group.APPLICATION_WORKSPACE_USER_RESOURCE_PERMISSION, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
- parent_group=[SystemGroup.RESOURCE_PERMISSION, WorkspaceGroup.RESOURCE_PERMISSION]
- )
- KNOWLEDGE_WORKSPACE_USER_RESOURCE_PERMISSION_READ = Permission(
- group=Group.KNOWLEDGE_WORKSPACE_USER_RESOURCE_PERMISSION, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
- parent_group=[SystemGroup.RESOURCE_PERMISSION, WorkspaceGroup.RESOURCE_PERMISSION]
- )
- KNOWLEDGE_WORKSPACE_USER_RESOURCE_PERMISSION_EDIT = Permission(
- group=Group.KNOWLEDGE_WORKSPACE_USER_RESOURCE_PERMISSION, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
- parent_group=[SystemGroup.RESOURCE_PERMISSION, WorkspaceGroup.RESOURCE_PERMISSION]
- )
- TOOL_WORKSPACE_USER_RESOURCE_PERMISSION_READ = Permission(
- group=Group.TOOL_WORKSPACE_USER_RESOURCE_PERMISSION, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
- parent_group=[SystemGroup.RESOURCE_PERMISSION, WorkspaceGroup.RESOURCE_PERMISSION]
- )
- TOOL_WORKSPACE_USER_RESOURCE_PERMISSION_EDIT = Permission(
- group=Group.TOOL_WORKSPACE_USER_RESOURCE_PERMISSION, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
- parent_group=[SystemGroup.RESOURCE_PERMISSION, WorkspaceGroup.RESOURCE_PERMISSION]
-
- )
- MODEL_WORKSPACE_USER_RESOURCE_PERMISSION_READ = Permission(
- group=Group.MODEL_WORKSPACE_USER_RESOURCE_PERMISSION, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
- parent_group=[SystemGroup.RESOURCE_PERMISSION, WorkspaceGroup.RESOURCE_PERMISSION]
- )
- MODEL_WORKSPACE_USER_RESOURCE_PERMISSION_EDIT = Permission(
- group=Group.MODEL_WORKSPACE_USER_RESOURCE_PERMISSION, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN, RoleConstants.WORKSPACE_MANAGE],
- parent_group=[SystemGroup.RESOURCE_PERMISSION, WorkspaceGroup.RESOURCE_PERMISSION]
- )
-
- EMAIL_SETTING_READ = Permission(
- group=Group.EMAIL_SETTING, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SYSTEM_SETTING]
- )
- EMAIL_SETTING_EDIT = Permission(
- group=Group.EMAIL_SETTING, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SYSTEM_SETTING]
- )
-
- ROLE_READ = Permission(
- group=Group.ROLE, operate=Operate.READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[SystemGroup.ROLE]
- )
- ROLE_CREATE = Permission(
- group=Group.ROLE, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.ROLE]
- )
- ROLE_EDIT = Permission(
- group=Group.ROLE, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.ROLE]
- )
- ROLE_DELETE = Permission(
- group=Group.ROLE, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.ROLE]
- )
- ROLE_ADD_MEMBER = Permission(
- group=Group.ROLE, operate=Operate.ADD_MEMBER, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.ROLE]
- )
- ROLE_REMOVE_MEMBER = Permission(
- group=Group.ROLE, operate=Operate.REMOVE_MEMBER, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.ROLE]
- )
- WORKSPACE_ROLE_READ = Permission(
- group=Group.WORKSPACE_ROLE, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_ROLE_ADD_MEMBER = Permission(
- group=Group.WORKSPACE_ROLE, operate=Operate.ADD_MEMBER, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_ROLE_REMOVE_MEMBER = Permission(
- group=Group.WORKSPACE_ROLE, operate=Operate.REMOVE_MEMBER, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
-
- WORKSPACE_READ = Permission(
- group=Group.WORKSPACE, operate=Operate.READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[SystemGroup.WORKSPACE], is_ee=settings.edition == "EE"
- )
- WORKSPACE_CREATE = Permission(
- group=Group.WORKSPACE, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.WORKSPACE], is_ee=settings.edition == "EE"
- )
- WORKSPACE_EDIT = Permission(
- group=Group.WORKSPACE, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.WORKSPACE], is_ee=settings.edition == "EE"
- )
- WORKSPACE_DELETE = Permission(
- group=Group.WORKSPACE, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.WORKSPACE], is_ee=settings.edition == "EE"
- )
- WORKSPACE_ADD_MEMBER = Permission(
- group=Group.WORKSPACE, operate=Operate.ADD_MEMBER, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.WORKSPACE], is_ee=settings.edition == "EE"
- )
- WORKSPACE_REMOVE_MEMBER = Permission(
- group=Group.WORKSPACE, operate=Operate.REMOVE_MEMBER, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.WORKSPACE], is_ee=settings.edition == "EE"
- )
- WORKSPACE_WORKSPACE_READ = Permission(
- group=Group.WORKSPACE_WORKSPACE, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT], is_ee=settings.edition == "EE"
- )
- WORKSPACE_WORKSPACE_ADD_MEMBER = Permission(
- group=Group.WORKSPACE_WORKSPACE, operate=Operate.ADD_MEMBER, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT], is_ee=settings.edition == "EE"
- )
- WORKSPACE_WORKSPACE_REMOVE_MEMBER = Permission(
- group=Group.WORKSPACE_WORKSPACE, operate=Operate.REMOVE_MEMBER, role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT], is_ee=settings.edition == "EE"
- )
- LOGIN_AUTH_READ = Permission(
- group=Group.LOGIN_AUTH, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SYSTEM_SETTING]
- )
- LOGIN_AUTH_EDIT = Permission(
- group=Group.LOGIN_AUTH, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SYSTEM_SETTING]
- )
- APPLICATION_READ = Permission(group=Group.APPLICATION, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
- )
- APPLICATION_CREATE = Permission(group=Group.APPLICATION, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
- )
- APPLICATION_COPY = Permission(group=Group.APPLICATION, operate=Operate.COPY,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
- )
- APPLICATION_EDIT = Permission(group=Group.APPLICATION, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
- )
- APPLICATION_DELETE = Permission(group=Group.APPLICATION, operate=Operate.DELETE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
- )
- APPLICATION_IMPORT = Permission(group=Group.APPLICATION, operate=Operate.IMPORT,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE]
- )
- APPLICATION_EXPORT = Permission(group=Group.APPLICATION, operate=Operate.EXPORT,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- )
- APPLICATION_PUBLISH = Permission(group=Group.APPLICATION, operate=Operate.PUBLISH,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- )
- APPLICATION_BATCH_DELETE = Permission(group=Group.APPLICATION, operate=Operate.BATCH_DELETE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- )
- APPLICATION_BATCH_MOVE = Permission(group=Group.APPLICATION, operate=Operate.BATCH_MOVE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- )
- APPLICATION_RESOURCE_AUTHORIZATION = Permission(group=Group.APPLICATION, operate=Operate.AUTH,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[
- ResourcePermissionConst.APPLICATION_MANGE],
- )
- APPLICATION_RELATE_RESOURCE_VIEW = Permission(
- group=Group.APPLICATION, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE]
- )
- APPLICATION_TRIGGER_READ = Permission(
- group=Group.APPLICATION, operate=Operate.TRIGGER_READ, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE]
- )
- APPLICATION_TRIGGER_CREATE = Permission(
- group=Group.APPLICATION, operate=Operate.TRIGGER_CREATE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE]
- )
- APPLICATION_TRIGGER_EDIT = Permission(
- group=Group.APPLICATION, operate=Operate.TRIGGER_EDIT, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE]
- )
- APPLICATION_TRIGGER_DELETE = Permission(
- group=Group.APPLICATION, operate=Operate.TRIGGER_DELETE, role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE]
- )
- APPLICATION_FOLDER_READ = Permission(group=Group.APPLICATION_FOLDER, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW]
- )
- APPLICATION_FOLDER_CREATE = Permission(group=Group.APPLICATION_FOLDER, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE]
- )
- APPLICATION_FOLDER_EDIT = Permission(group=Group.APPLICATION_FOLDER, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE]
- )
- APPLICATION_FOLDER_DELETE = Permission(group=Group.APPLICATION_FOLDER, operate=Operate.DELETE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE]
- )
- APPLICATION_FOLDER_AUTH = Permission(group=Group.APPLICATION_FOLDER, operate=Operate.AUTH,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE]
- )
- APPLICATION_OVERVIEW_READ = Permission(group=Group.APPLICATION_OVERVIEW, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
- )
-
- APPLICATION_OVERVIEW_EMBED = Permission(group=Group.APPLICATION_OVERVIEW, operate=Operate.EMBED,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
-
- )
-
- APPLICATION_OVERVIEW_ACCESS = Permission(group=Group.APPLICATION_OVERVIEW, operate=Operate.ACCESS,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
-
- )
- APPLICATION_OVERVIEW_DISPLAY = Permission(group=Group.APPLICATION_OVERVIEW, operate=Operate.DISPLAY,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[
- ResourcePermissionConst.APPLICATION_MANGE],
-
- )
- APPLICATION_OVERVIEW_API_KEY = Permission(group=Group.APPLICATION_OVERVIEW, operate=Operate.API_KEY,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[
- ResourcePermissionConst.APPLICATION_MANGE],
-
- )
- APPLICATION_OVERVIEW_PUBLIC = Permission(group=Group.APPLICATION_OVERVIEW, operate=Operate.PUBLIC_ACCESS,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
-
- )
- # 应用接入
- APPLICATION_ACCESS_READ = Permission(group=Group.APPLICATION_ACCESS, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
-
- )
- APPLICATION_ACCESS_EDIT = Permission(group=Group.APPLICATION_ACCESS, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE])
-
- APPLICATION_CHAT_USER_READ = Permission(group=Group.APPLICATION_CHAT_USER, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
- )
- APPLICATION_CHAT_USER_EDIT = Permission(group=Group.APPLICATION_CHAT_USER, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
- )
- KNOWLEDGE_CHAT_USER_READ = Permission(group=Group.KNOWLEDGE_CHAT_USER, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_VIEW],
- )
-
- KNOWLEDGE_CHAT_USER_EDIT = Permission(group=Group.KNOWLEDGE_CHAT_USER, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.KNOWLEDGE, UserGroup.KNOWLEDGE],
- resource_permission_group_list=[ResourcePermissionConst.KNOWLEDGE_MANGE],
- )
-
- APPLICATION_CHAT_LOG_READ = Permission(group=Group.APPLICATION_CHAT_LOG, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_VIEW],
- )
-
- APPLICATION_CHAT_LOG_ANNOTATION = Permission(group=Group.APPLICATION_CHAT_LOG, operate=Operate.ANNOTATION,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[
- ResourcePermissionConst.APPLICATION_MANGE],
- )
-
- APPLICATION_CHAT_LOG_EXPORT = Permission(group=Group.APPLICATION_CHAT_LOG, operate=Operate.EXPORT,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[ResourcePermissionConst.APPLICATION_MANGE],
- )
-
- APPLICATION_CHAT_LOG_CLEAR_POLICY = Permission(group=Group.APPLICATION_CHAT_LOG, operate=Operate.CLEAR_POLICY,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[
- ResourcePermissionConst.APPLICATION_MANGE],
- )
- APPLICATION_CHAT_LOG_ADD_KNOWLEDGE = Permission(group=Group.APPLICATION_CHAT_LOG, operate=Operate.ADD_KNOWLEDGE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[WorkspaceGroup.APPLICATION, UserGroup.APPLICATION],
- resource_permission_group_list=[
- ResourcePermissionConst.APPLICATION_MANGE],
- )
-
- ABOUT_READ = Permission(group=Group.OTHER, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[SystemGroup.OTHER, WorkspaceGroup.OTHER, UserGroup.OTHER],
- label=_('About')
- )
- ABOUT_UPDATE = Permission(group=Group.OTHER, operate=Operate.UPDATE,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.OTHER],
- label=_('Update License')
- )
- SWITCH_LANGUAGE = Permission(group=Group.OTHER, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[SystemGroup.OTHER, WorkspaceGroup.OTHER, UserGroup.OTHER],
- label=_('Switch Language')
- )
- CHANGE_PASSWORD = Permission(group=Group.OTHER, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[SystemGroup.OTHER, WorkspaceGroup.OTHER, UserGroup.OTHER],
- label=_('Change Password')
- )
-
- SYSTEM_API_KEY_EDIT = Permission(group=Group.OTHER, operate=Operate.DELETE,
- role_list=[RoleConstants.ADMIN, RoleConstants.USER],
- parent_group=[SystemGroup.OTHER, WorkspaceGroup.OTHER, UserGroup.OTHER],
- label=_('System API Key')
- )
-
- APPEARANCE_SETTINGS_READ = Permission(group=Group.APPEARANCE_SETTINGS, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SYSTEM_SETTING]
- )
- APPEARANCE_SETTINGS_EDIT = Permission(group=Group.APPEARANCE_SETTINGS, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SYSTEM_SETTING]
- )
- CHAT_USER_READ = Permission(group=Group.CHAT_USER, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER],
- )
- CHAT_USER_CREATE = Permission(group=Group.CHAT_USER, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- CHAT_USER_SYNC = Permission(group=Group.CHAT_USER, operate=Operate.SYNC,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- CHAT_USER_EDIT = Permission(group=Group.CHAT_USER, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- CHAT_USER_DELETE = Permission(group=Group.CHAT_USER, operate=Operate.DELETE,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- CHAT_USER_GROUP = Permission(group=Group.CHAT_USER, operate=Operate.USER_GROUP,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER],
- label=_('Set up user groups')
- )
- USER_GROUP_READ = Permission(group=Group.USER_GROUP, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- USER_GROUP_CREATE = Permission(group=Group.USER_GROUP, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- USER_GROUP_EDIT = Permission(group=Group.USER_GROUP, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- USER_GROUP_DELETE = Permission(group=Group.USER_GROUP, operate=Operate.DELETE,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- USER_GROUP_ADD_MEMBER = Permission(group=Group.USER_GROUP, operate=Operate.ADD_MEMBER,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- USER_GROUP_REMOVE_MEMBER = Permission(group=Group.USER_GROUP, operate=Operate.REMOVE_MEMBER,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- CHAT_USER_AUTH_READ = Permission(group=Group.CHAT_USER_AUTH, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- CHAT_USER_AUTH_EDIT = Permission(group=Group.CHAT_USER_AUTH, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.CHAT_USER]
- )
- WORKSPACE_CHAT_USER_READ = Permission(group=Group.WORKSPACE_CHAT_USER, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_CHAT_USER_CREATE = Permission(group=Group.WORKSPACE_CHAT_USER, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_CHAT_USER_EDIT = Permission(group=Group.WORKSPACE_CHAT_USER, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_CHAT_USER_DELETE = Permission(group=Group.WORKSPACE_CHAT_USER, operate=Operate.DELETE,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_CHAT_USER_GROUP = Permission(group=Group.WORKSPACE_CHAT_USER, operate=Operate.USER_GROUP,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT],
- label=_('Set up user groups')
- )
- WORKSPACE_USER_GROUP_READ = Permission(group=Group.WORKSPACE_USER_GROUP, operate=Operate.READ,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_USER_GROUP_CREATE = Permission(group=Group.WORKSPACE_USER_GROUP, operate=Operate.CREATE,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_USER_GROUP_EDIT = Permission(group=Group.WORKSPACE_USER_GROUP, operate=Operate.EDIT,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_USER_GROUP_DELETE = Permission(group=Group.WORKSPACE_USER_GROUP, operate=Operate.DELETE,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_USER_GROUP_ADD_MEMBER = Permission(group=Group.WORKSPACE_USER_GROUP, operate=Operate.ADD_MEMBER,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
- WORKSPACE_USER_GROUP_REMOVE_MEMBER = Permission(group=Group.WORKSPACE_USER_GROUP, operate=Operate.REMOVE_MEMBER,
- role_list=[RoleConstants.ADMIN],
- parent_group=[WorkspaceGroup.SYSTEM_MANAGEMENT]
- )
-
- SHARED_TOOL_READ = Permission(group=Group.SYSTEM_TOOL, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_TOOL], is_ee=settings.edition == "EE"
- )
-
- SHARED_TOOL_CREATE = Permission(group=Group.SYSTEM_TOOL, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_TOOL], is_ee=settings.edition == "EE"
- )
-
- SHARED_TOOL_EDIT = Permission(
- group=Group.SYSTEM_TOOL, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_TOOL], is_ee=settings.edition == "EE"
- )
-
- SHARED_TOOL_DELETE = Permission(
- group=Group.SYSTEM_TOOL, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_TOOL], is_ee=settings.edition == "EE"
- )
- SHARED_TOOL_IMPORT = Permission(
- group=Group.SYSTEM_TOOL, operate=Operate.IMPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_TOOL], is_ee=settings.edition == "EE"
- )
- SHARED_TOOL_EXPORT = Permission(
- group=Group.SYSTEM_TOOL, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_TOOL], is_ee=settings.edition == "EE"
- )
- SHARED_TOOL_PUBLISH = Permission(
- group=Group.SYSTEM_TOOL, operate=Operate.PUBLISH, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_TOOL], is_ee=settings.edition == "EE"
- )
- SHARED_TOOL_RELATE_RESOURCE_VIEW = Permission(
- group=Group.SYSTEM_TOOL, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_TOOL], is_ee=settings.edition == "EE"
- )
- SHARED_TOOL_EXECUTE_RECORD = Permission(
- group=Group.SYSTEM_TOOL, operate=Operate.RECORD, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_TOOL], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_READ = Permission(
- group=Group.SYSTEM_KNOWLEDGE, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_CREATE = Permission(
- group=Group.SYSTEM_KNOWLEDGE, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_EDIT = Permission(
- group=Group.SYSTEM_KNOWLEDGE, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_SYNC = Permission(
- group=Group.SYSTEM_KNOWLEDGE, operate=Operate.SYNC, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_VECTOR = Permission(
- group=Group.SYSTEM_KNOWLEDGE, operate=Operate.VECTOR, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_EXPORT = Permission(
- group=Group.SYSTEM_KNOWLEDGE, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_GENERATE = Permission(
- group=Group.SYSTEM_KNOWLEDGE, operate=Operate.GENERATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DELETE = Permission(
- group=Group.SYSTEM_KNOWLEDGE, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_RELATE_RESOURCE_VIEW = Permission(
- group=Group.SYSTEM_KNOWLEDGE, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_WORKFLOW_READ = Permission(
- group=Group.SYSTEM_KNOWLEDGE_WORKFLOW, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_WORKFLOW_EDIT = Permission(
- group=Group.SYSTEM_KNOWLEDGE_WORKFLOW, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_WORKFLOW_EXPORT = Permission(
- group=Group.SYSTEM_KNOWLEDGE_WORKFLOW, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_WORKFLOW_PUBLISH = Permission(
- group=Group.SYSTEM_KNOWLEDGE_WORKFLOW, operate=Operate.PUBLISH, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_READ = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_CREATE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_EDIT = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_DELETE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_SYNC = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.SYNC, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_EXPORT = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_DOWNLOAD_SOURCE_FILE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.DOWNLOAD, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_GENERATE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.GENERATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_VECTOR = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.VECTOR, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_MIGRATE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.MIGRATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_TAG = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.TAG, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_DOCUMENT_REPLACE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_DOCUMENT, operate=Operate.REPLACE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_TAG_READ = Permission(
- group=Group.SYSTEM_KNOWLEDGE_TAG, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_TAG_CREATE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_TAG, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_TAG_EDIT = Permission(
- group=Group.SYSTEM_KNOWLEDGE_TAG, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_TAG_DELETE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_TAG, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_PROBLEM_READ = Permission(
- group=Group.SYSTEM_KNOWLEDGE_PROBLEM, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_PROBLEM_CREATE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_PROBLEM, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_PROBLEM_EDIT = Permission(
- group=Group.SYSTEM_KNOWLEDGE_PROBLEM, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_PROBLEM_DELETE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_PROBLEM, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_PROBLEM_RELATE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_PROBLEM, operate=Operate.RELATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_TERMBASE_READ = Permission(
- group=Group.SYSTEM_KNOWLEDGE_TERMBASE, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_TERMBASE_CREATE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_TERMBASE, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_TERMBASE_EDIT = Permission(
- group=Group.SYSTEM_KNOWLEDGE_TERMBASE, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_TERMBASE_DELETE = Permission(
- group=Group.SYSTEM_KNOWLEDGE_TERMBASE, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_TERMBASE_EXPORT = Permission(
- group=Group.SYSTEM_KNOWLEDGE_TERMBASE, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_HIT_TEST = Permission(
- group=Group.SYSTEM_KNOWLEDGE_HIT_TEST, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_CHAT_USER_READ = Permission(
- group=Group.SYSTEM_KNOWLEDGE_CHAT_USER, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_KNOWLEDGE_CHAT_USER_EDIT = Permission(
- group=Group.SYSTEM_KNOWLEDGE_CHAT_USER, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- SHARED_MODEL_READ = Permission(
- group=Group.SYSTEM_MODEL, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_MODEL], is_ee=settings.edition == "EE"
- )
- SHARED_MODEL_CREATE = Permission(
- group=Group.SYSTEM_MODEL, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_MODEL], is_ee=settings.edition == "EE"
- )
-
- SHARED_MODEL_EDIT = Permission(
- group=Group.SYSTEM_MODEL, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_MODEL], is_ee=settings.edition == "EE"
- )
- SHARED_MODEL_DELETE = Permission(
- group=Group.SYSTEM_MODEL, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_MODEL], is_ee=settings.edition == "EE"
- )
- SHARED_MODEL_RELATE_RESOURCE_VIEW = Permission(
- group=Group.SYSTEM_MODEL, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.SHARED_MODEL], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_READ = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_EDIT = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_DELETE = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_EXPORT = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_COPY = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.COPY,
- role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_AUTH = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.AUTH, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_PUBLISH = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.PUBLISH, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_TRIGGER_READ = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.TRIGGER_READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_TRIGGER_CREATE = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.TRIGGER_CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_TRIGGER_EDIT = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.TRIGGER_EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_TRIGGER_DELETE = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.TRIGGER_DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_RELATE_RESOURCE_VIEW = Permission(
- group=Group.SYSTEM_RES_APPLICATION, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_OVERVIEW_READ = Permission(
- group=Group.SYSTEM_RES_APPLICATION_OVERVIEW, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_OVERVIEW_EMBED = Permission(
- group=Group.SYSTEM_RES_APPLICATION_OVERVIEW, operate=Operate.EMBED, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_OVERVIEW_ACCESS = Permission(
- group=Group.SYSTEM_RES_APPLICATION_OVERVIEW, operate=Operate.ACCESS, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_OVERVIEW_DISPLAY = Permission(
- group=Group.SYSTEM_RES_APPLICATION_OVERVIEW, operate=Operate.DISPLAY, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_OVERVIEW_API_KEY = Permission(
- group=Group.SYSTEM_RES_APPLICATION_OVERVIEW, operate=Operate.API_KEY, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_OVERVIEW_PUBLIC = Permission(
- group=Group.SYSTEM_RES_APPLICATION_OVERVIEW, operate=Operate.PUBLIC_ACCESS, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- # 应用接入
- RESOURCE_APPLICATION_ACCESS_READ = Permission(
- group=Group.SYSTEM_RES_APPLICATION_ACCESS, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_ACCESS_EDIT = Permission(
- group=Group.SYSTEM_RES_APPLICATION_ACCESS, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_CHAT_USER_READ = Permission(
- group=Group.SYSTEM_RES_APPLICATION_CHAT_USER, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_CHAT_USER_EDIT = Permission(
- group=Group.SYSTEM_RES_APPLICATION_CHAT_USER, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_CHAT_LOG_READ = Permission(
- group=Group.SYSTEM_RES_APPLICATION_CHAT_LOG, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_CHAT_LOG_ADD_KNOWLEDGE = Permission(
- group=Group.SYSTEM_RES_APPLICATION_CHAT_LOG, operate=Operate.ADD_KNOWLEDGE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_CHAT_LOG_ANNOTATION = Permission(
- group=Group.SYSTEM_RES_APPLICATION_CHAT_LOG, operate=Operate.ANNOTATION, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_CHAT_LOG_EXPORT = Permission(
- group=Group.SYSTEM_RES_APPLICATION_CHAT_LOG, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- RESOURCE_APPLICATION_CHAT_LOG_CLEAR_POLICY = Permission(
- group=Group.SYSTEM_RES_APPLICATION_CHAT_LOG, operate=Operate.CLEAR_POLICY, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_APPLICATION], is_ee=settings.edition == "EE"
- )
- # 知识库
- RESOURCE_KNOWLEDGE_READ = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_EDIT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DELETE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_SYNC = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.SYNC, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_EXPORT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_PUBLISH = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.PUBLISH, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_VECTOR = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.VECTOR, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_GENERATE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.GENERATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_AUTH = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.AUTH, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_RELATE_RESOURCE_VIEW = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- # 文档
- RESOURCE_KNOWLEDGE_WORKFLOW_READ = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_WORKFLOW, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_WORKFLOW_EDIT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_WORKFLOW, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_WORKFLOW_EXPORT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_WORKFLOW, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_WORKFLOW_PUBLISH = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_WORKFLOW, operate=Operate.PUBLISH, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_READ = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_CREATE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_EDIT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_DELETE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_SYNC = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.SYNC, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_EXPORT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_DOWNLOAD_SOURCE_FILE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.DOWNLOAD, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_GENERATE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.GENERATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_VECTOR = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.VECTOR, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_MIGRATE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.MIGRATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_TAG = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.TAG, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_DOCUMENT_REPLACE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_DOCUMENT, operate=Operate.REPLACE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_HIT_TEST = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_HIT_TEST, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_PROBLEM_READ = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_PROBLEM, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_PROBLEM_CREATE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_PROBLEM, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_PROBLEM_EDIT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_PROBLEM, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_PROBLEM_DELETE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_PROBLEM, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_PROBLEM_RELATE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_PROBLEM, operate=Operate.RELATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_TERMBASE_READ = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_TERMBASE, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_TERMBASE_CREATE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_TERMBASE, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_TERMBASE_EDIT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_TERMBASE, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_TERMBASE_DELETE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_TERMBASE, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_TERMBASE_EXPORT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_TERMBASE, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_TAG_READ = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_TAG, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_TAG_CREATE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_TAG, operate=Operate.CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_TAG_EDIT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_TAG, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_TAG_DELETE = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_TAG, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_CHAT_USER_READ = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_CHAT_USER, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_KNOWLEDGE_CHAT_USER_EDIT = Permission(
- group=Group.SYSTEM_RES_KNOWLEDGE_CHAT_USER, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_KNOWLEDGE], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_READ = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_EDIT = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_DELETE = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_EXPORT = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_PUBLISH = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.PUBLISH, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_AUTH = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.AUTH, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_RELATE_RESOURCE_VIEW = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_EXECUTE_RECORD = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.RECORD, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_TRIGGER_READ = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.TRIGGER_READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_TRIGGER_CREATE = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.TRIGGER_CREATE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_TRIGGER_EDIT = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.TRIGGER_EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_TOOL_TRIGGER_DELETE = Permission(
- group=Group.SYSTEM_RES_TOOL, operate=Operate.TRIGGER_DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_TOOL], is_ee=settings.edition == "EE"
- )
- RESOURCE_MODEL_READ = Permission(
- group=Group.SYSTEM_RES_MODEL, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_MODEL], is_ee=settings.edition == "EE"
- )
- RESOURCE_MODEL_EDIT = Permission(
- group=Group.SYSTEM_RES_MODEL, operate=Operate.EDIT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_MODEL], is_ee=settings.edition == "EE"
- )
- RESOURCE_MODEL_DELETE = Permission(
- group=Group.SYSTEM_RES_MODEL, operate=Operate.DELETE, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_MODEL], is_ee=settings.edition == "EE"
- )
- RESOURCE_MODEL_AUTH = Permission(
- group=Group.SYSTEM_RES_MODEL, operate=Operate.AUTH, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_MODEL], is_ee=settings.edition == "EE"
- )
- RESOURCE_MODEL_RELATE_RESOURCE_VIEW = Permission(
- group=Group.SYSTEM_RES_MODEL, operate=Operate.RELATE_VIEW, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.RESOURCE_MODEL], is_ee=settings.edition == "EE"
- )
- OPERATION_LOG_READ = Permission(
- group=Group.OPERATION_LOG, operate=Operate.READ, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.OPERATION_LOG]
- )
- OPERATION_LOG_EXPORT = Permission(
- group=Group.OPERATION_LOG, operate=Operate.EXPORT, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.OPERATION_LOG]
- )
- OPERATION_LOG_CLEAR_POLICY = Permission(
- group=Group.OPERATION_LOG, operate=Operate.CLEAR_POLICY, role_list=[RoleConstants.ADMIN],
- parent_group=[SystemGroup.OPERATION_LOG]
- )
-
- def get_workspace_application_permission(self):
- return lambda r, kwargs: Permission(group=self.value.group, operate=self.value.operate,
- resource_path=
- f"/WORKSPACE/{kwargs.get('workspace_id')}/APPLICATION/{kwargs.get('application_id')}")
-
- def get_workspace_knowledge_permission(self):
- return lambda r, kwargs: Permission(group=self.value.group, operate=self.value.operate,
- resource_path=
- f"/WORKSPACE/{kwargs.get('workspace_id')}/KNOWLEDGE/{kwargs.get('knowledge_id')}")
-
- def get_workspace_model_permission(self):
- return lambda r, kwargs: Permission(group=self.value.group, operate=self.value.operate,
- resource_path=
- f"/WORKSPACE/{kwargs.get('workspace_id')}/MODEL/{kwargs.get('model_id')}")
-
- def get_workspace_tool_permission(self):
- return lambda r, kwargs: Permission(group=self.value.group, operate=self.value.operate,
- resource_path=
- f"/WORKSPACE/{kwargs.get('workspace_id')}/TOOL/{kwargs.get('tool_id')}")
-
- def get_workspace_permission(self):
- return lambda r, kwargs: Permission(group=self.value.group, operate=self.value.operate,
- resource_path=
- f"/WORKSPACE/{kwargs.get('workspace_id')}")
-
- def get_workspace_permission_workspace_manage_role(self):
- return lambda r, kwargs: Permission(group=self.value.group, operate=self.value.operate,
- resource_path=
- f"/WORKSPACE/{kwargs.get('workspace_id')}:ROLE/{RoleConstants.WORKSPACE_MANAGE.value.__str__()}")
-
- def __eq__(self, other):
- if isinstance(other, PermissionConstants):
- return other == self
- else:
- return self.value == other
-
-
-def get_default_permission_list_by_role(role: RoleConstants):
- """
- 根据角色 获取角色对应的权限
- :param role: 角色
- :return: 权限
- """
- return list(map(lambda k: PermissionConstants[k],
- list(filter(lambda k: PermissionConstants[k].value.role_list.__contains__(role),
- PermissionConstants.__members__))))
-
-
-class RolePermissionMapping:
- def __init__(self, role_id, permission_id):
- self.role_id = role_id
- self.permission_id = permission_id
-
-
-class WorkspaceUserRoleMapping:
- def __init__(self, workspace_id, role_id, user_id):
- self.workspace_id = workspace_id
- self.role_id = role_id
- self.user_id = user_id
-
-
-def get_default_role_permission_mapping_list():
- role_permission_mapping_list = [
- [RolePermissionMapping(role.value.name, PermissionConstants[k].value.__str__()) for role in
- PermissionConstants[k].value.role_list] for k in PermissionConstants.__members__]
- return reduce(lambda x, y: [*x, *y], role_permission_mapping_list, [])
-
-
-def get_default_workspace_user_role_mapping_list(user_role_list: list):
- return [WorkspaceUserRoleMapping('default', role.value.name, 'default') for role in RoleConstants if
- user_role_list.__contains__(role.value.name)]
-
-
-def get_permission_list_by_resource_group(resource_group: ResourcePermissionGroup):
- """
- 根据资源组获取权限
- """
- return [PermissionConstants[k].value for k in PermissionConstants.__members__ if
- PermissionConstants[k].value.resource_permission_group_list.__contains__(resource_group)]
-
-
-class ChatAuth:
- def __init__(self,
- current_role_list: List[RoleConstants | Role],
- permission_list: List[PermissionConstants | Permission],
- chat_user_id,
- chat_user_type,
- application_id):
- # 权限列表
- self.permission_list = permission_list
- # 角色列表
- self.role_list = current_role_list
- self.chat_user_id = chat_user_id
- self.chat_user_type = chat_user_type
- self.application_id = application_id
-
-
-class Auth:
- """
- 用于存储当前用户的角色和权限
- """
-
- def __init__(self,
- current_role_list: List[RoleConstants | Role],
- permission_list: List[PermissionConstants | Permission],
- **keywords):
- # 权限列表
- self.permission_list = permission_list
- # 角色列表
- self.role_list = current_role_list
- self.keywords = keywords
-
-
-class CompareConstants(Enum):
- # 或者
- OR = "OR"
- # 并且
- AND = "AND"
-
-
-class ViewPermission:
- def __init__(self, roleList: List[RoleConstants], permissionList: List[PermissionConstants | object],
- compare=CompareConstants.OR):
- self.roleList = roleList
- self.permissionList = permissionList
- self.compare = compare
diff --git a/apps/common/constants/resource_permission_constants.py b/apps/common/constants/resource_permission_constants.py
new file mode 100644
index 00000000000..a692f72d5f4
--- /dev/null
+++ b/apps/common/constants/resource_permission_constants.py
@@ -0,0 +1,43 @@
+# coding=utf-8
+"""
+ @project: MaxKB
+ @Author:虎虎虎
+ @file: resource_permission_constants.py
+ @date:2026/8/4 15:38
+ @desc:
+"""
+from django.db import models
+
+
+class ResourcePermissionConstants(models.TextChoices):
+ """
+ 资源权限组
+ """
+ # 查看
+ VIEW = "VIEW"
+ # 管理
+ MANAGE = "MANAGE"
+ # 角色
+ ROLE = "ROLE"
+
+ def __eq__(self, other):
+ return str(self) == str(other)
+
+
+class ResourceAuthType(models.TextChoices):
+ """
+ 资源授权类型
+ """
+ "当授权类型是Role时候"
+ ROLE = "ROLE"
+
+ """资源权限组"""
+ RESOURCE_PERMISSION_GROUP = "RESOURCE_PERMISSION_GROUP"
+
+
+class AuthTargetType(models.TextChoices):
+ """授权目标"""
+ KNOWLEDGE = 'KNOWLEDGE', '知识库'
+ APPLICATION = 'APPLICATION', '应用'
+ TOOL = 'TOOL', '工具'
+ MODEL = 'MODEL', '模型'
diff --git a/apps/common/event/listener_manage.py b/apps/common/event/listener_manage.py
index f9617bbb417..74fbad95a8c 100644
--- a/apps/common/event/listener_manage.py
+++ b/apps/common/event/listener_manage.py
@@ -31,8 +31,9 @@
Termbase,
)
from knowledge.serializers.common import create_knowledge_index
-from langchain_core.embeddings import Embeddings
+from knowledge.services.paragraph_assets import embed_paragraph_assets
from maxkb.conf import PROJECT_DIR
+from models_provider.base_model_provider import MaxKBBaseEmbeddingModel
from common.config.embedding_config import VectorStore
from common.db.search import get_dynamics_model, native_search, native_update
@@ -61,7 +62,7 @@ def __init__(self, source_url_list: List[str], selector: str, handler):
class UpdateProblemArgs:
- def __init__(self, problem_id: str, problem_content: str, embedding_model: Embeddings):
+ def __init__(self, problem_id: str, problem_content: str, embedding_model: MaxKBBaseEmbeddingModel):
self.problem_id = problem_id
self.problem_content = problem_content
self.embedding_model = embedding_model
@@ -79,7 +80,7 @@ def __init__(
paragraph_id_list: List[str],
target_document_id: str,
target_knowledge_id: str,
- target_embedding_model: Embeddings = None,
+ target_embedding_model: MaxKBBaseEmbeddingModel = None,
):
self.paragraph_id_list = paragraph_id_list
self.target_document_id = target_document_id
@@ -89,11 +90,11 @@ def __init__(
class ListenerManagement:
@staticmethod
- def embedding_by_problem(args, embedding_model: Embeddings):
+ def embedding_by_problem(args, embedding_model: MaxKBBaseEmbeddingModel):
VectorStore.get_embedding_vector().save(**args, embedding=embedding_model)
@staticmethod
- def embedding_by_paragraph_list(paragraph_id_list, embedding_model: Embeddings):
+ def embedding_by_paragraph_list(paragraph_id_list, embedding_model: MaxKBBaseEmbeddingModel):
try:
data_list = native_search(
{
@@ -117,7 +118,7 @@ def embedding_by_paragraph_list(paragraph_id_list, embedding_model: Embeddings):
)
@staticmethod
- def embedding_by_paragraph_data_list(data_list, paragraph_id_list, embedding_model: Embeddings):
+ def embedding_by_paragraph_data_list(data_list, paragraph_id_list, embedding_model: MaxKBBaseEmbeddingModel):
maxkb_logger.info(
_("Start--->Embedding paragraph: {paragraph_id_list}").format(paragraph_id_list=paragraph_id_list)
)
@@ -130,6 +131,7 @@ def is_save_function():
# 批量向量化
VectorStore.get_embedding_vector().batch_save(data_list, embedding_model, is_save_function)
+ embed_paragraph_assets(paragraph_id_list, embedding_model)
ListenerManagement.update_status(
QuerySet(Paragraph).filter(id__in=paragraph_id_list), TaskType.EMBEDDING, State.SUCCESS
)
@@ -148,7 +150,7 @@ def is_save_function():
)
@staticmethod
- def embedding_by_paragraph(paragraph_id, embedding_model: Embeddings):
+ def embedding_by_paragraph(paragraph_id, embedding_model: MaxKBBaseEmbeddingModel):
"""
向量化段落 根据段落id
@param paragraph_id: 段落id
@@ -180,6 +182,7 @@ def is_the_task_interrupted():
# 批量向量化
VectorStore.get_embedding_vector().batch_save(data_list, embedding_model, is_the_task_interrupted)
+ embed_paragraph_assets([paragraph_id], embedding_model)
# 更新到开始状态
ListenerManagement.update_status(
QuerySet(Paragraph).filter(id=paragraph_id), TaskType.EMBEDDING, State.SUCCESS
@@ -197,7 +200,7 @@ def is_the_task_interrupted():
maxkb_logger.info(_("End--->Embedding paragraph: {paragraph_id}").format(paragraph_id=paragraph_id))
@staticmethod
- def embedding_by_data_list(data_list: List, embedding_model: Embeddings):
+ def embedding_by_data_list(data_list: List, embedding_model: MaxKBBaseEmbeddingModel):
# 批量向量化
VectorStore.get_embedding_vector().batch_save(data_list, embedding_model, lambda: False)
@@ -224,13 +227,11 @@ def tokenize_by_paragraph(paragraph_id):
chunks = paragraph.chunks
# 提前查询一次用户词汇,避免循环内重复查询
user_words = list(
- QuerySet(Termbase)
- .filter(knowledge_id=paragraph.knowledge_id)
- .values_list("content", flat=True)
+ QuerySet(Termbase).filter(knowledge_id=paragraph.knowledge_id).values_list("content", flat=True)
)
data_list = list(QuerySet(Embedding).filter(paragraph_id=paragraph_id))
for data, chunk in zip(data_list, chunks):
- data.search_vector = SearchVector(Value(to_ts_vector(chunk, user_words=user_words)), config='simple')
+ data.search_vector = SearchVector(Value(to_ts_vector(chunk, user_words=user_words)), config="simple")
# 批量保存,减少数据库写入次数
QuerySet(Embedding).filter(paragraph_id=paragraph_id).bulk_update(data_list, ["search_vector"])
@@ -351,7 +352,7 @@ def update_status(query_set: QuerySet, taskType: TaskType, state: State):
lock.release()
@staticmethod
- def embedding_by_document(document_id, embedding_model: Embeddings, state_list=None):
+ def embedding_by_document(document_id, embedding_model: MaxKBBaseEmbeddingModel, state_list=None):
"""
向量化文档
@param state_list:
@@ -412,7 +413,7 @@ def is_the_task_interrupted():
rlock.un_lock("embedding:" + str(document_id))
@staticmethod
- def embedding_by_knowledge(knowledge_id, embedding_model: Embeddings):
+ def embedding_by_knowledge(knowledge_id, embedding_model: MaxKBBaseEmbeddingModel):
"""
向量化知识库
@param knowledge_id: 知识库id
@@ -509,10 +510,18 @@ def hit_test(
top_number: int,
similarity: float,
search_mode: SearchMode,
- embedding: Embeddings,
+ embedding: MaxKBBaseEmbeddingModel,
+ image_list: list[str] | None = None,
):
return VectorStore.get_embedding_vector().hit_test(
- query_text, knowledge_id, exclude_document_id_list, top_number, similarity, search_mode, embedding
+ query_text,
+ knowledge_id,
+ exclude_document_id_list,
+ top_number,
+ similarity,
+ search_mode,
+ embedding,
+ image_list,
)
@staticmethod
@@ -544,8 +553,8 @@ def is_the_task_interrupted():
.annotate(
reversed_status=Reverse("status"),
task_type_status=Coalesce(
- NullIf(Substr("reversed_status", TaskType.TOKENIZE.value, 1), Value('')),
- Value('n'),
+ NullIf(Substr("reversed_status", TaskType.TOKENIZE.value, 1), Value("")),
+ Value("n"),
),
)
.filter(task_type_status__in=state_list, document_id=document_id)
diff --git a/apps/common/handle/base_to_response.py b/apps/common/handle/base_to_response.py
index 376d1a9ddd7..8f03a68f7f2 100644
--- a/apps/common/handle/base_to_response.py
+++ b/apps/common/handle/base_to_response.py
@@ -1,30 +1,40 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎
- @file: base_to_response.py
- @date:2024/9/6 16:04
- @desc:
+@project: MaxKB
+@Author:虎
+@file: base_to_response.py
+@date:2024/9/6 16:04
+@desc:
"""
+
from abc import ABC, abstractmethod
from rest_framework import status
class BaseToResponse(ABC):
+ @abstractmethod
+ def to_stream(self, chat_id, chat_record_id, block: dict):
+ """
+ 把一个内容块(content.to_dict())格式化成一帧 SSE 的 data 载荷(JSON 字符串)。
+ 返回 None 表示该块类型在此格式下不表达(消费方跳过)。
+ 只返回 data 载荷,不含 'data:'/'id:' 帧壳,帧壳由消费方拼。
+ """
+ pass
@abstractmethod
- def to_block_response(self, chat_id, chat_record_id, content, is_end, completion_tokens,
- prompt_tokens, other_params: dict = None,
- _status=status.HTTP_200_OK):
+ def to_stream_end(self, chat_id, chat_record_id, usage: dict = None):
+ """
+ 流结束帧(如 OpenAI 的空 delta + finish_reason=stop + 最终用量)。
+ 返回 None 表示该格式无需单独结束帧(如系统格式以 [DONE] 收尾)。
+ """
pass
@abstractmethod
- def to_stream_chunk_response(self, chat_id, chat_record_id, node_id, up_node_id_list, content, is_end,
- completion_tokens,
- prompt_tokens, other_params: dict = None):
+ def to_block(self, chat_id, chat_record_id, contents: list, usage: dict = None, _status=status.HTTP_200_OK):
+ """从聚合后的内容块列表(content.to_dict() 的 list)构造非流式响应。"""
pass
@staticmethod
def format_stream_chunk(response_str):
- return 'data: ' + response_str + '\n\n'
+ return "data: " + response_str + "\n\n"
diff --git a/apps/common/handle/impl/common_handle.py b/apps/common/handle/impl/common_handle.py
index 16c647a9626..99cab6f63c6 100644
--- a/apps/common/handle/impl/common_handle.py
+++ b/apps/common/handle/impl/common_handle.py
@@ -1,14 +1,14 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎
- @file: tools.py
- @date:2024/9/11 16:41
- @desc:
+@project: MaxKB
+@Author:虎
+@file: tools.py
+@date:2024/9/11 16:41
+@desc:
"""
+
import io
import traceback
-from functools import reduce
from io import BytesIO
from xml.etree.ElementTree import fromstring
from zipfile import ZipFile
@@ -23,8 +23,42 @@
from knowledge.models import File
from PIL import ImageFile
+
ImageFile.LOAD_TRUNCATED_IMAGES = True
-PILImage.MAX_IMAGE_PIXELS = None
+
+# 全局图片解码像素上限(不再禁用 Pillow 的解压炸弹保护)。
+# 超过该上限 Pillow 会告警,超过 2 倍会直接抛错,避免超大图片耗尽 worker 内存。
+PILImage.MAX_IMAGE_PIXELS = 50_000_000
+
+# 内嵌图片解码保护(防解压炸弹 / 超大尺寸图片导致共享 worker OOM)。
+MAX_EMBED_IMAGE_PIXELS = 16_000_000
+MAX_EMBED_IMAGE_AGGREGATE_PIXELS = 64_000_000
+
+# XLSX(zip) 压缩包防护,限制成员数 / 解压后总大小 / 解压膨胀比。
+MAX_EMBED_ARCHIVE_MEMBERS = 10_000
+MAX_EMBED_ARCHIVE_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024
+MAX_EMBED_ARCHIVE_EXPANSION_RATIO = 50
+
+
+def validate_xlsx_archive(archive: ZipFile):
+ infolist = archive.infolist()
+ if len(infolist) > MAX_EMBED_ARCHIVE_MEMBERS:
+ raise ValueError(f"XLSX archive member count exceeds limit: {len(infolist)}")
+ total_uncompressed = sum(info.file_size for info in infolist)
+ total_compressed = sum(info.compress_size for info in infolist)
+ if total_uncompressed > MAX_EMBED_ARCHIVE_UNCOMPRESSED_BYTES:
+ raise ValueError("XLSX archive uncompressed size exceeds limit")
+ if total_compressed > 0 and total_uncompressed > total_compressed * MAX_EMBED_ARCHIVE_EXPANSION_RATIO:
+ raise ValueError("XLSX archive expansion ratio exceeds limit")
+
+
+def validate_xlsx_buffer(buffer):
+ archive = ZipFile(buffer)
+ try:
+ validate_xlsx_archive(archive)
+ finally:
+ archive.close()
+
def parse_element(element) -> {}:
data = {}
@@ -87,15 +121,16 @@ def handle_images(deps, archive: ZipFile) -> []:
def xlsx_embed_cells_images(buffer) -> {}:
archive = ZipFile(buffer)
+ validate_xlsx_archive(archive)
# 解析cellImage.xml文件
deps = get_dependents(archive, get_rels_path("xl/cellimages.xml"))
image_rel = handle_images(deps=deps, archive=archive)
# 工作表及其中图片ID
sheet_list = {}
for item in archive.namelist():
- if not item.startswith('xl/worksheets/sheet'):
+ if not item.startswith("xl/worksheets/sheet"):
continue
- key = item.split('/')[-1].split('.')[0].split('sheet')[-1]
+ key = item.split("/")[-1].split(".")[0].split("sheet")[-1]
sheet_list[key] = parse_element_sheet_xml(fromstring(archive.read(item)))
cell_images_xml = parse_element(fromstring(archive.read("xl/cellimages.xml")))
cell_images_rel = {}
@@ -104,18 +139,11 @@ def xlsx_embed_cells_images(buffer) -> {}:
for cnv, embed in cell_images_xml.items():
cell_images_xml[cnv] = cell_images_rel.get(embed)
result = {}
+ total_pixels = 0
for key, img in cell_images_xml.items():
- all_cells = [
- cell
- for _sheet_id, sheet in sheet_list.items()
- if sheet is not None
- for cell in sheet or []
- ]
-
- image_excel_id_list = [
- cell for cell in all_cells
- if isinstance(cell, str) and key in cell
- ]
+ all_cells = [cell for _sheet_id, sheet in sheet_list.items() if sheet is not None for cell in sheet or []]
+
+ image_excel_id_list = [cell for cell in all_cells if isinstance(cell, str) and key in cell]
# print(key, img)
if img is None:
continue
@@ -123,9 +151,24 @@ def xlsx_embed_cells_images(buffer) -> {}:
image_excel_id = image_excel_id_list[-1]
f = archive.open(img.target)
img_byte = io.BytesIO()
- im = PILImage.open(f).convert('RGB')
- im.save(img_byte, format='JPEG')
- image = File(id=uuid.uuid7(), file_name=img.path, meta={'debug': False, 'content': img_byte.getvalue()})
- result['=' + image_excel_id] = image
+ try:
+ with PILImage.open(f) as im:
+ width, height = im.size
+ pixels = width * height
+ if pixels > MAX_EMBED_IMAGE_PIXELS:
+ maxkb_logger.warning(
+ f"Skip oversized embedded image {img.path}: {width}x{height} pixels exceeds limit"
+ )
+ continue
+ total_pixels += pixels
+ if total_pixels > MAX_EMBED_IMAGE_AGGREGATE_PIXELS:
+ maxkb_logger.warning("Skip embedded images in archive: aggregate pixels exceed limit")
+ break
+ im.convert("RGB").save(img_byte, format="JPEG")
+ except Exception as e:
+ maxkb_logger.error(f"Error decoding image {img.target}: {e}, {traceback.format_exc()}")
+ continue
+ image = File(id=uuid.uuid7(), file_name=img.path, meta={"debug": False, "content": img_byte.getvalue()})
+ result["=" + image_excel_id] = image
archive.close()
return result
diff --git a/apps/common/handle/impl/qa/xlsx_parse_qa_handle.py b/apps/common/handle/impl/qa/xlsx_parse_qa_handle.py
index 71332b1b9e1..ff5987763f6 100644
--- a/apps/common/handle/impl/qa/xlsx_parse_qa_handle.py
+++ b/apps/common/handle/impl/qa/xlsx_parse_qa_handle.py
@@ -1,18 +1,19 @@
# coding=utf-8
"""
- @project: maxkb
- @Author:虎
- @file: xlsx_parse_qa_handle.py
- @date:2024/5/21 14:59
- @desc:
+@project: maxkb
+@Author:虎
+@file: xlsx_parse_qa_handle.py
+@date:2024/5/21 14:59
+@desc:
"""
+
import io
import traceback
import openpyxl
from common.handle.base_parse_qa_handle import BaseParseQAHandle, get_title_row_index_dict, get_row_value
-from common.handle.impl.common_handle import xlsx_embed_cells_images
+from common.handle.impl.common_handle import xlsx_embed_cells_images, validate_xlsx_buffer
from common.utils.logger import maxkb_logger
@@ -22,28 +23,26 @@ def handle_sheet(file_name, sheet, image_dict):
title_row_list = next(rows)
title_row_list = [row.value for row in title_row_list]
except Exception as e:
- return {'name': file_name, 'paragraphs': []}
+ return {"name": file_name, "paragraphs": []}
if len(title_row_list) == 0:
- return {'name': file_name, 'paragraphs': []}
+ return {"name": file_name, "paragraphs": []}
title_row_index_dict = get_title_row_index_dict(title_row_list)
paragraph_list = []
for row in rows:
- content = get_row_value(row, title_row_index_dict, 'content')
+ content = get_row_value(row, title_row_index_dict, "content")
if content is None or content.value is None:
continue
- problem = get_row_value(row, title_row_index_dict, 'problem_list')
- problem = str(problem.value) if problem is not None and problem.value is not None else ''
- problem_list = [{'content': p[0:255]} for p in problem.split('\n') if len(p.strip()) > 0]
- title = get_row_value(row, title_row_index_dict, 'title')
- title = str(title.value) if title is not None and title.value is not None else ''
+ problem = get_row_value(row, title_row_index_dict, "problem_list")
+ problem = str(problem.value) if problem is not None and problem.value is not None else ""
+ problem_list = [{"content": p[0:255]} for p in problem.split("\n") if len(p.strip()) > 0]
+ title = get_row_value(row, title_row_index_dict, "title")
+ title = str(title.value) if title is not None and title.value is not None else ""
content = str(content.value)
image = image_dict.get(content, None)
if image is not None:
- content = f''
- paragraph_list.append({'title': title[0:255],
- 'content': content[0:102400],
- 'problem_list': problem_list})
- return {'name': file_name, 'paragraphs': paragraph_list}
+ content = f""
+ paragraph_list.append({"title": title[0:255], "content": content[0:102400], "problem_list": problem_list})
+ return {"name": file_name, "paragraphs": paragraph_list}
class XlsxParseQAHandle(BaseParseQAHandle):
@@ -56,6 +55,7 @@ def support(self, file, get_buffer):
def handle(self, file, get_buffer, save_image):
buffer = get_buffer(file)
try:
+ validate_xlsx_buffer(io.BytesIO(buffer))
workbook = openpyxl.load_workbook(io.BytesIO(buffer))
try:
image_dict: dict = xlsx_embed_cells_images(io.BytesIO(buffer))
@@ -64,12 +64,16 @@ def handle(self, file, get_buffer, save_image):
image_dict = {}
worksheets = workbook.worksheets
worksheets_size = len(worksheets)
- return [row for row in
- [handle_sheet(file.name,
- sheet,
- image_dict) if worksheets_size == 1 and sheet.title == 'Sheet1' else handle_sheet(
- sheet.title, sheet, image_dict) for sheet
- in worksheets] if row is not None]
+ return [
+ row
+ for row in [
+ handle_sheet(file.name, sheet, image_dict)
+ if worksheets_size == 1 and sheet.title == "Sheet1"
+ else handle_sheet(sheet.title, sheet, image_dict)
+ for sheet in worksheets
+ ]
+ if row is not None
+ ]
except Exception as e:
maxkb_logger.error(f"Error processing XLSX file {file.name}: {e}, {traceback.format_exc()}")
- return [{'name': file.name, 'paragraphs': []}]
+ return [{"name": file.name, "paragraphs": []}]
diff --git a/apps/common/handle/impl/qa/zip_parse_qa_handle.py b/apps/common/handle/impl/qa/zip_parse_qa_handle.py
index cdf56ef1524..02b135c368e 100644
--- a/apps/common/handle/impl/qa/zip_parse_qa_handle.py
+++ b/apps/common/handle/impl/qa/zip_parse_qa_handle.py
@@ -20,7 +20,7 @@
from common.handle.impl.qa.csv_parse_qa_handle import CsvParseQAHandle
from common.handle.impl.qa.xls_parse_qa_handle import XlsParseQAHandle
from common.handle.impl.qa.xlsx_parse_qa_handle import XlsxParseQAHandle
-from common.utils.common import parse_md_image
+from common.utils.common import parse_md_image, parse_md_file_link
from knowledge.models import File
@@ -70,42 +70,39 @@ def is_valid_uuid(uuid_str: str):
def get_image_list(result_list: list, zip_files: List[str]):
- """
- 获取图片文件列表
- @param result_list:
- @param zip_files:
- @return:
- """
image_file_list = []
for result in result_list:
for p in result.get('paragraphs', []):
content: str = p.get('content', '')
- image_list = parse_md_image(content)
- for image in image_list:
- search = re.search("\(.*\)", image)
- if search:
- new_image_id = str(uuid.uuid7())
- source_image_path = search.group().replace('(', '').replace(')', '')
- image_path = urljoin(result.get('name'), '.' + source_image_path if source_image_path.startswith(
- '/') else source_image_path)
- if not zip_files.__contains__(image_path):
- continue
- if image_path.startswith('oss/file/') or image_path.startswith('oss/image/'):
- image_id = image_path.replace('oss/file/', '')
- if is_valid_uuid(image_id):
- image_file_list.append({'source_file': image_path,
- 'image_id': image_id})
- else:
- image_file_list.append({'source_file': image_path,
- 'image_id': new_image_id})
- content = content.replace(source_image_path, f'./oss/file/{new_image_id}')
- p['content'] = content
+ tokens = parse_md_image(content) + parse_md_file_link(content)
+ for token in tokens:
+ src_match = re.search(r'\bsrc=["\']([^"\']+)["\']', token)
+ paren_match = re.search(r'\(([^)]*)\)', token)
+ if src_match:
+ source_path = src_match.group(1).strip()
+ elif paren_match:
+ source_path = paren_match.group(1).strip().split(" ")[0]
+ else:
+ continue
+ new_image_id = str(uuid.uuid7())
+ image_path = urljoin(result.get('name'), '.' + source_path if source_path.startswith(
+ '/') else source_path)
+ if image_path not in zip_files:
+ continue
+ if image_path.startswith('oss/file/') or image_path.startswith('oss/image/'):
+ image_id = image_path.replace('oss/file/', '').replace('oss/image/', '')
+ if is_valid_uuid(image_id):
+ image_file_list.append({"source_file": image_path, "image_id": new_image_id})
+ content = content.replace(source_path, f"./oss/file/{new_image_id}")
+ p['content'] = content
else:
- image_file_list.append({'source_file': image_path,
- 'image_id': new_image_id})
- content = content.replace(source_image_path, f'./oss/file/{new_image_id}')
+ image_file_list.append({'source_file': image_path, 'image_id': new_image_id})
+ content = content.replace(source_path, f'./oss/file/{new_image_id}')
p['content'] = content
-
+ else:
+ image_file_list.append({'source_file': image_path, 'image_id': new_image_id})
+ content = content.replace(source_path, f'./oss/file/{new_image_id}')
+ p['content'] = content
return image_file_list
diff --git a/apps/common/handle/impl/response/openai_to_response.py b/apps/common/handle/impl/response/openai_to_response.py
index b4eda362555..98023aef364 100644
--- a/apps/common/handle/impl/response/openai_to_response.py
+++ b/apps/common/handle/impl/response/openai_to_response.py
@@ -1,12 +1,11 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎
- @file: openai_to_response.py
- @date:2024/9/6 16:08
- @desc:
+@project: MaxKB
+@Author:虎
+@file: openai_to_response.py
+@date:2024/9/6 16:08
+@desc:
"""
-import datetime
from django.http import JsonResponse
from django.utils import timezone
@@ -20,34 +19,102 @@
class OpenaiToResponse(BaseToResponse):
- def to_block_response(self, chat_id, chat_record_id, content, is_end, prompt_tokens, completion_tokens,
- other_params: dict = None,
- _status=status.HTTP_200_OK):
- if other_params is None:
- other_params = {}
- data = ChatCompletion(id=chat_record_id, choices=[
- BlockChoice(finish_reason='stop', index=0, chat_id=chat_id,
- answer_list=other_params.get('answer_list', ""),
- message=ChatCompletionMessage(role='assistant', content=content))],
- created=timezone.now().second, model='', object='chat.completion',
- usage=CompletionUsage(completion_tokens=completion_tokens,
- prompt_tokens=prompt_tokens,
- total_tokens=completion_tokens + prompt_tokens)
- ).dict()
- return JsonResponse(data=data, status=_status)
+ def __init__(self):
+ # per-response 状态:tool_id -> index,逐帧分配,客户端按 index 累加 arguments
+ self._tool_index = {}
+
+ def _to_tool_call_delta(self, block: dict) -> dict:
+ """把一个 ToolContent 块转成 OpenAI 的 delta.tool_calls 项;靠稳定 id 分帧、不缓冲。"""
+ tool_id = block.get("id")
+ first = tool_id not in self._tool_index
+ if first:
+ self._tool_index[tool_id] = len(self._tool_index)
+ index = self._tool_index[tool_id]
+ function = {"arguments": block.get("arguments") or ""}
+ if first:
+ function["name"] = block.get("content") or "" # ToolContent.content = 工具名
+ tool_call = {"index": index, "type": "function", "function": function}
+ if first:
+ tool_call["id"] = tool_id
+ # 非标扩展:result(与 reasoning_content/chat_id 一致),标准客户端忽略、自家客户端读
+ if block.get("result"):
+ tool_call["result"] = block.get("result")
+ return tool_call
+
+ def to_stream(self, chat_id, chat_record_id, block: dict):
+ block_type = block.get("type")
+ delta_kwargs = {"chat_id": chat_id}
+ if block_type == "TEXT":
+ delta_kwargs["content"] = block.get("content", "")
+ elif block_type == "REASONING":
+ delta_kwargs["reasoning_content"] = block.get("content", "")
+ elif block_type == "TOOL":
+ delta_kwargs["tool_calls"] = [self._to_tool_call_delta(block)]
+ else:
+ # FORM / FAILURE 等:OpenAI 流不表达,跳过
+ return None
+ # 内容帧:finish_reason=None、usage=None(用量只在结束帧给,符合 OpenAI 规范)
+ return ChatCompletionChunk(
+ id=str(chat_record_id),
+ model="",
+ object="chat.completion.chunk",
+ created=int(timezone.now().timestamp()),
+ choices=[Choice(delta=ChoiceDelta(**delta_kwargs), finish_reason=None, index=0)],
+ ).json()
- def to_stream_chunk_response(self, chat_id, chat_record_id, node_id, up_node_id_list, content, is_end,
- prompt_tokens,
- completion_tokens, other_params: dict = None):
- if other_params is None:
- other_params = {}
- chunk = ChatCompletionChunk(id=chat_record_id, model='', object='chat.completion.chunk',
- created=timezone.now().second, choices=[
- Choice(delta=ChoiceDelta(content=content, reasoning_content=other_params.get('reasoning_content', ""),
- chat_id=chat_id),
- finish_reason='stop' if is_end else None,
- index=0)],
- usage=CompletionUsage(completion_tokens=completion_tokens,
- prompt_tokens=prompt_tokens,
- total_tokens=completion_tokens + prompt_tokens)).json()
- return super().format_stream_chunk(chunk)
+ def to_stream_end(self, chat_id, chat_record_id, usage: dict = None):
+ # 结束帧:空 delta + finish_reason=stop + 最终用量
+ usage = usage or {}
+ completion_tokens = usage.get("completion_tokens", 0)
+ prompt_tokens = usage.get("prompt_tokens", 0)
+ return ChatCompletionChunk(
+ id=str(chat_record_id),
+ model="",
+ object="chat.completion.chunk",
+ created=int(timezone.now().timestamp()),
+ choices=[Choice(delta=ChoiceDelta(chat_id=chat_id), finish_reason="stop", index=0)],
+ usage=CompletionUsage(
+ completion_tokens=completion_tokens,
+ prompt_tokens=prompt_tokens,
+ total_tokens=completion_tokens + prompt_tokens,
+ ),
+ ).json()
+
+ def to_block(self, chat_id, chat_record_id, contents: list, usage: dict = None, _status=status.HTTP_200_OK):
+ usage = usage or {}
+ answer = "".join(c.get("content", "") for c in (contents or []) if c.get("type") == "TEXT")
+ tool_calls = []
+ for c in contents or []:
+ if c.get("type") != "TOOL":
+ continue
+ tc = {
+ "index": len(tool_calls),
+ "id": c.get("id"),
+ "type": "function",
+ "function": {"name": c.get("content") or "", "arguments": c.get("arguments") or ""},
+ }
+ if c.get("result"):
+ tc["result"] = c.get("result")
+ tool_calls.append(tc)
+ message_kwargs = {"role": "assistant", "content": answer}
+ if tool_calls:
+ message_kwargs["tool_calls"] = tool_calls
+ completion_tokens = usage.get("completion_tokens", 0)
+ prompt_tokens = usage.get("prompt_tokens", 0)
+ data = ChatCompletion(
+ id=str(chat_record_id),
+ choices=[
+ BlockChoice(
+ finish_reason="stop", index=0, chat_id=chat_id, message=ChatCompletionMessage(**message_kwargs)
+ )
+ ],
+ created=int(timezone.now().timestamp()),
+ model="",
+ object="chat.completion",
+ usage=CompletionUsage(
+ completion_tokens=completion_tokens,
+ prompt_tokens=prompt_tokens,
+ total_tokens=completion_tokens + prompt_tokens,
+ ),
+ ).dict()
+ return JsonResponse(data=data, status=_status)
diff --git a/apps/common/handle/impl/response/system_to_response.py b/apps/common/handle/impl/response/system_to_response.py
index a1a530dba08..daabc6cc1b5 100644
--- a/apps/common/handle/impl/response/system_to_response.py
+++ b/apps/common/handle/impl/response/system_to_response.py
@@ -1,11 +1,12 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎
- @file: system_to_response.py
- @date:2024/9/6 18:03
- @desc:
+@project: MaxKB
+@Author:虎
+@file: system_to_response.py
+@date:2024/9/6 18:03
+@desc:
"""
+
import json
from rest_framework import status
@@ -15,27 +16,35 @@
class SystemToResponse(BaseToResponse):
- def to_block_response(self, chat_id, chat_record_id, content, is_end, completion_tokens,
- prompt_tokens, other_params: dict = None,
- _status=status.HTTP_200_OK):
- if other_params is None:
- other_params = {}
- return result.success({'chat_id': str(chat_id), 'id': str(chat_record_id), 'operate': True,
- 'content': content, 'is_end': is_end, **other_params,
- 'completion_tokens': completion_tokens, 'prompt_tokens': prompt_tokens},
- response_status=_status,
- code=_status)
+ def to_stream(self, chat_id, chat_record_id, block: dict):
+ # 沿用前端在解析的信封 shape:{chat_id, chat_record_id, content:[block]}
+ # 系统格式所有块类型都原样下发(block 即 content.to_dict())
+ return json.dumps(
+ {
+ "chat_id": str(chat_id),
+ "chat_record_id": str(chat_record_id),
+ "content": [{**block, "chat_id": str(chat_id), "chat_record_id": str(chat_record_id)}],
+ },
+ ensure_ascii=False,
+ )
+
+ def to_stream_end(self, chat_id, chat_record_id, usage: dict = None):
+ # 系统格式以 [DONE] 收尾,无需单独结束帧
+ return None
- def to_stream_chunk_response(self, chat_id, chat_record_id, node_id, up_node_id_list, content, is_end,
- completion_tokens,
- prompt_tokens, other_params: dict = None):
- if other_params is None:
- other_params = {}
- chunk = json.dumps({'chat_id': str(chat_id), 'chat_record_id': str(chat_record_id), 'operate': True,
- 'content': content, 'node_id': node_id, 'up_node_id_list': up_node_id_list,
- 'is_end': is_end,
- 'usage': {'completion_tokens': completion_tokens,
- 'prompt_tokens': prompt_tokens,
- 'total_tokens': completion_tokens + prompt_tokens},
- **other_params})
- return super().format_stream_chunk(chunk)
+ def to_block(self, chat_id, chat_record_id, contents: list, usage: dict = None, _status=status.HTTP_200_OK):
+ usage = usage or {}
+ answer = "".join(c.get("content", "") for c in (contents or []) if c.get("type") == "TEXT")
+ return result.success(
+ {
+ "chat_id": str(chat_id),
+ "id": str(chat_record_id),
+ "operate": True,
+ "content": answer,
+ "is_end": True,
+ "completion_tokens": usage.get("completion_tokens", 0),
+ "prompt_tokens": usage.get("prompt_tokens", 0),
+ },
+ response_status=_status,
+ code=_status,
+ )
diff --git a/apps/common/handle/impl/table/xlsx_parse_table_handle.py b/apps/common/handle/impl/table/xlsx_parse_table_handle.py
index 2acf5aa1a95..fb76678b282 100644
--- a/apps/common/handle/impl/table/xlsx_parse_table_handle.py
+++ b/apps/common/handle/impl/table/xlsx_parse_table_handle.py
@@ -6,14 +6,15 @@
from openpyxl import load_workbook
from common.handle.base_parse_table_handle import BaseParseTableHandle
-from common.handle.impl.common_handle import xlsx_embed_cells_images
+from common.handle.impl.common_handle import xlsx_embed_cells_images, validate_xlsx_buffer
+from common.handle.impl.xlsx_utils import iter_sheet_content_rows
from common.utils.logger import maxkb_logger
class XlsxParseTableHandle(BaseParseTableHandle):
def support(self, file, get_buffer):
file_name: str = file.name.lower()
- if file_name.endswith('.xlsx'):
+ if file_name.endswith(".xlsx"):
return True
return False
@@ -22,14 +23,19 @@ def fill_merged_cells(self, sheet, image_dict):
# 获取第一行作为标题行
headers = []
- for idx, cell in enumerate(sheet[1]):
+ rows = iter_sheet_content_rows(sheet)
+ try:
+ title_row = next(rows)
+ except StopIteration:
+ return data
+ for idx, cell in enumerate(title_row):
if cell.value is None:
- headers.append(' ' * (idx + 1))
+ headers.append(" " * (idx + 1))
else:
headers.append(cell.value)
# 从第二行开始遍历每一行
- for row in sheet.iter_rows(min_row=2, values_only=False):
+ for row in rows:
row_data = {}
for col_idx, cell in enumerate(row):
cell_value = cell.value
@@ -41,10 +47,10 @@ def fill_merged_cells(self, sheet, image_dict):
cell_value = sheet[merged_range.min_row][merged_range.min_col - 1].value
break
if cell_value is None:
- cell_value = ''
+ cell_value = ""
image = image_dict.get(cell_value, None)
if image is not None:
- cell_value = f''
+ cell_value = f""
# 使用标题作为键,单元格的值作为值存入字典
row_data[headers[col_idx]] = cell_value
@@ -55,6 +61,7 @@ def fill_merged_cells(self, sheet, image_dict):
def handle(self, file, get_buffer, save_image):
buffer = get_buffer(file)
try:
+ validate_xlsx_buffer(io.BytesIO(buffer))
wb = load_workbook(io.BytesIO(buffer))
try:
image_dict: dict = xlsx_embed_cells_images(io.BytesIO(buffer))
@@ -70,13 +77,13 @@ def handle(self, file, get_buffer, save_image):
for row in data:
row_output = "; ".join([f"{key}: {value}" for key, value in row.items()])
# print(row_output)
- paragraphs.append({'title': '', 'content': row_output})
+ paragraphs.append({"title": "", "content": row_output})
- result.append({'name': sheetname, 'paragraphs': paragraphs})
+ result.append({"name": sheetname, "paragraphs": paragraphs})
except BaseException as e:
maxkb_logger.error(f"Error processing XLSX file {file.name}: {e}, {traceback.format_exc()}")
- return [{'name': file.name, 'paragraphs': []}]
+ return [{"name": file.name, "paragraphs": []}]
return result
def get_content(self, file, save_image):
@@ -88,9 +95,9 @@ def get_content(self, file, save_image):
if len(image_dict) > 0:
save_image(image_dict.values())
except Exception as e:
- maxkb_logger.error(f'Exception: {e}')
+ maxkb_logger.error(f"Exception: {e}")
image_dict = {}
- md_tables = ''
+ md_tables = ""
# 遍历所有工作表
for sheetname in workbook.sheetnames:
sheet = workbook[sheetname]
@@ -99,22 +106,25 @@ def get_content(self, file, save_image):
continue
# 添加 sheet 名称作为标题
- md_tables += f'## {sheetname}\n\n'
+ md_tables += f"## {sheetname}\n\n"
# 提取表头和内容
headers = [f"{key}" for key, value in rows[0].items()]
# 构建 Markdown 表格
- md_table = '| ' + ' | '.join(headers) + ' |\n'
- md_table += '| ' + ' | '.join(['---'] * len(headers)) + ' |\n'
+ md_table = "| " + " | ".join(headers) + " |\n"
+ md_table += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in rows:
- r = [f'{value}' for key, value in row.items()]
- md_table += '| ' + ' | '.join(
- [str(cell).replace('\n', '
') if cell is not None else '' for cell in r]) + ' |\n'
+ r = [f"{value}" for key, value in row.items()]
+ md_table += (
+ "| "
+ + " | ".join([str(cell).replace("\n", "
") if cell is not None else "" for cell in r])
+ + " |\n"
+ )
- md_tables += md_table + '\n\n'
+ md_tables += md_table + "\n\n"
return md_tables
except Exception as e:
- maxkb_logger.error(f'excel split handle error: {e}')
- return f'error: {e}'
+ maxkb_logger.error(f"excel split handle error: {e}")
+ return f"error: {e}"
diff --git a/apps/common/handle/impl/text/pdf_split_handle.py b/apps/common/handle/impl/text/pdf_split_handle.py
index dde048887a1..3e416285b94 100644
--- a/apps/common/handle/impl/text/pdf_split_handle.py
+++ b/apps/common/handle/impl/text/pdf_split_handle.py
@@ -14,13 +14,15 @@
import traceback
from typing import List
+import uuid_utils.compat as uuid
+from django.utils.translation import gettext_lazy as _
from pypdf import PdfReader
from pypdf.generic import Destination
-from django.utils.translation import gettext_lazy as _
from common.handle.base_split_handle import BaseSplitHandle
from common.utils.logger import maxkb_logger
from common.utils.split_model import SplitModel, smart_split_paragraph
+from knowledge.models import File
default_pattern_list = [
re.compile("(?<=^)# .*|(?<=\\n)# .*"),
@@ -76,25 +78,19 @@ def handle(
return {"name": file.name, "content": result}
# 没目录但是有链接的pdf
- result = self.handle_links(
- pdf_document, pattern_list, with_filter, limit
- )
+ result = self.handle_links(pdf_document, pattern_list, with_filter, limit)
if result is not None and len(result) > 0:
return {"name": file.name, "content": result}
# 没有目录的pdf
- content = self.handle_pdf_content(file, pdf_document)
+ content = self.handle_pdf_content(file, pdf_document, save_image)
if pattern_list is not None and len(pattern_list) > 0:
split_model = SplitModel(pattern_list, with_filter, limit)
else:
- split_model = SplitModel(
- default_pattern_list, with_filter=with_filter, limit=limit
- )
+ split_model = SplitModel(default_pattern_list, with_filter=with_filter, limit=limit)
except BaseException as e:
- maxkb_logger.error(
- f"File: {file.name}, error: {e}, {traceback.format_exc()}"
- )
+ maxkb_logger.error(f"File: {file.name}, error: {e}, {traceback.format_exc()}")
return {"name": file.name, "content": []}
finally:
# 处理完后可以删除临时文件
@@ -103,7 +99,7 @@ def handle(
return {"name": file.name, "content": split_model.parse(content)}
@staticmethod
- def handle_pdf_content(file, pdf_document):
+ def handle_pdf_content(file, pdf_document, save_image):
# 第一步:收集所有字体大小
font_sizes = []
page_lines = []
@@ -124,6 +120,7 @@ def handle_pdf_content(file, pdf_document):
# 第二步:提取内容
content = ""
+ image_list = []
for page_num, page in enumerate(pdf_document.pages):
start_time = time.time()
@@ -142,15 +139,22 @@ def handle_pdf_content(file, pdf_document):
content += f"{text}\n"
for image_index in range(PdfSplitHandle.get_page_image_count(page)):
- content += f"\n\n"
+ try:
+ image = page.images[image_index]
+ except Exception as e:
+ maxkb_logger.warning(f"File: {file.name}, Page: {page_num + 1}, Image: {image_index}, error: {e}")
+ continue
+ image_id = uuid.uuid7()
+ image_list.append(File(id=image_id, file_name=image.name, meta={"debug": False, "content": image.data}))
+ content += f"\n\n"
content = content.replace("\0", "")
elapsed_time = time.time() - start_time
- maxkb_logger.debug(
- f"File: {file.name}, Page: {page_num + 1}, Time: {elapsed_time:.3f}s"
- )
+ maxkb_logger.debug(f"File: {file.name}, Page: {page_num + 1}, Time: {elapsed_time:.3f}s")
+ if image_list:
+ save_image(image_list)
return content
@staticmethod
@@ -228,7 +232,80 @@ def collect_toc(doc, outline, level, toc):
title = item.get("/Title")
if title is None:
title = str(item)
- toc.append((level, str(title).replace("\0", ""), page_number))
+ toc.append(
+ (
+ level,
+ str(title).replace("\0", ""),
+ page_number,
+ PdfSplitHandle.get_destination_top(item),
+ )
+ )
+
+ @staticmethod
+ def get_destination_top(destination):
+ top = getattr(destination, "top", None)
+ try:
+ return float(top)
+ except (TypeError, ValueError):
+ return None
+
+ @staticmethod
+ def extract_page_text_by_position(page, top=None, bottom=None):
+ if top is None and bottom is None:
+ return PdfSplitHandle.extract_page_text(page)
+
+ text_parts = []
+
+ def visitor_text(text, cm, tm, font_dict, font_size):
+ if not text:
+ return
+
+ # Text matrix coordinates can be relative to a page-level transform.
+ # Convert the text origin to PDF user-space coordinates before comparing
+ # it with the outline destination's /Top value.
+ x = tm[4] if len(tm) > 4 else 0
+ y = tm[5] if len(tm) > 5 else 0
+ if len(cm) > 5:
+ y = x * cm[1] + y * cm[3] + cm[5]
+
+ if top is not None and y > top:
+ return
+ if bottom is not None and y <= bottom:
+ return
+ text_parts.append(text)
+
+ try:
+ page.extract_text(visitor_text=visitor_text)
+ except BaseException:
+ return PdfSplitHandle.extract_page_text(page)
+ return "".join(text_parts).replace("\0", "")
+
+ @staticmethod
+ def remove_leading_title(text, *titles):
+ for title in titles:
+ title = title.strip()
+ if not title:
+ continue
+ pattern = r"^\s*" + r"\s*".join(re.escape(char) for char in title)
+ stripped_text, count = re.subn(pattern, "", text, count=1)
+ if count:
+ return stripped_text
+ return text
+
+ @staticmethod
+ def discard_ambiguous_destination_tops(toc):
+ position_counts = {}
+ for _level, _title, page_number, top in toc:
+ if top is not None:
+ position = (page_number, top)
+ position_counts[position] = position_counts.get(position, 0) + 1
+
+ ambiguous_tops = {top for (_page_number, top), count in position_counts.items() if count > 1}
+
+ return [
+ (level, title, page_number, None if top in ambiguous_tops else top)
+ for level, title, page_number, top in toc
+ ]
@staticmethod
def handle_toc(doc, limit):
@@ -236,19 +313,29 @@ def handle_toc(doc, limit):
toc = PdfSplitHandle.get_toc(doc)
if toc is None or len(toc) == 0:
return None
+ # Some PDF generators assign the same default position to every bookmark
+ # on a page. Such coordinates cannot define chapter boundaries, so preserve
+ # the title-based behavior for those entries.
+ toc = PdfSplitHandle.discard_ambiguous_destination_tops(toc)
# 创建存储章节内容的数组
chapters = []
# 遍历目录并按章节提取文本
for i, entry in enumerate(toc):
- level, title, start_page = entry
+ level, title, start_page, start_top = entry
chapter_title = title
# 确定结束页码,如果是最后一个章节则到文档末尾
if i + 1 < len(toc):
- end_page = toc[i + 1][2] - 1
+ _next_level, next_title, next_start_page, next_top = toc[i + 1]
+ # A positioned bookmark can start partway down a page. Include that
+ # page and keep only the text above the next bookmark for this chapter.
+ end_page = next_start_page if next_top is not None else next_start_page - 1
else:
end_page = len(doc.pages) - 1
+ next_title = None
+ next_start_page = None
+ next_top = None
end_page = max(start_page, end_page)
# 去掉标题中的符号
@@ -257,20 +344,23 @@ def handle_toc(doc, limit):
# 提取该章节的文本内容
chapter_text = ""
for page_num in range(start_page, end_page + 1):
- text = PdfSplitHandle.extract_page_text(doc.pages[page_num])
+ page_top = start_top if page_num == start_page else None
+ page_bottom = next_top if page_num == next_start_page else None
+ text = PdfSplitHandle.extract_page_text_by_position(doc.pages[page_num], page_top, page_bottom)
text = re.sub(r"(? -1:
- text = text[idx + len(title) :]
+ if page_num == start_page:
+ if start_top is not None:
+ text = PdfSplitHandle.remove_leading_title(text, chapter_title, title)
+ else:
+ idx = text.find(title)
+ if idx > -1:
+ text = text[idx + len(title) :]
- if i + 1 < len(toc):
- _level, next_title, next_start_page = toc[i + 1]
- next_title = PdfSplitHandle.handle_chapter_title(next_title)
- # print(f'next_title: {next_title}')
- idx = text.find(next_title)
+ if next_title is not None and next_top is None:
+ handled_next_title = PdfSplitHandle.handle_chapter_title(next_title)
+ idx = text.find(handled_next_title)
if idx > -1:
text = text[:idx]
@@ -284,12 +374,16 @@ def handle_toc(doc, limit):
if 0 < limit < len(chapter_text):
split_text = smart_split_paragraph(chapter_text, limit)
for text in split_text:
- chapters.append({"title": real_chapter_title, "content": text})
+ chapters.append(
+ {"title": real_chapter_title, "content": text.encode("utf-8", "ignore").decode("utf-8")}
+ )
else:
chapters.append(
{
"title": real_chapter_title,
- "content": chapter_text if chapter_text else real_chapter_title,
+ "content": (chapter_text if chapter_text else real_chapter_title)
+ .encode("utf-8", "ignore")
+ .decode("utf-8"),
}
)
# 保存章节内容和章节标题
@@ -334,13 +428,9 @@ def handle_links(doc, pattern_list, with_filter, limit):
next_link = links[num + 1] if num + 1 < len(links) else None
next_link_title = None
if next_link is not None:
- next_link_title = PdfSplitHandle.extract_link_title(
- page, next_link["from"]
- )
+ next_link_title = PdfSplitHandle.extract_link_title(page, next_link["from"])
if not next_link_title:
- next_link_title = PdfSplitHandle.extract_first_line(
- doc.pages[next_link["page"]]
- )
+ next_link_title = PdfSplitHandle.extract_first_line(doc.pages[next_link["page"]])
end_page = next_link["page"]
# 提取章节内容
@@ -383,24 +473,14 @@ def handle_links(doc, pattern_list, with_filter, limit):
else:
pre_toc[-1]["content"] += line
for i in range(len(pre_toc)):
- pre_toc[i]["content"] = re.sub(
- r"(? 0:
split_model = SplitModel(pattern_list, with_filter, limit)
else:
- split_model = SplitModel(
- default_pattern_list, with_filter=with_filter, limit=limit
- )
+ split_model = SplitModel(default_pattern_list, with_filter=with_filter, limit=limit)
# 插入目录前的部分
page_content = re.sub(r"(?= len(doc.pages):
continue
rect = annotation.get("/Rect")
- links.append(
- {"page": dest_page, "from": PdfSplitHandle.normalize_rect(rect)}
- )
+ links.append({"page": dest_page, "from": PdfSplitHandle.normalize_rect(rect)})
return links
@staticmethod
@@ -465,9 +541,7 @@ def get_destination_page_number(doc, destination):
return PdfSplitHandle.get_page_number_by_reference(doc, destination[0])
if hasattr(destination, "get") and destination.get("/D") is not None:
- return PdfSplitHandle.get_destination_page_number(
- doc, destination.get("/D")
- )
+ return PdfSplitHandle.get_destination_page_number(doc, destination.get("/D"))
return None
@@ -511,8 +585,7 @@ def visitor_text(text, cm, tm, font_dict, font_size):
text_top = y + (float(font_size) if font_size else 0)
in_horizontal_range = left - tolerance <= x <= right + tolerance
in_vertical_range = (
- bottom - tolerance <= y <= top + tolerance
- or bottom - tolerance <= text_top <= top + tolerance
+ bottom - tolerance <= y <= top + tolerance or bottom - tolerance <= text_top <= top + tolerance
)
if in_horizontal_range and in_vertical_range:
text_parts.append(text)
@@ -552,7 +625,7 @@ def get_content(self, file, save_image):
try:
with open(temp_file_path, "rb") as pdf_file:
pdf_document = PdfReader(pdf_file)
- return self.handle_pdf_content(file, pdf_document)
+ return self.handle_pdf_content(file, pdf_document, save_image)
except BaseException as e:
traceback.print_exception(e)
return f"{e}"
diff --git a/apps/common/handle/impl/text/xlsx_split_handle.py b/apps/common/handle/impl/text/xlsx_split_handle.py
index 13f9c41d17b..4da581c0e93 100644
--- a/apps/common/handle/impl/text/xlsx_split_handle.py
+++ b/apps/common/handle/impl/text/xlsx_split_handle.py
@@ -1,11 +1,12 @@
# coding=utf-8
"""
- @project: maxkb
- @Author:虎
- @file: xlsx_parse_qa_handle.py
- @date:2024/5/21 14:59
- @desc:
+@project: maxkb
+@Author:虎
+@file: xlsx_parse_qa_handle.py
+@date:2024/5/21 14:59
+@desc:
"""
+
import io
import traceback
from typing import List
@@ -14,39 +15,46 @@
from openpyxl import load_workbook
from common.handle.base_split_handle import BaseSplitHandle
-from common.handle.impl.common_handle import xlsx_embed_cells_images
+from common.handle.impl.common_handle import xlsx_embed_cells_images, validate_xlsx_buffer
+from common.handle.impl.xlsx_utils import iter_sheet_content_rows
from common.utils.logger import maxkb_logger
-splitter = '\n`-----------------------------------`\n'
+splitter = "\n`-----------------------------------`\n"
def post_cell(image_dict, cell_value):
image = image_dict.get(cell_value, None)
if image is not None:
- return f''
- return cell_value.replace('\n', '
').replace('|', '|')
+ return f""
+ return cell_value.replace("\n", "
").replace("|", "|")
def row_to_md(row, image_dict):
- return '| ' + ' | '.join(
- [post_cell(image_dict, str(cell.value if cell.value is not None else '')) if cell is not None else '' for cell
- in row]) + ' |\n'
+ return (
+ "| "
+ + " | ".join(
+ [
+ post_cell(image_dict, str(cell.value if cell.value is not None else "")) if cell is not None else ""
+ for cell in row
+ ]
+ )
+ + " |\n"
+ )
def handle_sheet(file_name, sheet, image_dict, limit: int):
- rows = sheet.rows
+ rows = iter_sheet_content_rows(sheet)
paragraphs = []
- result = {'name': file_name, 'content': paragraphs}
+ result = {"name": file_name, "content": paragraphs}
try:
title_row_list = next(rows)
title_md_content = row_to_md(title_row_list, image_dict)
- title_md_content += '| ' + ' | '.join(
- ['---' if cell is not None else '' for cell in title_row_list]) + ' |\n'
+ title_md_content += "| " + " | ".join(["---" if cell is not None else "" for cell in title_row_list]) + " |\n"
except Exception as e:
return result
if len(title_row_list) == 0:
return result
- result_item_content = ''
+ result_item_content = ""
for row in rows:
next_md_content = row_to_md(row, image_dict)
next_md_content_len = len(next_md_content)
@@ -58,10 +66,10 @@ def handle_sheet(file_name, sheet, image_dict, limit: int):
if result_item_content_len + next_md_content_len < limit:
result_item_content += next_md_content
else:
- paragraphs.append({'content': result_item_content, 'title': ''})
+ paragraphs.append({"content": result_item_content, "title": ""})
result_item_content = title_md_content + next_md_content
if len(result_item_content) > 0:
- paragraphs.append({'content': result_item_content, 'title': ''})
+ paragraphs.append({"content": result_item_content, "title": ""})
return result
@@ -71,14 +79,19 @@ def fill_merged_cells(self, sheet, image_dict):
# 获取第一行作为标题行
headers = []
- for idx, cell in enumerate(sheet[1]):
+ rows = iter_sheet_content_rows(sheet)
+ try:
+ title_row = next(rows)
+ except StopIteration:
+ return data
+ for idx, cell in enumerate(title_row):
if cell.value is None:
- headers.append(' ' * (idx + 1))
+ headers.append(" " * (idx + 1))
else:
headers.append(cell.value)
# 从第二行开始遍历每一行
- for row in sheet.iter_rows(min_row=2, values_only=False):
+ for row in rows:
row_data = {}
for col_idx, cell in enumerate(row):
cell_value = cell.value
@@ -92,7 +105,7 @@ def fill_merged_cells(self, sheet, image_dict):
image = image_dict.get(cell_value, None)
if image is not None:
- cell_value = f''
+ cell_value = f""
# 使用标题作为键,单元格的值作为值存入字典
row_data[headers[col_idx]] = cell_value
@@ -103,6 +116,7 @@ def fill_merged_cells(self, sheet, image_dict):
def handle(self, file, pattern_list: List, with_filter: bool, limit: int, get_buffer, save_image):
buffer = get_buffer(file)
try:
+ validate_xlsx_buffer(io.BytesIO(buffer))
if type(limit) is str:
limit = int(limit)
workbook = openpyxl.load_workbook(io.BytesIO(buffer))
@@ -113,16 +127,19 @@ def handle(self, file, pattern_list: List, with_filter: bool, limit: int, get_bu
image_dict = {}
worksheets = workbook.worksheets
worksheets_size = len(worksheets)
- return [row for row in
- [handle_sheet(file.name,
- sheet,
- image_dict,
- limit) if worksheets_size == 1 and sheet.title == 'Sheet1' else handle_sheet(
- sheet.title, sheet, image_dict, limit) for sheet
- in worksheets] if row is not None]
+ return [
+ row
+ for row in [
+ handle_sheet(file.name, sheet, image_dict, limit)
+ if worksheets_size == 1 and sheet.title == "Sheet1"
+ else handle_sheet(sheet.title, sheet, image_dict, limit)
+ for sheet in worksheets
+ ]
+ if row is not None
+ ]
except Exception as e:
maxkb_logger.error(f"Error processing XLSX file {file.name}: {e}, {traceback.format_exc()}")
- return [{'name': file.name, 'content': []}]
+ return [{"name": file.name, "content": []}]
def get_content(self, file, save_image):
try:
@@ -133,9 +150,9 @@ def get_content(self, file, save_image):
if len(image_dict) > 0:
save_image(image_dict.values())
except Exception as e:
- maxkb_logger.error(f'Exception: {e}')
+ maxkb_logger.error(f"Exception: {e}")
image_dict = {}
- md_tables = ''
+ md_tables = ""
# 遍历所有工作表
for sheetname in workbook.sheetnames:
sheet = workbook[sheetname]
@@ -144,41 +161,41 @@ def get_content(self, file, save_image):
continue
# 添加 sheet 名称作为标题
- md_tables += f'## {sheetname}\n\n'
+ md_tables += f"## {sheetname}\n\n"
# 提取表头和内容
headers = [f"{key}" for key, value in rows[0].items()]
# 构建 Markdown 表格
- md_table = '| ' + ' | '.join(headers) + ' |\n'
- md_table += '| ' + ' | '.join(['---'] * len(headers)) + ' |\n'
+ md_table = "| " + " | ".join(headers) + " |\n"
+ md_table += "| " + " | ".join(["---"] * len(headers)) + " |\n"
for row in rows:
r = [self._escape_cell_content(value) for key, value in row.items()]
- md_table += '| ' + ' | '.join(r) + ' |\n'
+ md_table += "| " + " | ".join(r) + " |\n"
- md_tables += md_table + '\n\n'
+ md_tables += md_table + "\n\n"
return md_tables
except Exception as e:
- maxkb_logger.error(f'excel split handle error: {e}')
- return f'error: {e}'
+ maxkb_logger.error(f"excel split handle error: {e}")
+ return f"error: {e}"
def _escape_cell_content(self, cell_value):
"""转义单元格内容,避免破坏 Markdown 表格结构"""
if cell_value is None:
- return ''
+ return ""
cell_str = str(cell_value)
# 替换换行符为
- cell_str = cell_str.replace('\n', '
')
+ cell_str = cell_str.replace("\n", "
")
# 转义管道符 | 为 HTML 实体
- cell_str = cell_str.replace('|', '|')
+ cell_str = cell_str.replace("|", "|")
# 如果内容包含反引号,需要转义
- if '`' in cell_str:
- cell_str = cell_str.replace('`', '`')
+ if "`" in cell_str:
+ cell_str = cell_str.replace("`", "`")
return cell_str
diff --git a/apps/common/handle/impl/text/zip_split_handle.py b/apps/common/handle/impl/text/zip_split_handle.py
index 75e418665d9..14f1b7c310a 100644
--- a/apps/common/handle/impl/text/zip_split_handle.py
+++ b/apps/common/handle/impl/text/zip_split_handle.py
@@ -91,7 +91,8 @@ def _collect_file_refs(tokens: list, base_name: str, zip_files: List[str], conte
if file_path.startswith("oss/file/") or file_path.startswith("oss/image/"):
file_id = file_path.replace("oss/file/", "").replace("oss/image/", "")
if is_valid_uuid(file_id):
- file_list.append({"source_file": file_path, "image_id": file_id})
+ file_list.append({"source_file": file_path, "image_id": new_id})
+ content = update_content(content, source_path, f"./oss/file/{new_id}")
else:
file_list.append({"source_file": file_path, "image_id": new_id})
content = update_content(content, source_path, f"./oss/file/{new_id}")
diff --git a/apps/common/handle/impl/xlsx_utils.py b/apps/common/handle/impl/xlsx_utils.py
new file mode 100644
index 00000000000..3cd99defe96
--- /dev/null
+++ b/apps/common/handle/impl/xlsx_utils.py
@@ -0,0 +1,15 @@
+# coding=utf-8
+
+
+def get_sheet_content_max_column(sheet):
+ return max(
+ (cell.column for cell in sheet._cells.values() if cell.value is not None),
+ default=0,
+ )
+
+
+def iter_sheet_content_rows(sheet, min_row=1):
+ max_column = get_sheet_content_max_column(sheet)
+ if max_column == 0:
+ return iter(())
+ return sheet.iter_rows(min_row=min_row, max_col=max_column)
diff --git a/apps/common/init/init_doc.py b/apps/common/init/init_doc.py
index 156275e4ae6..09154e4fed1 100644
--- a/apps/common/init/init_doc.py
+++ b/apps/common/init/init_doc.py
@@ -1,51 +1,91 @@
# coding=utf-8
"""
- @project: maxkb
- @Author:虎
- @file: init_doc.py
- @date:2024/5/24 14:11
- @desc:
+@project: maxkb
+@Author:虎
+@file: init_doc.py
+@date:2024/5/24 14:11
+@desc:
"""
+
import hashlib
-from django.urls import path, URLPattern
+from django.urls import path, URLPattern, URLResolver
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
from maxkb.const import CONFIG
-chat_api_prefix = CONFIG.get_chat_path()[1:] + '/api/'
+chat_api_prefix = CONFIG.get_chat_path()[1:] + "/api/"
+
+
+def flatten_url_patterns(patterns, prefix=""):
+ """
+ 递归展开 urlpatterns,遇到 include() 产生的 URLResolver 时向下钻取,
+ 累加各层路由前缀,最终产出 (完整路由字符串, URLPattern) 元组。
+ """
+ for entry in patterns:
+ if isinstance(entry, URLResolver):
+ yield from flatten_url_patterns(entry.url_patterns, prefix + str(entry.pattern))
+ elif isinstance(entry, URLPattern):
+ yield prefix + str(entry.pattern), entry
def init_app_doc(system_urlpatterns):
system_urlpatterns += [
- path(f'{CONFIG.get_admin_path()[1:]}/api-doc/schema/', SpectacularAPIView.as_view(), name='schema'),
+ path(f"{CONFIG.get_admin_path()[1:]}/api-doc/schema/", SpectacularAPIView.as_view(), name="schema"),
# schema的配置文件的路由,下面两个ui也是根据这个配置文件来生成的
- path(f'{CONFIG.get_admin_path()[1:]}/api-doc/', SpectacularSwaggerView.as_view(url_name='schema'),
- name='swagger-ui'), # swagger-ui的路由
+ path(
+ f"{CONFIG.get_admin_path()[1:]}/api-doc/",
+ SpectacularSwaggerView.as_view(url_name="schema"),
+ name="swagger-ui",
+ ), # swagger-ui的路由
]
class ChatSpectacularSwaggerView(SpectacularSwaggerView):
@staticmethod
def _swagger_ui_resource(filename):
- return f'{CONFIG.get_chat_path()}/api-doc/swagger-ui-dist/{filename}'
+ return f"{CONFIG.get_chat_path()}/api-doc/swagger-ui-dist/{filename}"
@staticmethod
def _swagger_ui_favicon():
- return f'{CONFIG.get_chat_path()}/api-doc/swagger-ui-dist/favicon-32x32.png'
+ return f"{CONFIG.get_chat_path()}/api-doc/swagger-ui-dist/favicon-32x32.png"
+
+
+def build_curated_patterns(chat_urlpatterns, doc_names):
+ """按 name 集合从(递归展开后的)chat 路由里挑出 curated 端点,重建为带完整 path 的 URLPattern。"""
+ return [
+ URLPattern(
+ pattern=f"{chat_api_prefix}{full_path}", callback=url.callback, default_args=url.default_args, name=url.name
+ )
+ for full_path, url in flatten_url_patterns(chat_urlpatterns)
+ if doc_names.__contains__(getattr(url, "name", None))
+ ]
def init_chat_doc(system_urlpatterns, chat_urlpatterns):
+ chat_path = CONFIG.get_chat_path()[1:]
+ v3_patterns = build_curated_patterns(
+ chat_urlpatterns,
+ ["v3_chat", "v3_open", "v3_profile", "v3_portal_application", "v3_portal_historical_conversation"],
+ )
+ v2_patterns = build_curated_patterns(chat_urlpatterns, ["chat", "open", "profile", "anonymous"])
system_urlpatterns += [
- path(f'{CONFIG.get_chat_path()[1:]}/api-doc/schema/',
- SpectacularAPIView.as_view(patterns=[
- URLPattern(pattern=f'{chat_api_prefix}{str(url.pattern)}', callback=url.callback,
- default_args=url.default_args,
- name=url.name) for url in chat_urlpatterns if
- ['chat', 'open', 'profile'].__contains__(url.name)]),
- name='chat_schema'), # schema的配置文件的路由,下面两个ui也是根据这个配置文件来生成的
- path(f'{CONFIG.get_chat_path()[1:]}/api-doc/', ChatSpectacularSwaggerView.as_view(url_name='chat_schema'),
- name='swagger-ui'), # swagger-ui的路由
+ # v3 curated 文档(主路径)
+ path(
+ f"{chat_path}/api-doc/schema/", SpectacularAPIView.as_view(patterns=v3_patterns), name="chat_schema"
+ ), # schema的配置文件的路由,下面ui根据它生成
+ path(
+ f"{chat_path}/api-doc/", ChatSpectacularSwaggerView.as_view(url_name="chat_schema"), name="swagger-ui"
+ ), # swagger-ui的路由
+ # v2 curated 文档(保留)
+ path(
+ f"{chat_path}/api-doc/v2/schema/", SpectacularAPIView.as_view(patterns=v2_patterns), name="chat_schema_v2"
+ ),
+ path(
+ f"{chat_path}/api-doc/v2/",
+ ChatSpectacularSwaggerView.as_view(url_name="chat_schema_v2"),
+ name="swagger-ui-v2",
+ ),
]
@@ -58,23 +98,40 @@ def encrypt(text):
def get_call(application_urlpatterns, patterns, params, func):
def run():
- if params['valid']():
- func(*params['get_params'](application_urlpatterns, patterns))
+ if params["valid"]():
+ func(*params["get_params"](application_urlpatterns, patterns))
return run
-init_list = [(init_app_doc, {'valid': lambda: CONFIG.get('DOC_PASSWORD') is not None and encrypt(
- CONFIG.get('DOC_PASSWORD')) == 'd4fc097197b4b90a122b92cbd5bbe867',
- 'get_call': get_call,
- 'get_params': lambda application_urlpatterns, patterns: (application_urlpatterns,)}),
- (init_chat_doc, {'valid': lambda: CONFIG.get('DOC_PASSWORD') is not None and encrypt(
- CONFIG.get('DOC_PASSWORD')) == 'd4fc097197b4b90a122b92cbd5bbe867' or True, 'get_call': get_call,
- 'get_params': lambda application_urlpatterns, patterns: (
- application_urlpatterns, patterns)})]
+init_list = [
+ (
+ init_app_doc,
+ {
+ "valid": lambda: (
+ CONFIG.get("DOC_PASSWORD") is not None
+ and encrypt(CONFIG.get("DOC_PASSWORD")) == "d4fc097197b4b90a122b92cbd5bbe867"
+ ),
+ "get_call": get_call,
+ "get_params": lambda application_urlpatterns, patterns: (application_urlpatterns,),
+ },
+ ),
+ (
+ init_chat_doc,
+ {
+ "valid": lambda: (
+ CONFIG.get("DOC_PASSWORD") is not None
+ and encrypt(CONFIG.get("DOC_PASSWORD")) == "d4fc097197b4b90a122b92cbd5bbe867"
+ or True
+ ),
+ "get_call": get_call,
+ "get_params": lambda application_urlpatterns, patterns: (application_urlpatterns, patterns),
+ },
+ ),
+]
def init_doc(system_urlpatterns, chat_patterns):
for init, params in init_list:
- if params['valid']():
+ if params["valid"]():
get_call(system_urlpatterns, chat_patterns, params, init)()
diff --git a/apps/common/job/__init__.py b/apps/common/job/__init__.py
index 4984886b423..8b4aa3542c1 100644
--- a/apps/common/job/__init__.py
+++ b/apps/common/job/__init__.py
@@ -1,14 +1,16 @@
# coding=utf-8
"""
- @project: maxkb
- @Author:虎
- @file: __init__.py
- @date:2024/3/14 11:54
- @desc:
+@project: maxkb
+@Author:虎
+@file: __init__.py
+@date:2024/3/14 11:54
+@desc:
"""
+
from .clean_chat_job import *
from .clean_debug_file_job import *
from .client_access_num_job import *
+from knowledge.services.knowledge_sync_schedule import restore_knowledge_sync_jobs
def run():
@@ -16,3 +18,4 @@ def run():
clean_chat_job.run()
clean_debug_file_job.run()
client_access_num_job.run()
+ restore_knowledge_sync_jobs()
diff --git a/apps/common/job/clean_chat_job.py b/apps/common/job/clean_chat_job.py
index eab82630dac..e90d5e58c7f 100644
--- a/apps/common/job/clean_chat_job.py
+++ b/apps/common/job/clean_chat_job.py
@@ -3,10 +3,11 @@
import datetime
from django.db import transaction
-from django.db.models import Q, Max
+from django.db.models import CharField, Q, Max
+from django.db.models.functions import Cast
from django.utils import timezone
-from application.models import Application, Chat, ChatRecord
+from application.models import Application, Chat, ChatRecord, ApplicationChatUserStats
from common.job.scheduler import scheduler
from common.utils.lock import lock, RedisLock
from common.utils.logger import maxkb_logger
@@ -17,19 +18,17 @@ def clean_chat_log_job():
clean_chat_log_job_lock()
-@lock(lock_key='clean_chat_log_job_execute', timeout=30)
+@lock(lock_key="clean_chat_log_job_execute", timeout=30)
def clean_chat_log_job_lock():
from django.utils.translation import gettext_lazy as _
- maxkb_logger.info(_('start clean chat log'))
+
+ maxkb_logger.info(_("start clean chat log"))
now = timezone.now()
- applications = Application.objects.all().values('id', 'clean_time', 'file_clean_time')
- cutoff_dates = {
- app['id']: now - datetime.timedelta(days=app['clean_time'] or 180)
- for app in applications
- }
+ applications = Application.objects.all().values("id", "clean_time", "file_clean_time")
+ cutoff_dates = {app["id"]: now - datetime.timedelta(days=app["clean_time"] or 180) for app in applications}
file_cutoff_dates = {
- app['id']: now - datetime.timedelta(days=app['file_clean_time'] or app['clean_time'] or 180)
+ app["id"]: now - datetime.timedelta(days=app["file_clean_time"] or app["clean_time"] or 180)
for app in applications
}
file_conditions = Q()
@@ -42,69 +41,132 @@ def clean_chat_log_job_lock():
query_conditions |= Q(chat__application_id=app_id, create_time__lt=cutoff_date)
clean_method(query_conditions)
- maxkb_logger.info(_('end clean chat log'))
+ maxkb_logger.info(_("end clean chat log"))
+
+
+def delete_orphan_chats(orphan_chat_ids):
+ if not orphan_chat_ids:
+ return
+
+ orphan_chats = list(Chat.objects.filter(id__in=orphan_chat_ids))
+
+ # 按 (application_id, chat_user_id) 收集孤儿会话的用户,
+ # 仅当该用户在该应用下不再有其它会话时才删除其访问统计,避免误删其他应用或仍活跃用户的统计
+ app_user_ids = {}
+ for chat in orphan_chats:
+ if chat.chat_user_id:
+ chat_user_id = str(chat.chat_user_id)
+ app_user_ids.setdefault(chat.application_id, set()).add(chat_user_id)
+
+ if app_user_ids:
+ all_user_ids = set()
+ for user_ids in app_user_ids.values():
+ all_user_ids.update(user_ids)
+
+ remaining_keys = (
+ Chat.objects.filter(
+ application_id__in=app_user_ids.keys(),
+ chat_user_id__in=all_user_ids,
+ )
+ .exclude(id__in=orphan_chat_ids)
+ .values_list("application_id", "chat_user_id")
+ .distinct()
+ )
+
+ remaining_app_user_ids = {}
+ for app_id, user_id in remaining_keys:
+ remaining_app_user_ids.setdefault(app_id, set()).add(user_id)
+
+ for app_id, user_ids in app_user_ids.items():
+ user_ids_to_delete = user_ids - remaining_app_user_ids.get(app_id, set())
+ if user_ids_to_delete:
+ ApplicationChatUserStats.objects.annotate(
+ chat_user_id_str=Cast("chat_user_id", output_field=CharField(max_length=128))
+ ).filter(
+ application_id=app_id,
+ chat_user_id_str__in=user_ids_to_delete,
+ ).delete()
+
+ deleted_chat_count, _ = Chat.objects.filter(id__in=orphan_chat_ids).delete()
+ maxkb_logger.info(f"[clean_chat_log] delete orphan chats, count={deleted_chat_count}")
def clean_method(query_conditions, clean_log=True):
batch_size = 500
+ last_record_id = None
while True:
with transaction.atomic():
- chat_records = ChatRecord.objects.filter(query_conditions).select_related('chat').only('id', 'chat_id',
- 'create_time')[
- :batch_size]
+ records = ChatRecord.objects.filter(query_conditions)
+ if last_record_id is not None:
+ records = records.filter(id__gt=last_record_id)
+ chat_records = list(records.order_by("id").only("id", "chat_id", "create_time")[:batch_size])
if not chat_records:
break
+ last_record_id = chat_records[-1].id
chat_record_ids = [record.id for record in chat_records]
chat_ids = {record.chat_id for record in chat_records}
# 计算每个 chat_id 的最大 create_time
- max_create_times = ChatRecord.objects.filter(id__in=chat_record_ids).values('chat_id').annotate(
- max_create_time=Max('create_time'))
+ max_create_times = (
+ ChatRecord.objects.filter(id__in=chat_record_ids)
+ .values("chat_id")
+ .annotate(max_create_time=Max("create_time"))
+ )
# 收集需要删除的文件
files_to_delete = []
- for record in chat_records:
- max_create_time = next(
- (item['max_create_time'] for item in max_create_times if
- str(item['chat_id']) == str(record.chat_id)), None)
- if max_create_time:
- files_to_delete.extend(
- File.objects.filter(source_id=str(record.chat_id), create_time__lt=max_create_time)
- )
+ for item in max_create_times:
+ files_to_delete.extend(
+ File.objects.filter(source_id=str(item["chat_id"]), create_time__lt=item["max_create_time"])
+ )
# 删除 ChatRecord
- deleted_count = 0
if clean_log:
deleted_count = ChatRecord.objects.filter(id__in=chat_record_ids).delete()[0]
+ maxkb_logger.info(f"[clean_chat_log] delete chat_records, count={deleted_count}")
from django.db.models import Count
- updated_counts = ChatRecord.objects.filter(chat_id__in=chat_ids) \
- .values('chat_id') \
- .annotate(count=Count('id'))
- count_map = {item['chat_id']: item['count'] for item in updated_counts}
+ updated_counts = (
+ ChatRecord.objects.filter(chat_id__in=chat_ids).values("chat_id").annotate(count=Count("id"))
+ )
+
+ count_map = {item["chat_id"]: item["count"] for item in updated_counts}
for chat_id in chat_ids:
count = count_map.get(chat_id, 0) # 如果没有记录则为0
Chat.objects.filter(id=chat_id).update(chat_record_count=count)
- # 删除没有关联 ChatRecord 的 Chat
- Chat.objects.filter(chatrecord__isnull=True, id__in=chat_ids).delete()
- File.objects.filter(loid__in=[file.loid for file in files_to_delete]).delete()
+ # 删除已经没有关联 ChatRecord 的 Chat
+ orphan_chat_ids = [chat_id for chat_id in chat_ids if count_map.get(chat_id, 0) == 0]
+ delete_orphan_chats(orphan_chat_ids)
+ File.objects.filter(id__in=[file.id for file in files_to_delete]).delete()
- if deleted_count < batch_size:
+ if len(chat_records) < batch_size:
break
+ if clean_log:
+ orphan_chat_ids = list(Chat.objects.filter(chatrecord__isnull=True).values_list("id", flat=True))
+ maxkb_logger.info(f"[clean_chat_log] final orphan_chat_count={len(orphan_chat_ids)}")
+ delete_orphan_chats(orphan_chat_ids)
+
def run():
rlock = RedisLock()
- if rlock.try_lock('clean_chat_log_job', 30 * 30):
+ if rlock.try_lock("clean_chat_log_job", 30 * 30):
try:
- maxkb_logger.debug('get lock clean_chat_log_job')
+ maxkb_logger.debug("get lock clean_chat_log_job")
- existing_job = scheduler.get_job(job_id='clean_chat_log')
+ existing_job = scheduler.get_job(job_id="clean_chat_log")
if existing_job is not None:
existing_job.remove()
- scheduler.add_job(clean_chat_log_job, 'cron', hour='0', minute='5', id='clean_chat_log',
- misfire_grace_time=300, max_instances=1)
+ scheduler.add_job(
+ clean_chat_log_job,
+ "cron",
+ hour="0",
+ minute="5",
+ id="clean_chat_log",
+ misfire_grace_time=300,
+ max_instances=1,
+ )
finally:
- rlock.un_lock('clean_chat_log_job')
+ rlock.un_lock("clean_chat_log_job")
diff --git a/apps/common/log/log.py b/apps/common/log/log.py
index faca1cdf881..272acef689d 100644
--- a/apps/common/log/log.py
+++ b/apps/common/log/log.py
@@ -1,12 +1,11 @@
# coding=utf-8
"""
- @project: MaxKB
- @Author:虎虎
- @file: log.py
- @date:2025/6/4 14:13
- @desc:
+@project: MaxKB
+@Author:虎虎
+@file: log.py
+@date:2025/6/4 14:13
+@desc:
"""
-from qianfan.utils.utils import get_ip_address
from system_manage.models.log_management import Log
@@ -17,11 +16,11 @@ def _get_ip_address(request):
@param request:
@return:
"""
- x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
- ip = x_forwarded_for.split(',')[0]
+ ip = x_forwarded_for.split(",")[0]
else:
- ip = request.META.get('REMOTE_ADDR')
+ ip = request.META.get("REMOTE_ADDR")
return ip
@@ -33,9 +32,9 @@ def _get_user(request):
"""
user = request.user
if user is None:
- return {
-
- }
+ return {}
+ if hasattr(user, 'profile') and user.profile is not None:
+ user = user.profile
user_info = {
"id": str(user.id),
"email": user.email,
@@ -43,9 +42,8 @@ def _get_user(request):
"nick_name": user.nick_name,
"username": user.username,
}
- # 如果是 User 模型且有 role 属性
- if hasattr(user, 'role'):
- user_info['role'] = user.role
+ if hasattr(user, "role"):
+ user_info["role"] = user.role
return user_info
@@ -53,28 +51,31 @@ def _get_details(request):
path = request.path
body = request.data
- sensitive_fields = {'password', 're_password'}
+ sensitive_fields = {"password", "re_password"}
- body_copy = dict(body) if hasattr(body, 'items') else body
+ body_copy = dict(body) if hasattr(body, "items") else body
if isinstance(body_copy, dict):
for field in sensitive_fields:
body_copy.pop(field, None)
query = request.query_params
- return {
- 'path': path,
- 'body': body_copy,
- 'query': query
- }
+ return {"path": path, "body": body_copy, "query": query}
def _get_workspace_id(request, kwargs):
- return kwargs.get('workspace_id', 'None')
-
-
-def log(menu: str, operate, get_user=_get_user, get_ip_address=_get_ip_address, get_details=_get_details,
- get_operation_object=None, get_workspace_id=_get_workspace_id):
+ return kwargs.get("workspace_id", "None")
+
+
+def log(
+ menu: str,
+ operate,
+ get_user=_get_user,
+ get_ip_address=_get_ip_address,
+ get_details=_get_details,
+ get_operation_object=None,
+ get_workspace_id=_get_workspace_id,
+):
"""
记录审计日志
@param menu: 操作菜单 str
@@ -110,17 +111,33 @@ def run(view, request, **kwargs):
if callable(operate):
_operate = operate(request)
# 插入审计日志
- Log(menu=menu, operate=_operate, user=user, status=status, ip_address=ip, details=details,
- operation_object=operation_object, workspace_id=workspace_id).save()
+ Log(
+ menu=menu,
+ operate=_operate,
+ user=user,
+ status=status,
+ ip_address=ip,
+ details=details,
+ operation_object=operation_object,
+ workspace_id=workspace_id,
+ ).save()
return run
return inner
-def record_log(menu: str, operate: str, request, user: dict = None, status: int = 200,
- get_details=_get_details, get_operation_object=None, workspace_id: str = 'default',
- operation_object: dict = None):
+def record_log(
+ menu: str,
+ operate: str,
+ request,
+ user: dict = None,
+ status: int = 200,
+ get_details=_get_details,
+ get_operation_object=None,
+ workspace_id: str = "default",
+ operation_object: dict = None,
+):
"""
手动记录审计日志(适用于无法使用装饰器的场景,如第三方登录回调)
@@ -154,7 +171,7 @@ def record_log(menu: str, operate: str, request, user: dict = None, status: int
ip_address=ip,
details=details,
operation_object=operation_object or {},
- workspace_id=workspace_id
+ workspace_id=workspace_id,
).save()
except Exception as e:
# 日志记录失败不应影响主业务流程
diff --git a/apps/common/mcp/__init__.py b/apps/common/mcp/__init__.py
new file mode 100644
index 00000000000..7e622b56dc7
--- /dev/null
+++ b/apps/common/mcp/__init__.py
@@ -0,0 +1 @@
+"""Shared MCP configuration and sandbox workers."""
diff --git a/apps/common/mcp/client.py b/apps/common/mcp/client.py
new file mode 100644
index 00000000000..767af83f15b
--- /dev/null
+++ b/apps/common/mcp/client.py
@@ -0,0 +1,12 @@
+"""Compatibility exports and client factory for the dedicated MCP backend."""
+
+from application.workflow.backend.sandbox_mcp import SandboxMCPBackend
+from common.mcp.config import InternalMCPConfig, validate_mcp_servers
+
+
+__all__ = ["InternalMCPConfig", "validate_mcp_servers", "create_mcp_client"]
+
+
+def create_mcp_client(servers):
+ """Keep existing callers compatible with the dedicated MCP backend."""
+ return SandboxMCPBackend(servers)
diff --git a/apps/common/mcp/config.py b/apps/common/mcp/config.py
new file mode 100644
index 00000000000..868f4947496
--- /dev/null
+++ b/apps/common/mcp/config.py
@@ -0,0 +1,32 @@
+"""Shared MCP configuration types and validation."""
+
+import json
+
+
+REMOTE_FIELDS = {"transport", "url", "headers", "timeout", "sse_read_timeout", "terminate_on_close"}
+
+
+class InternalMCPConfig(dict):
+ """In-memory provenance for configurations generated by ToolExecutor.
+
+ Never deserialize user input into this type. JSON round trips deliberately
+ lose this privilege; keep runtime configurations in memory instead.
+ """
+
+
+def validate_mcp_servers(servers):
+ if not isinstance(servers, dict):
+ raise ValueError("MCP servers must be an object")
+ for config in servers.values():
+ if not isinstance(config, dict) or config.get("transport") not in ("sse", "streamable_http"):
+ raise ValueError("Only support transport=sse or transport=streamable_http")
+ if not isinstance(config.get("url"), str) or not config["url"].strip():
+ raise ValueError("MCP server URL must be a non-empty string")
+
+
+def remote_connection(config):
+ """Copy serializable transport data, excluding commands and SDK callbacks."""
+ remote = {key: value for key, value in config.items() if key in REMOTE_FIELDS}
+ if remote.get("transport") == "sse":
+ remote.pop("terminate_on_close", None)
+ return json.loads(json.dumps(remote, allow_nan=False))
diff --git a/apps/common/mcp/sandbox.py b/apps/common/mcp/sandbox.py
new file mode 100644
index 00000000000..65c936f1929
--- /dev/null
+++ b/apps/common/mcp/sandbox.py
@@ -0,0 +1,66 @@
+"""Build fixed stdio worker connections; user configuration never selects code."""
+
+import json
+import pwd
+import sys
+from datetime import timedelta
+from importlib.machinery import PathFinder
+from pathlib import Path
+
+from mcp.types import Implementation
+
+from common.mcp.config import remote_connection
+from maxkb.const import CONFIG
+
+
+BOOTSTRAP_KEY = "maxkbSandbox"
+
+
+def sandbox_settings():
+ if not bool(int(CONFIG.get("SANDBOX", 1))):
+ raise ValueError("MCP sandbox is disabled")
+ if not sys.platform.startswith("linux"):
+ raise ValueError("MCP sandbox requires Linux; set SANDBOX=0 for local development")
+ account = pwd.getpwnam("sandbox")
+ sandbox_home = Path(CONFIG.get("SANDBOX_HOME", "/opt/maxkb-app/sandbox"))
+ library = sandbox_home / "lib/sandbox.so"
+ if not library.is_file() or not library.with_name(".sandbox.conf").is_file():
+ raise ValueError("MCP sandbox library or configuration is missing")
+ return {
+ "uid": account.pw_uid,
+ "gid": account.pw_gid,
+ "library": str(library),
+ "cwd": str(sandbox_home),
+ "python_paths": CONFIG.get_sandbox_python_package_paths().split(","),
+ "memory_mb": int(CONFIG.get("SANDBOX_PYTHON_PROCESS_LIMIT_MEM_MB", "256")),
+ "cpu_cores": int(CONFIG.get("SANDBOX_PYTHON_PROCESS_LIMIT_CPU_CORES", "1")),
+ "timeout": int(CONFIG.get("SANDBOX_PYTHON_PROCESS_LIMIT_TIMEOUT_SECONDS", "3600")),
+ }
+
+
+def sandbox_connection(config):
+ settings = sandbox_settings()
+ # Release builds replace source files with adjacent, sourceless .pyc files.
+ # Search only our installed directory, never a user-controlled module path.
+ worker = PathFinder.find_spec("sandbox_worker", [str(Path(__file__).parent)])
+ if worker is None or worker.origin is None or Path(worker.origin).suffix not in (".py", ".pyc"):
+ raise RuntimeError("MCP sandbox worker is missing or has an unsupported format")
+ # Only transport data goes to the remote client. In particular, ignore user
+ # command/env/factory/session_kwargs fields and never deserialize Python code.
+ bootstrap = {"connection": remote_connection(config)}
+ return {
+ "transport": "stdio",
+ "command": sys.executable,
+ "args": ["-I", worker.origin],
+ "cwd": settings["cwd"],
+ "env": {
+ "LD_PRELOAD": settings["library"],
+ "MAXKB_MCP_WORKER_SETTINGS": json.dumps(settings),
+ },
+ "session_kwargs": {
+ "read_timeout_seconds": timedelta(seconds=settings["timeout"]),
+ # This field travels only over the child's stdio pipe. The worker
+ # removes it before forwarding initialize to the remote server.
+ "client_info": Implementation(name="maxkb-sandbox", version="1", **{BOOTSTRAP_KEY: bootstrap}),
+ },
+ }
diff --git a/apps/common/mcp/sandbox_proxy.py b/apps/common/mcp/sandbox_proxy.py
new file mode 100644
index 00000000000..7b4e7928bff
--- /dev/null
+++ b/apps/common/mcp/sandbox_proxy.py
@@ -0,0 +1,162 @@
+"""Forward MCP messages without converting tools, results or notifications."""
+
+from contextlib import asynccontextmanager
+import logging
+import os
+import socket
+import ssl
+import sys
+
+import anyio
+import httpx
+from mcp.client.sse import sse_client
+from mcp.client.streamable_http import streamable_http_client
+from mcp.server.stdio import stdio_server
+from mcp.shared._httpx_utils import create_mcp_http_client
+from mcp.types import JSONRPCRequest
+
+
+def sandbox_failure_message(error):
+ # SDK exception groups and chained HTTP errors can embed credentials. Only
+ # report numeric HTTP statuses or fixed descriptions, never exception text.
+ errors, pending, seen = [], [error], set()
+ while pending:
+ current = pending.pop()
+ if id(current) in seen:
+ continue
+ seen.add(id(current))
+ errors.append(current)
+ if isinstance(current, BaseExceptionGroup):
+ pending.extend(current.exceptions)
+ if current.__cause__ is not None:
+ pending.append(current.__cause__)
+ for current in errors:
+ if isinstance(current, httpx.HTTPStatusError):
+ return f"MCP endpoint returned HTTP {current.response.status_code}; check endpoint and credentials"
+ for exception_type, message in (
+ (ssl.SSLCertVerificationError, "MCP TLS certificate verification failed"),
+ (socket.gaierror, "MCP hostname resolution failed; check container DNS"),
+ (PermissionError, "MCP access denied; check sandbox file and network policy"),
+ ((httpx.TimeoutException, TimeoutError), "MCP connection timed out"),
+ (httpx.TooManyRedirects, "MCP endpoint returned too many redirects"),
+ (httpx.ConnectError, "MCP connection failed; check container connectivity and sandbox network policy"),
+ ):
+ if any(isinstance(current, exception_type) for current in errors):
+ return message
+ return "MCP session failed; check endpoint, sandbox setup and network policy"
+
+
+class PipeInput:
+ """Cancellable pipe reads; a blocked readline thread would delay shutdown."""
+
+ def __init__(self):
+ self.fd = sys.stdin.fileno()
+ os.set_blocking(self.fd, False)
+ self.buffer = b""
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ while b"\n" not in self.buffer:
+ await anyio.wait_readable(self.fd)
+ try:
+ chunk = os.read(self.fd, 65536)
+ except BlockingIOError:
+ continue
+ if not chunk:
+ raise StopAsyncIteration
+ self.buffer += chunk
+ if len(self.buffer) > 32 * 1024 * 1024:
+ raise ValueError("MCP message exceeds sandbox limit")
+ line, self.buffer = self.buffer.split(b"\n", 1)
+ return line.decode("utf-8")
+
+
+class PipeOutput:
+ def __init__(self):
+ self.fd = sys.stdout.fileno()
+ os.set_blocking(self.fd, False)
+
+ async def write(self, value):
+ remaining = value.encode("utf-8")
+ while remaining:
+ await anyio.wait_writable(self.fd)
+ try:
+ written = os.write(self.fd, remaining)
+ except BlockingIOError:
+ continue
+ remaining = remaining[written:]
+
+ async def flush(self):
+ pass
+
+
+def extract_bootstrap(message):
+ request = message.message.root
+ if not isinstance(request, JSONRPCRequest) or request.method != "initialize":
+ raise ValueError("MCP sandbox requires initialize first")
+ params = dict(request.params or {})
+ client_info = dict(params.get("clientInfo") or {})
+ bootstrap = client_info.pop("maxkbSandbox", None)
+ if not isinstance(bootstrap, dict):
+ raise ValueError("Missing MCP sandbox bootstrap")
+ params["clientInfo"] = client_info
+ request.params = params
+ return bootstrap
+
+
+@asynccontextmanager
+async def remote_transport(bootstrap):
+ config = bootstrap["connection"]
+ if config.get("transport") not in ("sse", "streamable_http"):
+ raise ValueError("Unsupported external MCP transport")
+ timeout = config.get("timeout", 5 if config["transport"] == "sse" else 30)
+ read_timeout = config.get("sse_read_timeout", 300)
+ if config["transport"] == "sse":
+ async with sse_client(
+ config["url"],
+ headers=config.get("headers"),
+ timeout=timeout,
+ sse_read_timeout=read_timeout,
+ ) as streams:
+ yield streams
+ else:
+ async with create_mcp_http_client(
+ headers=config.get("headers"),
+ timeout=httpx.Timeout(timeout, read=read_timeout),
+ ) as client:
+ async with streamable_http_client(
+ config["url"],
+ http_client=client,
+ terminate_on_close=config.get("terminate_on_close", True),
+ ) as (read, write, _):
+ yield read, write
+
+
+async def forward(source, destination, cancel_scope):
+ try:
+ async for message in source:
+ if isinstance(message, Exception):
+ raise message
+ await destination.send(message)
+ finally:
+ cancel_scope.cancel()
+
+
+async def proxy():
+ async with stdio_server(stdin=PipeInput(), stdout=PipeOutput()) as (local_read, local_write):
+ with anyio.fail_after(30):
+ first = await local_read.receive()
+ bootstrap = extract_bootstrap(first)
+ async with remote_transport(bootstrap) as (remote_read, remote_write):
+ async with anyio.create_task_group() as tasks:
+ tasks.start_soon(forward, remote_read, local_write, tasks.cancel_scope)
+ await remote_write.send(first)
+ tasks.start_soon(forward, local_read, remote_write, tasks.cancel_scope)
+
+
+def run():
+ # Remote SDK exceptions may contain authorization headers or URL parameters.
+ logging.disable(logging.CRITICAL)
+ anyio.run(proxy)
diff --git a/apps/common/mcp/sandbox_worker.py b/apps/common/mcp/sandbox_worker.py
new file mode 100644
index 00000000000..9c069a8850a
--- /dev/null
+++ b/apps/common/mcp/sandbox_worker.py
@@ -0,0 +1,201 @@
+"""Fixed Linux entry point for the stdio-to-HTTP MCP sandbox proxy."""
+
+import ctypes
+from contextlib import contextmanager
+import errno
+import importlib.machinery
+import importlib.util
+import ipaddress
+import json
+import os
+from pathlib import Path
+import pwd
+import resource
+import signal
+import socket
+import struct
+import sys
+
+
+class MCPWorkerFailure(Exception):
+ """A failure whose message was sanitized by the protocol proxy."""
+
+
+class DlInfo(ctypes.Structure):
+ _fields_ = [
+ ("filename", ctypes.c_char_p),
+ ("base", ctypes.c_void_p),
+ ("symbol", ctypes.c_char_p),
+ ("address", ctypes.c_void_p),
+ ]
+
+
+class AddrInfo(ctypes.Structure):
+ pass
+
+
+AddrInfo._fields_ = [
+ ("flags", ctypes.c_int),
+ ("family", ctypes.c_int),
+ ("socktype", ctypes.c_int),
+ ("protocol", ctypes.c_int),
+ ("addrlen", ctypes.c_uint),
+ ("addr", ctypes.c_void_p),
+ ("canonname", ctypes.c_char_p),
+ ("next", ctypes.POINTER(AddrInfo)),
+]
+
+
+@contextmanager
+def quiet_probe():
+ # The C hook logs denied connections. Suppress only our synthetic startup
+ # probes, before any remote connection or concurrent task has been started.
+ saved = os.dup(2)
+ try:
+ with open(os.devnull, "w") as sink:
+ os.dup2(sink.fileno(), 2)
+ yield
+ finally:
+ os.dup2(saved, 2)
+ os.close(saved)
+
+
+class SandboxNetworkCheck:
+ """Verify the existing interposer without requiring a new C export."""
+
+ def __init__(self, library):
+ # Resolve process-global symbols only: loading a library here would not
+ # prove LD_PRELOAD actually installed the interposed network functions.
+ process = ctypes.CDLL(None, use_errno=True)
+ dladdr = process.dladdr
+ dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(DlInfo)]
+ dladdr.restype = ctypes.c_int
+ self.connect = process.connect
+ self.connect.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_uint]
+ self.connect.restype = ctypes.c_int
+ self.getaddrinfo = process.getaddrinfo
+ self.getaddrinfo.argtypes = [
+ ctypes.c_char_p,
+ ctypes.c_char_p,
+ ctypes.POINTER(AddrInfo),
+ ctypes.POINTER(ctypes.POINTER(AddrInfo)),
+ ]
+ self.getaddrinfo.restype = ctypes.c_int
+ self.freeaddrinfo = process.freeaddrinfo
+ self.freeaddrinfo.argtypes = [ctypes.POINTER(AddrInfo)]
+ self.freeaddrinfo.restype = None
+ for function in (self.connect, self.getaddrinfo):
+ info = DlInfo()
+ if not dladdr(ctypes.cast(function, ctypes.c_void_p), ctypes.byref(info)) or not info.filename:
+ raise RuntimeError("Cannot locate MCP sandbox network hooks")
+ loaded_path = Path(os.fsdecode(info.filename))
+ if not os.path.samefile(loaded_path, library):
+ raise RuntimeError("MCP sandbox network hooks are not preloaded")
+ self.policy_path = loaded_path.with_name(".sandbox.conf")
+
+ def verify(self):
+ rules = ""
+ # Read as the sandbox user, from the same path used by the C interposer.
+ # Reject a truncated policy rather than relying on its first 511 bytes.
+ for line in self.policy_path.read_text().splitlines(keepends=True):
+ key, separator, value = line.partition("=")
+ if separator and key.strip() == "SANDBOX_PYTHON_BANNED_HOSTS":
+ if len(line.encode()) > 511:
+ raise RuntimeError("MCP sandbox network policy is too long")
+ rules = value.strip()
+ if not rules:
+ raise RuntimeError("MCP sandbox network policy is empty")
+ for rule in filter(None, (value.strip() for value in rules.split(","))):
+ try:
+ ip = ipaddress.ip_network(rule, strict=False).network_address
+ except ValueError:
+ # Numeric-only flags prevent this self-check from sending DNS
+ # traffic, even if the rule is ineffective or the hook is broken.
+ hints = AddrInfo(flags=socket.AI_NUMERICHOST | socket.AI_NUMERICSERV)
+ result = ctypes.POINTER(AddrInfo)()
+ ctypes.set_errno(0)
+ with quiet_probe():
+ status = self.getaddrinfo(rule.encode(), b"0", ctypes.byref(hints), ctypes.byref(result))
+ error = ctypes.get_errno()
+ if result:
+ self.freeaddrinfo(result)
+ if status == socket.EAI_SYSTEM and error == errno.EACCES:
+ return
+ else:
+ if ip.version == 4:
+ address = struct.pack("=H", socket.AF_INET) + b"\0\0" + ip.packed + b"\0" * 8
+ else:
+ address = struct.pack("=H", socket.AF_INET6) + b"\0" * 6 + ip.packed + b"\0" * 4
+ buffer = ctypes.create_string_buffer(address)
+ ctypes.set_errno(0)
+ # -1 can never send a packet: libc returns EBADF, while the
+ # active sandbox must reject a configured banned IP with EACCES.
+ with quiet_probe():
+ status = self.connect(-1, buffer, len(address))
+ if status == -1 and ctypes.get_errno() == errno.EACCES:
+ return
+ raise RuntimeError("MCP sandbox network policy self-check failed")
+
+
+def enter_sandbox(settings):
+ if not sys.platform.startswith("linux"):
+ raise RuntimeError("MCP sandbox requires Linux")
+ account = pwd.getpwnam("sandbox")
+ if settings["uid"] != account.pw_uid or settings["gid"] != account.pw_gid or account.pw_uid == 0:
+ raise RuntimeError("Invalid MCP sandbox identity")
+ network_check = SandboxNetworkCheck(settings["library"])
+ timeout = settings["timeout"]
+ memory = settings["memory_mb"] * 1024 * 1024
+ cores = settings["cpu_cores"]
+ if timeout <= 0 or memory <= 0 or cores <= 0:
+ raise RuntimeError("Invalid MCP sandbox resource limits")
+ resource.setrlimit(resource.RLIMIT_AS, (memory, memory))
+ resource.setrlimit(resource.RLIMIT_CPU, (timeout, timeout))
+ resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
+ os.sched_setaffinity(0, sorted(os.sched_getaffinity(0))[:cores])
+ # The SDK closes stdin, then terminates/kills the child when a session ends.
+ # This independent wall deadline also bounds a hung handshake or orphan.
+ signal.signal(signal.SIGALRM, signal.SIG_DFL)
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
+ signal.alarm(timeout)
+ os.setgroups([])
+ os.setgid(account.pw_gid)
+ os.setuid(account.pw_uid)
+ os.environ.clear()
+ if os.getuid() != account.pw_uid or os.geteuid() != account.pw_uid:
+ raise RuntimeError("MCP sandbox identity was not applied")
+ network_check.verify()
+
+
+def main():
+ settings = json.loads(os.environ.pop("MAXKB_MCP_WORKER_SETTINGS"))
+ # Load the fixed proxy before dropping access to the app tree. It does not
+ # import Django or read application configuration. The loader supports both
+ # source and sourceless release layouts, only from our installed directory.
+ spec = importlib.machinery.PathFinder.find_spec("sandbox_proxy", [str(Path(__file__).parent)])
+ if spec is None or spec.loader is None:
+ raise RuntimeError("MCP sandbox dependency is missing")
+ proxy = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(proxy)
+ # Remove the application directory while retaining approved package paths.
+ app_path = str(Path(__file__).resolve().parents[2])
+ sys.path = [p for p in sys.path if p != app_path]
+ sys.path.extend(p for p in settings["python_paths"] if p and p not in sys.path)
+ enter_sandbox(settings)
+ try:
+ proxy.run()
+ except Exception as exc:
+ raise MCPWorkerFailure(proxy.sandbox_failure_message(exc)) from None
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except MCPWorkerFailure as exc:
+ sys.stderr.write(f"MCP sandbox worker failed: {exc}.\n")
+ sys.exit(1)
+ except BaseException:
+ # URLs/headers may contain secrets: keep failures off stdout and do not
+ # dump exceptions or bootstrap data inherited from the remote SDK.
+ sys.stderr.write("MCP sandbox worker failed; check sandbox setup and network policy.\n")
+ sys.exit(1)
diff --git a/apps/common/middleware/doc_headers_middleware.py b/apps/common/middleware/doc_headers_middleware.py
index 5f1884e7731..5afa66a523c 100644
--- a/apps/common/middleware/doc_headers_middleware.py
+++ b/apps/common/middleware/doc_headers_middleware.py
@@ -10,7 +10,7 @@
from django.http import HttpResponse
from django.utils.deprecation import MiddlewareMixin
-from common.auth import TokenDetails, handles
+from common.auth import TokenDetails, get_handles
from maxkb.const import CONFIG
content = """
@@ -126,7 +126,7 @@ def process_response(self, request, response):
try:
token = auth[7:]
token_details = TokenDetails(token)
- for handle in handles:
+ for handle in get_handles():
if handle.support(request, token, token_details.get_token_details):
handle.handle(request, token, token_details.get_token_details)
return response
diff --git a/apps/common/sql/list_embedding_text.sql b/apps/common/sql/list_embedding_text.sql
index 8f4f14dfd6d..431b8857458 100644
--- a/apps/common/sql/list_embedding_text.sql
+++ b/apps/common/sql/list_embedding_text.sql
@@ -20,10 +20,19 @@ SELECT
paragraph."id" AS paragraph_id,
paragraph.knowledge_id AS knowledge_id,
1 AS source_type,
- concat_ws(E'\n',paragraph.title,paragraph."content") AS "text",
+ concat_ws(
+ E'\n',
+ paragraph.title,
+ paragraph."content",
+ (
+ SELECT string_agg(concat_ws(E'\n', asset.caption, asset.ocr_text, asset.description), E'\n' ORDER BY asset.position)
+ FROM paragraph_asset asset
+ WHERE asset.paragraph_id = paragraph."id" AND asset.sync_state = 'active'
+ )
+ ) AS "text",
paragraph.is_active AS is_active,
paragraph.chunks AS chunks
FROM
paragraph paragraph
- ${paragraph}
\ No newline at end of file
+ ${paragraph}
diff --git a/apps/application/flow/step_node/variable_aggregation_node/__init__.py b/apps/common/storage/__init__.py
similarity index 100%
rename from apps/application/flow/step_node/variable_aggregation_node/__init__.py
rename to apps/common/storage/__init__.py
diff --git a/apps/common/storage/seaweedfs.py b/apps/common/storage/seaweedfs.py
new file mode 100644
index 00000000000..d3e57dbce57
--- /dev/null
+++ b/apps/common/storage/seaweedfs.py
@@ -0,0 +1,25 @@
+import boto3
+from botocore.client import Config
+from maxkb.const import CONFIG
+
+
+def is_seaweedfs_enabled() -> bool:
+ return bool(CONFIG.get("S3_ENDPOINT"))
+
+
+def get_bucket() -> str:
+ return CONFIG.get("S3_BUCKET") or "maxkb"
+
+
+def get_s3_client():
+ addr = CONFIG.get("S3_ENDPOINT") or ""
+ if addr and not addr.startswith(("http://", "https://")):
+ addr = f"http://{addr}"
+ return boto3.client(
+ "s3",
+ endpoint_url=addr,
+ aws_access_key_id=CONFIG.get("S3_ACCESS_KEY"),
+ aws_secret_access_key=CONFIG.get("S3_SECRET_KEY"),
+ config=Config(signature_version="s3v4"),
+ region_name="us-east-1",
+ )
diff --git a/apps/common/test_clean_chat_job.py b/apps/common/test_clean_chat_job.py
new file mode 100644
index 00000000000..1641f49d43c
--- /dev/null
+++ b/apps/common/test_clean_chat_job.py
@@ -0,0 +1,96 @@
+from contextlib import nullcontext
+from datetime import timedelta
+from types import SimpleNamespace
+from unittest.mock import MagicMock, call, patch
+from uuid import UUID
+
+from django.db.models import Q
+from django.test import SimpleTestCase
+from django.utils import timezone
+
+
+class CleanChatPaginationTests(SimpleTestCase):
+ def run_cleanup(self, count, clean_log=False):
+ # Importing common.job normally starts the scheduler; keep tests isolated from background jobs.
+ with patch("apscheduler.schedulers.background.BackgroundScheduler.start"):
+ from common.job.clean_chat_job import clean_method
+
+ now = timezone.now()
+ chat_id = UUID(int=9999)
+ records = [SimpleNamespace(id=UUID(int=i + 1), chat_id=chat_id, create_time=now) for i in range(count)]
+ pages = [records[i : i + 500] for i in range(0, count, 500)]
+ if count % 500 == 0:
+ pages.append([])
+ page_queries = []
+ for page in pages:
+ query = MagicMock()
+ query.filter.return_value = query
+ query.order_by.return_value.only.return_value.__getitem__.return_value = page
+ page_queries.append(query)
+ aggregate_query = MagicMock()
+ aggregate_query.values.return_value.annotate.return_value = [{"chat_id": chat_id, "max_create_time": now}]
+ # Cascaded delete counts can exceed the number of ChatRecords; they must not control pagination.
+ aggregate_query.delete.return_value = (9999, {})
+ count_query = MagicMock()
+ count_query.values.return_value.annotate.return_value = [{"chat_id": chat_id, "count": 1}]
+ remaining_queries = iter(page_queries)
+ conditions = Q(create_time__lt=now + timedelta(days=1))
+
+ def filter_records(*args, **kwargs):
+ if args:
+ self.assertEqual(args, (conditions,))
+ return next(remaining_queries)
+ if "chat_id__in" in kwargs:
+ return count_query
+ return aggregate_query
+
+ with (
+ patch("common.job.clean_chat_job.transaction.atomic", return_value=nullcontext()),
+ patch("common.job.clean_chat_job.ChatRecord.objects") as manager,
+ patch("common.job.clean_chat_job.Chat.objects") as chats,
+ patch("common.job.clean_chat_job.File.objects") as files,
+ patch("common.job.clean_chat_job.delete_orphan_chats") as delete_chats,
+ patch("common.job.clean_chat_job.maxkb_logger"),
+ ):
+ manager.filter.side_effect = filter_records
+ file = SimpleNamespace(id=UUID(int=99999))
+ file_query = MagicMock()
+ file_query.__iter__.return_value = [file]
+ files.filter.return_value = file_query
+ clean_method(conditions, clean_log=clean_log)
+
+ self.assertEqual(sum(bool(c.args) for c in manager.filter.call_args_list), len(pages))
+ for index, query in enumerate(page_queries):
+ query.order_by.assert_called_once_with("id")
+ if index:
+ query.filter.assert_called_once_with(id__gt=pages[index - 1][-1].id)
+ else:
+ query.filter.assert_not_called()
+ nonempty_pages = sum(bool(page) for page in pages)
+ # One file lookup per chat per batch, rather than one per record in the chat.
+ lookups = [c for c in files.filter.call_args_list if "source_id" in c.kwargs]
+ self.assertEqual(len(lookups), nonempty_pages)
+ self.assertTrue(all(c.kwargs["create_time__lt"] == now for c in lookups))
+ deletions = [c for c in files.filter.call_args_list if "id__in" in c.kwargs]
+ self.assertEqual(deletions, [call(id__in=[file.id])] * nonempty_pages)
+ if not clean_log:
+ aggregate_query.delete.assert_not_called()
+ chats.filter.assert_not_called()
+ delete_chats.assert_not_called()
+ else:
+ self.assertEqual(aggregate_query.delete.call_count, nonempty_pages)
+
+ def test_files_only_processes_all_pages(self):
+ self.run_cleanup(1201)
+
+ def test_files_only_exact_batch_boundary(self):
+ self.run_cleanup(500)
+
+ def test_files_only_one_record_after_boundary(self):
+ self.run_cleanup(501)
+
+ def test_empty_queryset(self):
+ self.run_cleanup(0)
+
+ def test_log_deletion_uses_same_cursor_without_offset_skips(self):
+ self.run_cleanup(1001, clean_log=True)
diff --git a/apps/common/tests.py b/apps/common/tests.py
new file mode 100644
index 00000000000..113d6ec6279
--- /dev/null
+++ b/apps/common/tests.py
@@ -0,0 +1,16 @@
+from django.test import SimpleTestCase
+
+from common.utils.common import markdown_to_plain_text
+
+
+class MarkdownToPlainTextTestCase(SimpleTestCase):
+ def test_removes_embedded_markup_contents(self):
+ cases = {
+ 'before after': "before after",
+ 'before after': "before after",
+ 'before {"label":"private"} after': "before after",
+ }
+
+ for markup, expected in cases.items():
+ with self.subTest(markup=markup):
+ self.assertEqual(markdown_to_plain_text(markup), expected)
diff --git a/apps/common/utils/common.py b/apps/common/utils/common.py
index 43370db2ef1..df589b220bb 100644
--- a/apps/common/utils/common.py
+++ b/apps/common/utils/common.py
@@ -13,8 +13,8 @@
import json
import mimetypes
import pickle
-import random
import re
+import secrets
import shutil
import uuid
from functools import reduce
@@ -24,6 +24,7 @@
from django.contrib.auth.hashers import check_password, make_password
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.db.models import QuerySet
+from django.http import StreamingHttpResponse
from django.utils.translation import gettext as _
from maxkb.settings import TIME_ZONE
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE
@@ -117,7 +118,7 @@ def group_by(list_source: List, key):
def get_random_chars(number=4):
if number <= 0:
return ""
- return "".join(random.choices(SAFE_CHAR_SET, k=number))
+ return "".join(secrets.choice(SAFE_CHAR_SET) for _ in range(number))
def encryption(message: str):
@@ -159,8 +160,18 @@ def _remove_empty_lines(text):
def markdown_to_plain_text(md: str) -> str:
+ # 先移除特定媒体标签(优先级高于通用 Markdown 和 HTML 处理)
+ text = re.sub(
+ r"<(audio|video)(?:\s+[^>]*)?>.*?\1>",
+ "",
+ md,
+ flags=re.DOTALL | re.IGNORECASE,
+ )
+ text = re.sub(r"
]*>", "", text) # 匹配图片标签
+ # 去除表单渲染
+ text = re.sub(r".*?", "", text, flags=re.DOTALL)
# 移除图片 
- text = re.sub(r"!\[.*?\]\(.*?\)", "", md)
+ text = re.sub(r"!\[.*?\]\(.*?\)", "", text)
# 移除链接 [text](url)
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
# 移除 Markdown 标题符号 (#, ##, ###)
@@ -179,15 +190,8 @@ def markdown_to_plain_text(md: str) -> str:
text = re.sub(r"\n{2,}", "\n", text)
# 使用正则表达式去除所有 HTML 标签
text = re.sub(r"<[^>]+>", "", text)
- # 先移除特定媒体标签(优先级高于通用HTML标签移除)
- text = re.sub(
- r"<(?:audio|video)(?:\s+[^>]*)?>.*?(?:(?:audio|video)>)?", "", text, flags=re.DOTALL | re.IGNORECASE
- )
- text = re.sub(r"
]*>", "", text) # 匹配图片标签
# 去除多余的空白字符(包括换行符、制表符等)
text = re.sub(r"\s+", " ", text)
- # 去除表单渲染
- text = re.sub(r".*?<\/form_rander>", "", text, flags=re.DOTALL)
# 去除首尾空格
text = text.strip()
return text
@@ -235,6 +239,33 @@ def bytes_to_uploaded_file(file_bytes, file_name="file.txt"):
return uploaded_file
+def guess_image_format(file_bytes: bytes, file_name: str = "") -> str:
+ content_type, _ = mimetypes.guess_type(file_name)
+ if content_type and content_type.startswith("image/"):
+ return content_type.split("/", 1)[1]
+
+ if file_bytes.startswith(b"\xff\xd8\xff"):
+ return "jpeg"
+ if file_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
+ return "png"
+ if file_bytes.startswith((b"GIF87a", b"GIF89a")):
+ return "gif"
+ if file_bytes.startswith(b"RIFF") and file_bytes[8:12] == b"WEBP":
+ return "webp"
+ if file_bytes.startswith(b"BM"):
+ return "bmp"
+ if file_bytes.startswith((b"II*\x00", b"MM\x00*")):
+ return "tiff"
+ if file_bytes.startswith(b"\x00\x00\x01\x00"):
+ return "x-icon"
+
+ stripped = file_bytes.lstrip()
+ if stripped.startswith(b"