Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aa5273c5e | ||
|
|
473b465efb | ||
|
|
670d479e32 | ||
|
|
ae7de7d36b | ||
|
|
ef5decc98a | ||
|
|
4445460eca | ||
|
|
8288b1dcab | ||
|
|
16d1289a0a | ||
|
|
ba775a1c90 | ||
|
|
b0e58f9da7 | ||
|
|
531e784a92 | ||
|
|
5e8fe30035 | ||
|
|
f5033c5a0e | ||
|
|
26d7654851 | ||
|
|
790a6ec203 | ||
|
|
de9c5f10b3 | ||
|
|
a7fe0e3f87 | ||
|
|
218e247fd2 | ||
|
|
6b51530e21 | ||
|
|
9755564a05 | ||
|
|
052d0e059e | ||
|
|
fa4d3bba86 | ||
|
|
f8fc9f8c79 | ||
|
|
80f0594f4b | ||
|
|
97b24f48d5 | ||
|
|
b475a6b257 | ||
|
|
ff52a54fef | ||
|
|
c69634085d | ||
|
|
0a9af45194 | ||
|
|
d92ddc4dd4 | ||
|
|
32fa817eaa | ||
|
|
af10b3c5fa | ||
|
|
8b601a983c | ||
|
|
2e28a64d38 | ||
|
|
4b9812ce6a | ||
|
|
c9e18346ce | ||
|
|
462ba8f416 | ||
|
|
658157e9a1 | ||
|
|
4031a46572 | ||
|
|
821fe26b56 | ||
|
|
352776ba77 | ||
|
|
f560116fb2 | ||
|
|
e7a5268fdd | ||
|
|
aa71173dbb | ||
|
|
11fdcb18c6 | ||
|
|
62c34c4bc2 | ||
|
|
01f2333381 | ||
|
|
8d47213529 | ||
|
|
f40e2cf98a | ||
|
|
05e071bf2f | ||
|
|
e340fccafb | ||
|
|
3f57e4a643 | ||
|
|
4a2e6af9b5 | ||
|
|
acba135de1 | ||
|
|
4e02abf784 | ||
|
|
61be6f5d2c | ||
|
|
e69797d738 | ||
|
|
415178fb0d | ||
|
|
1a642084b5 | ||
|
|
d9ccd74f0b | ||
|
|
4e6cb26778 | ||
|
|
12083de2ab | ||
|
|
3522eb51b6 | ||
|
|
8e1ea671bd | ||
|
|
d2eda60e0e | ||
|
|
b73487dd67 | ||
|
|
7ad64bfb60 | ||
|
|
1aec17f912 | ||
|
|
c1c7a43191 |
-1197
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
package_json_file: web/package.json
|
||||
run_install: false
|
||||
|
||||
- name: Setup NodeJS
|
||||
@@ -95,10 +95,12 @@ jobs:
|
||||
|
||||
- name: Web dependencies
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Web style check
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm run lint
|
||||
|
||||
docker-compose-template:
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
package_json_file: web/package.json
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
@@ -59,10 +59,12 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.FILES_CHANGED == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate i18n translations
|
||||
if: env.FILES_CHANGED == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm run auto-gen-i18n ${{ env.FILE_ARGS }}
|
||||
|
||||
- name: Create Pull Request
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
package_json_file: web/package.json
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -48,8 +48,10 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm test
|
||||
|
||||
@@ -197,6 +197,8 @@ sdks/python-client/dify_client.egg-info
|
||||
!.vscode/README.md
|
||||
pyrightconfig.json
|
||||
api/.vscode
|
||||
# vscode Code History Extension
|
||||
.history
|
||||
|
||||
.idea/
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Dify is an open-source platform for developing LLM applications with an intuitive interface combining agentic AI workflows, RAG pipelines, agent capabilities, and model management.
|
||||
|
||||
The codebase consists of:
|
||||
- **Backend API** (`/api`): Python Flask application with Domain-Driven Design architecture
|
||||
- **Frontend Web** (`/web`): Next.js 15 application with TypeScript and React 19
|
||||
- **Docker deployment** (`/docker`): Containerized deployment configurations
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend (API)
|
||||
|
||||
All Python commands must be prefixed with `uv run --project api`:
|
||||
|
||||
```bash
|
||||
# Start development servers
|
||||
./dev/start-api # Start API server
|
||||
./dev/start-worker # Start Celery worker
|
||||
|
||||
# Run tests
|
||||
uv run --project api pytest # Run all tests
|
||||
uv run --project api pytest tests/unit_tests/ # Unit tests only
|
||||
uv run --project api pytest tests/integration_tests/ # Integration tests
|
||||
|
||||
# Code quality
|
||||
./dev/reformat # Run all formatters and linters
|
||||
uv run --project api ruff check --fix ./ # Fix linting issues
|
||||
uv run --project api ruff format ./ # Format code
|
||||
uv run --project api mypy . # Type checking
|
||||
```
|
||||
|
||||
### Frontend (Web)
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm lint # Run ESLint
|
||||
pnpm eslint-fix # Fix ESLint issues
|
||||
pnpm test # Run Jest tests
|
||||
```
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Backend Testing
|
||||
- Use `pytest` for all backend tests
|
||||
- Write tests first (TDD approach)
|
||||
- Test structure: Arrange-Act-Assert
|
||||
|
||||
## Code Style Requirements
|
||||
|
||||
### Python
|
||||
- Use type hints for all functions and class attributes
|
||||
- No `Any` types unless absolutely necessary
|
||||
- Implement special methods (`__repr__`, `__str__`) appropriately
|
||||
|
||||
### TypeScript/JavaScript
|
||||
- Strict TypeScript configuration
|
||||
- ESLint with Prettier integration
|
||||
- Avoid `any` type
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Environment Variables**: Always use UV for Python commands: `uv run --project api <command>`
|
||||
- **Comments**: Only write meaningful comments that explain "why", not "what"
|
||||
- **File Creation**: Always prefer editing existing files over creating new ones
|
||||
- **Documentation**: Don't create documentation files unless explicitly requested
|
||||
- **Code Quality**: Always run `./dev/reformat` before committing backend changes
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding a New API Endpoint
|
||||
1. Create controller in `/api/controllers/`
|
||||
2. Add service logic in `/api/services/`
|
||||
3. Update routes in controller's `__init__.py`
|
||||
4. Write tests in `/api/tests/`
|
||||
|
||||
## Project-Specific Conventions
|
||||
|
||||
- All async tasks use Celery with Redis as broker
|
||||
@@ -225,7 +225,8 @@ Deploy Dify to AWS with [CDK](https://aws.amazon.com/cdk/)
|
||||
|
||||
##### AWS
|
||||
|
||||
- [AWS CDK by @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Using Alibaba Cloud Computing Nest
|
||||
|
||||
|
||||
+2
-1
@@ -208,7 +208,8 @@ docker compose up -d
|
||||
|
||||
##### AWS
|
||||
|
||||
- [AWS CDK بواسطة @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK بواسطة @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK بواسطة @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### استخدام Alibaba Cloud للنشر
|
||||
[بسرعة نشر Dify إلى سحابة علي بابا مع عش الحوسبة السحابية علي بابا](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
|
||||
|
||||
+2
-1
@@ -225,7 +225,8 @@ GitHub-এ ডিফাইকে স্টার দিয়ে রাখুন
|
||||
|
||||
##### AWS
|
||||
|
||||
- [AWS CDK by @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Alibaba Cloud ব্যবহার করে ডিপ্লয়
|
||||
|
||||
|
||||
+2
-1
@@ -223,7 +223,8 @@ docker compose up -d
|
||||
使用 [CDK](https://aws.amazon.com/cdk/) 将 Dify 部署到 AWS
|
||||
|
||||
##### AWS
|
||||
- [AWS CDK by @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### 使用 阿里云计算巢 部署
|
||||
|
||||
|
||||
+2
-1
@@ -220,7 +220,8 @@ Stellen Sie Dify mit nur einem Klick mithilfe von [terraform](https://www.terraf
|
||||
Bereitstellung von Dify auf AWS mit [CDK](https://aws.amazon.com/cdk/)
|
||||
|
||||
##### AWS
|
||||
- [AWS CDK by @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
|
||||
+2
-1
@@ -220,7 +220,8 @@ Despliega Dify en una plataforma en la nube con un solo clic utilizando [terrafo
|
||||
Despliegue Dify en AWS usando [CDK](https://aws.amazon.com/cdk/)
|
||||
|
||||
##### AWS
|
||||
- [AWS CDK por @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK por @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK por @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
|
||||
+2
-1
@@ -218,7 +218,8 @@ Déployez Dify sur une plateforme cloud en un clic en utilisant [terraform](http
|
||||
Déployez Dify sur AWS en utilisant [CDK](https://aws.amazon.com/cdk/)
|
||||
|
||||
##### AWS
|
||||
- [AWS CDK par @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK par @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK par @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
|
||||
+2
-1
@@ -219,7 +219,8 @@ docker compose up -d
|
||||
[CDK](https://aws.amazon.com/cdk/) を使用して、DifyをAWSにデプロイします
|
||||
|
||||
##### AWS
|
||||
- [@KevinZhaoによるAWS CDK](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [@KevinZhaoによるAWS CDK (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [@tmokmssによるAWS CDK (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Alibaba Cloud
|
||||
[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
|
||||
|
||||
+2
-1
@@ -218,7 +218,8 @@ wa'logh nIqHom neH ghun deployment toy'wI' [terraform](https://www.terraform.io/
|
||||
wa'logh nIqHom neH ghun deployment toy'wI' [CDK](https://aws.amazon.com/cdk/) lo'laH.
|
||||
|
||||
##### AWS
|
||||
- [AWS CDK qachlot @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK qachlot @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK qachlot @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
|
||||
+2
-1
@@ -212,7 +212,8 @@ Dify를 Kubernetes에 배포하고 프리미엄 스케일링 설정을 구성했
|
||||
[CDK](https://aws.amazon.com/cdk/)를 사용하여 AWS에 Dify 배포
|
||||
|
||||
##### AWS
|
||||
- [KevinZhao의 AWS CDK](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [KevinZhao의 AWS CDK (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [tmokmss의 AWS CDK (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
|
||||
+2
-1
@@ -217,7 +217,8 @@ Implante o Dify na Plataforma Cloud com um único clique usando [terraform](http
|
||||
Implante o Dify na AWS usando [CDK](https://aws.amazon.com/cdk/)
|
||||
|
||||
##### AWS
|
||||
- [AWS CDK por @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK por @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK por @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
|
||||
+2
-1
@@ -218,7 +218,8 @@ namestite Dify v Cloud Platform z enim klikom z uporabo [terraform](https://www.
|
||||
Uvedite Dify v AWS z uporabo [CDK](https://aws.amazon.com/cdk/)
|
||||
|
||||
##### AWS
|
||||
- [AWS CDK by @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK by @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
|
||||
+2
-1
@@ -211,7 +211,8 @@ Dify'ı bulut platformuna tek tıklamayla dağıtın [terraform](https://www.ter
|
||||
[CDK](https://aws.amazon.com/cdk/) kullanarak Dify'ı AWS'ye dağıtın
|
||||
|
||||
##### AWS
|
||||
- [AWS CDK tarafından @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK tarafından @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK tarafından @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
|
||||
+2
-1
@@ -223,7 +223,8 @@ Dify 的所有功能都提供相應的 API,因此您可以輕鬆地將 Dify
|
||||
|
||||
### AWS
|
||||
|
||||
- [由 @KevinZhao 提供的 AWS CDK](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [由 @KevinZhao 提供的 AWS CDK (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [由 @tmokmss 提供的 AWS CDK (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
#### 使用 阿里云计算巢進行部署
|
||||
|
||||
|
||||
+2
-1
@@ -213,7 +213,8 @@ Triển khai Dify lên nền tảng đám mây với một cú nhấp chuột b
|
||||
Triển khai Dify trên AWS bằng [CDK](https://aws.amazon.com/cdk/)
|
||||
|
||||
##### AWS
|
||||
- [AWS CDK bởi @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK bởi @KevinZhao (EKS based)](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
|
||||
- [AWS CDK bởi @tmokmss (ECS based)](https://github.com/aws-samples/dify-self-hosted-on-aws)
|
||||
|
||||
|
||||
#### Alibaba Cloud
|
||||
|
||||
@@ -42,6 +42,15 @@ REDIS_PORT=6379
|
||||
REDIS_USERNAME=
|
||||
REDIS_PASSWORD=difyai123456
|
||||
REDIS_USE_SSL=false
|
||||
# SSL configuration for Redis (when REDIS_USE_SSL=true)
|
||||
REDIS_SSL_CERT_REQS=CERT_NONE
|
||||
# Options: CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED
|
||||
REDIS_SSL_CA_CERTS=
|
||||
# Path to CA certificate file for SSL verification
|
||||
REDIS_SSL_CERTFILE=
|
||||
# Path to client certificate file for SSL authentication
|
||||
REDIS_SSL_KEYFILE=
|
||||
# Path to client private key file for SSL authentication
|
||||
REDIS_DB=0
|
||||
|
||||
# redis Sentinel configuration.
|
||||
|
||||
@@ -51,6 +51,7 @@ def initialize_extensions(app: DifyApp):
|
||||
ext_login,
|
||||
ext_mail,
|
||||
ext_migrate,
|
||||
ext_orjson,
|
||||
ext_otel,
|
||||
ext_proxy_fix,
|
||||
ext_redis,
|
||||
@@ -67,6 +68,7 @@ def initialize_extensions(app: DifyApp):
|
||||
ext_logging,
|
||||
ext_warnings,
|
||||
ext_import_modules,
|
||||
ext_orjson,
|
||||
ext_set_secretkey,
|
||||
ext_compress,
|
||||
ext_code_based_extension,
|
||||
|
||||
+20
@@ -39,6 +39,26 @@ class RedisConfig(BaseSettings):
|
||||
default=False,
|
||||
)
|
||||
|
||||
REDIS_SSL_CERT_REQS: str = Field(
|
||||
description="SSL certificate requirements (CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED)",
|
||||
default="CERT_NONE",
|
||||
)
|
||||
|
||||
REDIS_SSL_CA_CERTS: Optional[str] = Field(
|
||||
description="Path to the CA certificate file for SSL verification",
|
||||
default=None,
|
||||
)
|
||||
|
||||
REDIS_SSL_CERTFILE: Optional[str] = Field(
|
||||
description="Path to the client certificate file for SSL authentication",
|
||||
default=None,
|
||||
)
|
||||
|
||||
REDIS_SSL_KEYFILE: Optional[str] = Field(
|
||||
description="Path to the client private key file for SSL authentication",
|
||||
default=None,
|
||||
)
|
||||
|
||||
REDIS_USE_SENTINEL: Optional[bool] = Field(
|
||||
description="Enable Redis Sentinel mode for high availability",
|
||||
default=False,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
import flask_login
|
||||
from flask import request
|
||||
from flask_restful import Resource, reqparse
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
|
||||
@@ -24,6 +25,7 @@ from core.errors.error import (
|
||||
ProviderTokenNotInitError,
|
||||
QuotaExceededError,
|
||||
)
|
||||
from core.helper.trace_id_helper import get_external_trace_id
|
||||
from core.model_runtime.errors.invoke import InvokeError
|
||||
from libs import helper
|
||||
from libs.helper import uuid_value
|
||||
@@ -115,6 +117,10 @@ class ChatMessageApi(Resource):
|
||||
streaming = args["response_mode"] != "blocking"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
args["external_trace_id"] = external_trace_id
|
||||
|
||||
account = flask_login.current_user
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from flask_login import current_user
|
||||
from flask_restful import Resource, reqparse
|
||||
|
||||
@@ -10,6 +12,9 @@ from controllers.console.app.error import (
|
||||
)
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from core.helper.code_executor.code_node_provider import CodeNodeProvider
|
||||
from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider
|
||||
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from core.model_runtime.errors.invoke import InvokeError
|
||||
from libs.login import login_required
|
||||
@@ -107,6 +112,119 @@ class RuleStructuredOutputGenerateApi(Resource):
|
||||
return structured_output
|
||||
|
||||
|
||||
class InstructionGenerateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("flow_id", type=str, required=True, default="", location="json")
|
||||
parser.add_argument("node_id", type=str, required=False, default="", location="json")
|
||||
parser.add_argument("current", type=str, required=False, default="", location="json")
|
||||
parser.add_argument("language", type=str, required=False, default="javascript", location="json")
|
||||
parser.add_argument("instruction", type=str, required=True, nullable=False, location="json")
|
||||
parser.add_argument("model_config", type=dict, required=True, nullable=False, location="json")
|
||||
parser.add_argument("ideal_output", type=str, required=False, default="", location="json")
|
||||
args = parser.parse_args()
|
||||
providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider]
|
||||
code_provider: type[CodeNodeProvider] | None = next(
|
||||
(p for p in providers if p.is_accept_language(args["language"])), None
|
||||
)
|
||||
code_template = code_provider.get_default_code() if code_provider else ""
|
||||
try:
|
||||
# Generate from nothing for a workflow node
|
||||
if (args["current"] == code_template or args["current"] == "") and args["node_id"] != "":
|
||||
from models import App, db
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
app = db.session.query(App).filter(App.id == args["flow_id"]).first()
|
||||
if not app:
|
||||
return {"error": f"app {args['flow_id']} not found"}, 400
|
||||
workflow = WorkflowService().get_draft_workflow(app_model=app)
|
||||
if not workflow:
|
||||
return {"error": f"workflow {args['flow_id']} not found"}, 400
|
||||
nodes: Sequence = workflow.graph_dict["nodes"]
|
||||
node = [node for node in nodes if node["id"] == args["node_id"]]
|
||||
if len(node) == 0:
|
||||
return {"error": f"node {args['node_id']} not found"}, 400
|
||||
node_type = node[0]["data"]["type"]
|
||||
match node_type:
|
||||
case "llm":
|
||||
return LLMGenerator.generate_rule_config(
|
||||
current_user.current_tenant_id,
|
||||
instruction=args["instruction"],
|
||||
model_config=args["model_config"],
|
||||
no_variable=True,
|
||||
)
|
||||
case "agent":
|
||||
return LLMGenerator.generate_rule_config(
|
||||
current_user.current_tenant_id,
|
||||
instruction=args["instruction"],
|
||||
model_config=args["model_config"],
|
||||
no_variable=True,
|
||||
)
|
||||
case "code":
|
||||
return LLMGenerator.generate_code(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
instruction=args["instruction"],
|
||||
model_config=args["model_config"],
|
||||
code_language=args["language"],
|
||||
)
|
||||
case _:
|
||||
return {"error": f"invalid node type: {node_type}"}
|
||||
if args["node_id"] == "" and args["current"] != "": # For legacy app without a workflow
|
||||
return LLMGenerator.instruction_modify_legacy(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
flow_id=args["flow_id"],
|
||||
current=args["current"],
|
||||
instruction=args["instruction"],
|
||||
model_config=args["model_config"],
|
||||
ideal_output=args["ideal_output"],
|
||||
)
|
||||
if args["node_id"] != "" and args["current"] != "": # For workflow node
|
||||
return LLMGenerator.instruction_modify_workflow(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
flow_id=args["flow_id"],
|
||||
node_id=args["node_id"],
|
||||
current=args["current"],
|
||||
instruction=args["instruction"],
|
||||
model_config=args["model_config"],
|
||||
ideal_output=args["ideal_output"],
|
||||
)
|
||||
return {"error": "incompatible parameters"}, 400
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
|
||||
|
||||
class InstructionGenerationTemplateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self) -> dict:
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("type", type=str, required=True, default=False, location="json")
|
||||
args = parser.parse_args()
|
||||
match args["type"]:
|
||||
case "prompt":
|
||||
from core.llm_generator.prompts import INSTRUCTION_GENERATE_TEMPLATE_PROMPT
|
||||
|
||||
return {"data": INSTRUCTION_GENERATE_TEMPLATE_PROMPT}
|
||||
case "code":
|
||||
from core.llm_generator.prompts import INSTRUCTION_GENERATE_TEMPLATE_CODE
|
||||
|
||||
return {"data": INSTRUCTION_GENERATE_TEMPLATE_CODE}
|
||||
case _:
|
||||
raise ValueError(f"Invalid type: {args['type']}")
|
||||
|
||||
|
||||
api.add_resource(RuleGenerateApi, "/rule-generate")
|
||||
api.add_resource(RuleCodeGenerateApi, "/rule-code-generate")
|
||||
api.add_resource(RuleStructuredOutputGenerateApi, "/rule-structured-output-generate")
|
||||
api.add_resource(InstructionGenerateApi, "/instruction-generate")
|
||||
api.add_resource(InstructionGenerationTemplateApi, "/instruction-generate/template")
|
||||
|
||||
@@ -23,6 +23,7 @@ from core.app.app_config.features.file_upload.manager import FileUploadConfigMan
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.file.models import File
|
||||
from core.helper.trace_id_helper import get_external_trace_id
|
||||
from extensions.ext_database import db
|
||||
from factories import file_factory, variable_factory
|
||||
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
|
||||
@@ -185,6 +186,10 @@ class AdvancedChatDraftWorkflowRunApi(Resource):
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
args["external_trace_id"] = external_trace_id
|
||||
|
||||
try:
|
||||
response = AppGenerateService.generate(
|
||||
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=True
|
||||
@@ -373,6 +378,10 @@ class DraftWorkflowRunApi(Resource):
|
||||
parser.add_argument("files", type=list, required=False, location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
args["external_trace_id"] = external_trace_id
|
||||
|
||||
try:
|
||||
response = AppGenerateService.generate(
|
||||
app_model=app_model,
|
||||
|
||||
@@ -163,11 +163,11 @@ class WorkflowVariableCollectionApi(Resource):
|
||||
draft_var_srv = WorkflowDraftVariableService(
|
||||
session=session,
|
||||
)
|
||||
workflow_vars = draft_var_srv.list_variables_without_values(
|
||||
app_id=app_model.id,
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
)
|
||||
workflow_vars = draft_var_srv.list_variables_without_values(
|
||||
app_id=app_model.id,
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
)
|
||||
|
||||
return workflow_vars
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class VersionApi(Resource):
|
||||
return result
|
||||
|
||||
try:
|
||||
response = requests.get(check_update_url, {"current_version": args.get("current_version")})
|
||||
response = requests.get(check_update_url, {"current_version": args.get("current_version")}, timeout=(3, 10))
|
||||
except Exception as error:
|
||||
logging.warning("Check update version error: %s.", str(error))
|
||||
result["version"] = args.get("current_version")
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import json
|
||||
|
||||
from flask_restful import Resource, marshal_with, reqparse
|
||||
from flask_restful.inputs import int_range
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -136,12 +134,15 @@ class ConversationVariableDetailApi(Resource):
|
||||
variable_id = str(variable_id)
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("value", required=True, location="json")
|
||||
# using lambda is for passing the already-typed value without modification
|
||||
# if no lambda, it will be converted to string
|
||||
# the string cannot be converted using json.loads
|
||||
parser.add_argument("value", required=True, location="json", type=lambda x: x)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
return ConversationService.update_conversation_variable(
|
||||
app_model, conversation_id, variable_id, end_user, json.loads(args["value"])
|
||||
app_model, conversation_id, variable_id, end_user, args["value"]
|
||||
)
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
|
||||
@@ -140,7 +140,9 @@ class ChatAppGenerator(MessageBasedAppGenerator):
|
||||
)
|
||||
|
||||
# get tracing instance
|
||||
trace_manager = TraceQueueManager(app_id=app_model.id)
|
||||
trace_manager = TraceQueueManager(
|
||||
app_id=app_model.id, user_id=user.id if isinstance(user, Account) else user.session_id
|
||||
)
|
||||
|
||||
# init application generate entity
|
||||
application_generate_entity = ChatAppGenerateEntity(
|
||||
|
||||
@@ -124,7 +124,9 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
|
||||
)
|
||||
|
||||
# get tracing instance
|
||||
trace_manager = TraceQueueManager(app_model.id)
|
||||
trace_manager = TraceQueueManager(
|
||||
app_id=app_model.id, user_id=user.id if isinstance(user, Account) else user.session_id
|
||||
)
|
||||
|
||||
# init application generate entity
|
||||
application_generate_entity = CompletionAppGenerateEntity(
|
||||
|
||||
@@ -6,7 +6,6 @@ from core.app.entities.queue_entities import (
|
||||
MessageQueueMessage,
|
||||
QueueAdvancedChatMessageEndEvent,
|
||||
QueueErrorEvent,
|
||||
QueueMessage,
|
||||
QueueMessageEndEvent,
|
||||
QueueStopEvent,
|
||||
)
|
||||
@@ -22,15 +21,6 @@ class MessageBasedAppQueueManager(AppQueueManager):
|
||||
self._app_mode = app_mode
|
||||
self._message_id = str(message_id)
|
||||
|
||||
def construct_queue_message(self, event: AppQueueEvent) -> QueueMessage:
|
||||
return MessageQueueMessage(
|
||||
task_id=self._task_id,
|
||||
message_id=self._message_id,
|
||||
conversation_id=self._conversation_id,
|
||||
app_mode=self._app_mode,
|
||||
event=event,
|
||||
)
|
||||
|
||||
def _publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None:
|
||||
"""
|
||||
Publish event to queue
|
||||
|
||||
@@ -5,7 +5,7 @@ from base64 import b64encode
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from core.variables.utils import SegmentJSONEncoder
|
||||
from core.variables.utils import dumps_with_segments
|
||||
|
||||
|
||||
class TemplateTransformer(ABC):
|
||||
@@ -93,7 +93,7 @@ class TemplateTransformer(ABC):
|
||||
|
||||
@classmethod
|
||||
def serialize_inputs(cls, inputs: Mapping[str, Any]) -> str:
|
||||
inputs_json_str = json.dumps(inputs, ensure_ascii=False, cls=SegmentJSONEncoder).encode()
|
||||
inputs_json_str = dumps_with_segments(inputs, ensure_ascii=False).encode()
|
||||
input_base64_encoded = b64encode(inputs_json_str).decode("utf-8")
|
||||
return input_base64_encoded
|
||||
|
||||
|
||||
@@ -16,15 +16,33 @@ def get_external_trace_id(request: Any) -> Optional[str]:
|
||||
"""
|
||||
Retrieve the trace_id from the request.
|
||||
|
||||
Priority: header ('X-Trace-Id'), then parameters, then JSON body. Returns None if not provided or invalid.
|
||||
Priority:
|
||||
1. header ('X-Trace-Id')
|
||||
2. parameters
|
||||
3. JSON body
|
||||
4. Current OpenTelemetry context (if enabled)
|
||||
5. OpenTelemetry traceparent header (if present and valid)
|
||||
|
||||
Returns None if no valid trace_id is provided.
|
||||
"""
|
||||
trace_id = request.headers.get("X-Trace-Id")
|
||||
|
||||
if not trace_id:
|
||||
trace_id = request.args.get("trace_id")
|
||||
|
||||
if not trace_id and getattr(request, "is_json", False):
|
||||
json_data = getattr(request, "json", None)
|
||||
if json_data:
|
||||
trace_id = json_data.get("trace_id")
|
||||
|
||||
if not trace_id:
|
||||
trace_id = get_trace_id_from_otel_context()
|
||||
|
||||
if not trace_id:
|
||||
traceparent = request.headers.get("traceparent")
|
||||
if traceparent:
|
||||
trace_id = parse_traceparent_header(traceparent)
|
||||
|
||||
if isinstance(trace_id, str) and is_valid_trace_id(trace_id):
|
||||
return trace_id
|
||||
return None
|
||||
@@ -40,3 +58,49 @@ def extract_external_trace_id_from_args(args: Mapping[str, Any]) -> dict:
|
||||
if trace_id:
|
||||
return {"external_trace_id": trace_id}
|
||||
return {}
|
||||
|
||||
|
||||
def get_trace_id_from_otel_context() -> Optional[str]:
|
||||
"""
|
||||
Retrieve the current trace ID from the active OpenTelemetry trace context.
|
||||
Returns None if:
|
||||
1. OpenTelemetry SDK is not installed or enabled.
|
||||
2. There is no active span or trace context.
|
||||
"""
|
||||
try:
|
||||
from opentelemetry.trace import SpanContext, get_current_span
|
||||
from opentelemetry.trace.span import INVALID_TRACE_ID
|
||||
|
||||
span = get_current_span()
|
||||
if not span:
|
||||
return None
|
||||
|
||||
span_context: SpanContext = span.get_span_context()
|
||||
|
||||
if not span_context or span_context.trace_id == INVALID_TRACE_ID:
|
||||
return None
|
||||
|
||||
trace_id_hex = f"{span_context.trace_id:032x}"
|
||||
return trace_id_hex
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def parse_traceparent_header(traceparent: str) -> Optional[str]:
|
||||
"""
|
||||
Parse the `traceparent` header to extract the trace_id.
|
||||
|
||||
Expected format:
|
||||
'version-trace_id-span_id-flags'
|
||||
|
||||
Reference:
|
||||
W3C Trace Context Specification: https://www.w3.org/TR/trace-context/
|
||||
"""
|
||||
try:
|
||||
parts = traceparent.split("-")
|
||||
if len(parts) == 4 and len(parts[1]) == 32:
|
||||
return parts[1]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import Optional, cast
|
||||
|
||||
import json_repair
|
||||
@@ -11,6 +12,8 @@ from core.llm_generator.prompts import (
|
||||
CONVERSATION_TITLE_PROMPT,
|
||||
GENERATOR_QA_PROMPT,
|
||||
JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE,
|
||||
LLM_MODIFY_CODE_SYSTEM,
|
||||
LLM_MODIFY_PROMPT_SYSTEM,
|
||||
PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE,
|
||||
SYSTEM_STRUCTURED_OUTPUT_GENERATE,
|
||||
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
|
||||
@@ -24,6 +27,9 @@ from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.ops.utils import measure_time
|
||||
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
|
||||
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey
|
||||
from core.workflow.graph_engine.entities.event import AgentLogEvent
|
||||
from models import App, Message, WorkflowNodeExecutionModel, db
|
||||
|
||||
|
||||
class LLMGenerator:
|
||||
@@ -388,3 +394,181 @@ class LLMGenerator:
|
||||
except Exception as e:
|
||||
logging.exception("Failed to invoke LLM model, model: %s", model_config.get("name"))
|
||||
return {"output": "", "error": f"An unexpected error occurred: {str(e)}"}
|
||||
|
||||
@staticmethod
|
||||
def instruction_modify_legacy(
|
||||
tenant_id: str, flow_id: str, current: str, instruction: str, model_config: dict, ideal_output: str | None
|
||||
) -> dict:
|
||||
app: App | None = db.session.query(App).filter(App.id == flow_id).first()
|
||||
last_run: Message | None = (
|
||||
db.session.query(Message).filter(Message.app_id == flow_id).order_by(Message.created_at.desc()).first()
|
||||
)
|
||||
if not last_run:
|
||||
return LLMGenerator.__instruction_modify_common(
|
||||
tenant_id=tenant_id,
|
||||
model_config=model_config,
|
||||
last_run=None,
|
||||
current=current,
|
||||
error_message="",
|
||||
instruction=instruction,
|
||||
node_type="llm",
|
||||
ideal_output=ideal_output,
|
||||
)
|
||||
last_run_dict = {
|
||||
"query": last_run.query,
|
||||
"answer": last_run.answer,
|
||||
"error": last_run.error,
|
||||
}
|
||||
return LLMGenerator.__instruction_modify_common(
|
||||
tenant_id=tenant_id,
|
||||
model_config=model_config,
|
||||
last_run=last_run_dict,
|
||||
current=current,
|
||||
error_message=str(last_run.error),
|
||||
instruction=instruction,
|
||||
node_type="llm",
|
||||
ideal_output=ideal_output,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def instruction_modify_workflow(
|
||||
tenant_id: str,
|
||||
flow_id: str,
|
||||
node_id: str,
|
||||
current: str,
|
||||
instruction: str,
|
||||
model_config: dict,
|
||||
ideal_output: str | None,
|
||||
) -> dict:
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
app: App | None = db.session.query(App).filter(App.id == flow_id).first()
|
||||
if not app:
|
||||
raise ValueError("App not found.")
|
||||
workflow = WorkflowService().get_draft_workflow(app_model=app)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not found for the given app model.")
|
||||
last_run = WorkflowService().get_node_last_run(app_model=app, workflow=workflow, node_id=node_id)
|
||||
try:
|
||||
node_type = cast(WorkflowNodeExecutionModel, last_run).node_type
|
||||
except Exception:
|
||||
try:
|
||||
node_type = [it for it in workflow.graph_dict["graph"]["nodes"] if it["id"] == node_id][0]["data"][
|
||||
"type"
|
||||
]
|
||||
except Exception:
|
||||
node_type = "llm"
|
||||
|
||||
if not last_run: # Node is not executed yet
|
||||
return LLMGenerator.__instruction_modify_common(
|
||||
tenant_id=tenant_id,
|
||||
model_config=model_config,
|
||||
last_run=None,
|
||||
current=current,
|
||||
error_message="",
|
||||
instruction=instruction,
|
||||
node_type=node_type,
|
||||
ideal_output=ideal_output,
|
||||
)
|
||||
|
||||
def agent_log_of(node_execution: WorkflowNodeExecutionModel) -> Sequence:
|
||||
raw_agent_log = node_execution.execution_metadata_dict.get(WorkflowNodeExecutionMetadataKey.AGENT_LOG)
|
||||
if not raw_agent_log:
|
||||
return []
|
||||
parsed: Sequence[AgentLogEvent] = json.loads(raw_agent_log)
|
||||
|
||||
def dict_of_event(event: AgentLogEvent) -> dict:
|
||||
return {
|
||||
"status": event.status,
|
||||
"error": event.error,
|
||||
"data": event.data,
|
||||
}
|
||||
|
||||
return [dict_of_event(event) for event in parsed]
|
||||
|
||||
last_run_dict = {
|
||||
"inputs": last_run.inputs_dict,
|
||||
"status": last_run.status,
|
||||
"error": last_run.error,
|
||||
"agent_log": agent_log_of(last_run),
|
||||
}
|
||||
|
||||
return LLMGenerator.__instruction_modify_common(
|
||||
tenant_id=tenant_id,
|
||||
model_config=model_config,
|
||||
last_run=last_run_dict,
|
||||
current=current,
|
||||
error_message=last_run.error,
|
||||
instruction=instruction,
|
||||
node_type=last_run.node_type,
|
||||
ideal_output=ideal_output,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __instruction_modify_common(
|
||||
tenant_id: str,
|
||||
model_config: dict,
|
||||
last_run: dict | None,
|
||||
current: str | None,
|
||||
error_message: str | None,
|
||||
instruction: str,
|
||||
node_type: str,
|
||||
ideal_output: str | None,
|
||||
) -> dict:
|
||||
LAST_RUN = "{{#last_run#}}"
|
||||
CURRENT = "{{#current#}}"
|
||||
ERROR_MESSAGE = "{{#error_message#}}"
|
||||
injected_instruction = instruction
|
||||
if LAST_RUN in injected_instruction:
|
||||
injected_instruction = injected_instruction.replace(LAST_RUN, json.dumps(last_run))
|
||||
if CURRENT in injected_instruction:
|
||||
injected_instruction = injected_instruction.replace(CURRENT, current or "null")
|
||||
if ERROR_MESSAGE in injected_instruction:
|
||||
injected_instruction = injected_instruction.replace(ERROR_MESSAGE, error_message or "null")
|
||||
model_instance = ModelManager().get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
provider=model_config.get("provider", ""),
|
||||
model=model_config.get("name", ""),
|
||||
)
|
||||
match node_type:
|
||||
case "llm", "agent":
|
||||
system_prompt = LLM_MODIFY_PROMPT_SYSTEM
|
||||
case "code":
|
||||
system_prompt = LLM_MODIFY_CODE_SYSTEM
|
||||
case _:
|
||||
system_prompt = LLM_MODIFY_PROMPT_SYSTEM
|
||||
prompt_messages = [
|
||||
SystemPromptMessage(content=system_prompt),
|
||||
UserPromptMessage(
|
||||
content=json.dumps(
|
||||
{
|
||||
"current": current,
|
||||
"last_run": last_run,
|
||||
"instruction": injected_instruction,
|
||||
"ideal_output": ideal_output,
|
||||
}
|
||||
)
|
||||
),
|
||||
]
|
||||
model_parameters = {"temperature": 0.4}
|
||||
|
||||
try:
|
||||
response = cast(
|
||||
LLMResult,
|
||||
model_instance.invoke_llm(
|
||||
prompt_messages=list(prompt_messages), model_parameters=model_parameters, stream=False
|
||||
),
|
||||
)
|
||||
|
||||
generated_raw = cast(str, response.message.content)
|
||||
first_brace = generated_raw.find("{")
|
||||
last_brace = generated_raw.rfind("}")
|
||||
return {**json.loads(generated_raw[first_brace : last_brace + 1])}
|
||||
|
||||
except InvokeError as e:
|
||||
error = str(e)
|
||||
return {"error": f"Failed to generate code. Error: {error}"}
|
||||
except Exception as e:
|
||||
logging.exception("Failed to invoke LLM model, model: " + json.dumps(model_config.get("name")), exc_info=e)
|
||||
return {"error": f"An unexpected error occurred: {str(e)}"}
|
||||
|
||||
@@ -309,3 +309,116 @@ eg:
|
||||
Here is the JSON schema:
|
||||
{{schema}}
|
||||
""" # noqa: E501
|
||||
|
||||
LLM_MODIFY_PROMPT_SYSTEM = """
|
||||
Both your input and output should be in JSON format.
|
||||
|
||||
! Below is the schema for input content !
|
||||
{
|
||||
"type": "object",
|
||||
"description": "The user is trying to process some content with a prompt, but the output is not as expected. They hope to achieve their goal by modifying the prompt.",
|
||||
"properties": {
|
||||
"current": {
|
||||
"type": "string",
|
||||
"description": "The prompt before modification, where placeholders {{}} will be replaced with actual values for the large language model. The content in the placeholders should not be changed."
|
||||
},
|
||||
"last_run": {
|
||||
"type": "object",
|
||||
"description": "The output result from the large language model after receiving the prompt.",
|
||||
},
|
||||
"instruction": {
|
||||
"type": "string",
|
||||
"description": "User's instruction to edit the current prompt"
|
||||
},
|
||||
"ideal_output": {
|
||||
"type": "string",
|
||||
"description": "The ideal output that the user expects from the large language model after modifying the prompt. You should compare the last output with the ideal output and make changes to the prompt to achieve the goal."
|
||||
}
|
||||
}
|
||||
}
|
||||
! Above is the schema for input content !
|
||||
|
||||
! Below is the schema for output content !
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Your feedback to the user after they provide modification suggestions.",
|
||||
"properties": {
|
||||
"modified": {
|
||||
"type": "string",
|
||||
"description": "Your modified prompt. You should change the original prompt as little as possible to achieve the goal. Keep the language of prompt if not asked to change"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Your feedback to the user, in the user's language, explaining what you did and your thought process in text, providing sufficient emotional value to the user."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"modified",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
! Above is the schema for output content !
|
||||
|
||||
Your output must strictly follow the schema format, do not output any content outside of the JSON body.
|
||||
""" # noqa: E501
|
||||
|
||||
LLM_MODIFY_CODE_SYSTEM = """
|
||||
Both your input and output should be in JSON format.
|
||||
|
||||
! Below is the schema for input content !
|
||||
{
|
||||
"type": "object",
|
||||
"description": "The user is trying to process some data with a code snippet, but the result is not as expected. They hope to achieve their goal by modifying the code.",
|
||||
"properties": {
|
||||
"current": {
|
||||
"type": "string",
|
||||
"description": "The code before modification."
|
||||
},
|
||||
"last_run": {
|
||||
"type": "object",
|
||||
"description": "The result of the code.",
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "User's instruction to edit the current code"
|
||||
}
|
||||
}
|
||||
}
|
||||
! Above is the schema for input content !
|
||||
|
||||
! Below is the schema for output content !
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Your feedback to the user after they provide modification suggestions.",
|
||||
"properties": {
|
||||
"modified": {
|
||||
"type": "string",
|
||||
"description": "Your modified code. You should change the original code as little as possible to achieve the goal. Keep the programming language of code if not asked to change"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Your feedback to the user, in the user's language, explaining what you did and your thought process in text, providing sufficient emotional value to the user."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"modified",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
! Above is the schema for output content !
|
||||
|
||||
When you are modifying the code, you should remember:
|
||||
- Do not use print, this not work in dify sandbox.
|
||||
- Do not try dangerous call like deleting files. It's PROHIBITED.
|
||||
- Do not use any library that is not built-in in with Python.
|
||||
- Get inputs from the parameters of the function and have explicit type annotations.
|
||||
- Write proper imports at the top of the code.
|
||||
- Use return statement to return the result.
|
||||
- You should return a `dict`.
|
||||
Your output must strictly follow the schema format, do not output any content outside of the JSON body.
|
||||
""" # noqa: E501
|
||||
|
||||
INSTRUCTION_GENERATE_TEMPLATE_PROMPT = """The output of this prompt is not as expected: {{#last_run#}}.
|
||||
You should edit the prompt according to the IDEAL OUTPUT."""
|
||||
|
||||
INSTRUCTION_GENERATE_TEMPLATE_CODE = """Please fix the errors in the {{#error_message#}}."""
|
||||
|
||||
@@ -10,8 +10,6 @@ from core.mcp.types import (
|
||||
from models.tools import MCPToolProvider
|
||||
from services.tools.mcp_tools_manage_service import MCPToolManageService
|
||||
|
||||
LATEST_PROTOCOL_VERSION = "1.0"
|
||||
|
||||
|
||||
class OAuthClientProvider:
|
||||
mcp_provider: MCPToolProvider
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, TypeAlias, final
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from httpx_sse import EventSource, ServerSentEvent
|
||||
from sseclient import SSEClient
|
||||
|
||||
from core.mcp import types
|
||||
@@ -37,11 +38,6 @@ WriteQueue: TypeAlias = queue.Queue[SessionMessage | Exception | None]
|
||||
StatusQueue: TypeAlias = queue.Queue[_StatusReady | _StatusError]
|
||||
|
||||
|
||||
def remove_request_params(url: str) -> str:
|
||||
"""Remove request parameters from URL, keeping only the path."""
|
||||
return urljoin(url, urlparse(url).path)
|
||||
|
||||
|
||||
class SSETransport:
|
||||
"""SSE client transport implementation."""
|
||||
|
||||
@@ -114,7 +110,7 @@ class SSETransport:
|
||||
logger.exception("Error parsing server message")
|
||||
read_queue.put(exc)
|
||||
|
||||
def _handle_sse_event(self, sse, read_queue: ReadQueue, status_queue: StatusQueue) -> None:
|
||||
def _handle_sse_event(self, sse: ServerSentEvent, read_queue: ReadQueue, status_queue: StatusQueue) -> None:
|
||||
"""Handle a single SSE event.
|
||||
|
||||
Args:
|
||||
@@ -130,7 +126,7 @@ class SSETransport:
|
||||
case _:
|
||||
logger.warning("Unknown SSE event: %s", sse.event)
|
||||
|
||||
def sse_reader(self, event_source, read_queue: ReadQueue, status_queue: StatusQueue) -> None:
|
||||
def sse_reader(self, event_source: EventSource, read_queue: ReadQueue, status_queue: StatusQueue) -> None:
|
||||
"""Read and process SSE events.
|
||||
|
||||
Args:
|
||||
@@ -225,7 +221,7 @@ class SSETransport:
|
||||
self,
|
||||
executor: ThreadPoolExecutor,
|
||||
client: httpx.Client,
|
||||
event_source,
|
||||
event_source: EventSource,
|
||||
) -> tuple[ReadQueue, WriteQueue]:
|
||||
"""Establish connection and start worker threads.
|
||||
|
||||
|
||||
@@ -16,13 +16,14 @@ from extensions.ext_database import db
|
||||
from models.model import App, AppMCPServer, AppMode, EndUser
|
||||
from services.app_generate_service import AppGenerateService
|
||||
|
||||
"""
|
||||
Apply to MCP HTTP streamable server with stateless http
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPServerStreamableHTTPRequestHandler:
|
||||
"""
|
||||
Apply to MCP HTTP streamable server with stateless http
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, app: App, request: types.ClientRequest | types.ClientNotification, user_input_form: list[VariableEntity]
|
||||
):
|
||||
|
||||
+35
-7
@@ -1,6 +1,10 @@
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from contextlib import AbstractContextManager
|
||||
|
||||
import httpx
|
||||
import httpx_sse
|
||||
from httpx_sse import connect_sse
|
||||
|
||||
from configs import dify_config
|
||||
from core.mcp.types import ErrorData, JSONRPCError
|
||||
@@ -55,20 +59,42 @@ def create_ssrf_proxy_mcp_http_client(
|
||||
)
|
||||
|
||||
|
||||
def ssrf_proxy_sse_connect(url, **kwargs):
|
||||
def ssrf_proxy_sse_connect(url: str, **kwargs) -> AbstractContextManager[httpx_sse.EventSource]:
|
||||
"""Connect to SSE endpoint with SSRF proxy protection.
|
||||
|
||||
This function creates an SSE connection using the configured proxy settings
|
||||
to prevent SSRF attacks when connecting to external endpoints.
|
||||
to prevent SSRF attacks when connecting to external endpoints. It returns
|
||||
a context manager that yields an EventSource object for SSE streaming.
|
||||
|
||||
The function handles HTTP client creation and cleanup automatically, but
|
||||
also accepts a pre-configured client via kwargs.
|
||||
|
||||
Args:
|
||||
url: The SSE endpoint URL
|
||||
**kwargs: Additional arguments passed to the SSE connection
|
||||
url (str): The SSE endpoint URL to connect to
|
||||
**kwargs: Additional arguments passed to the SSE connection, including:
|
||||
- client (httpx.Client, optional): Pre-configured HTTP client.
|
||||
If not provided, one will be created with SSRF protection.
|
||||
- method (str, optional): HTTP method to use, defaults to "GET"
|
||||
- headers (dict, optional): HTTP headers to include in the request
|
||||
- timeout (httpx.Timeout, optional): Timeout configuration for the connection
|
||||
|
||||
Returns:
|
||||
EventSource object for SSE streaming
|
||||
AbstractContextManager[httpx_sse.EventSource]: A context manager that yields an EventSource
|
||||
object for SSE streaming. The EventSource provides access to server-sent events.
|
||||
|
||||
Example:
|
||||
```python
|
||||
with ssrf_proxy_sse_connect(url, headers=headers) as event_source:
|
||||
for sse in event_source.iter_sse():
|
||||
print(sse.event, sse.data)
|
||||
```
|
||||
|
||||
Note:
|
||||
If a client is not provided in kwargs, one will be automatically created
|
||||
with SSRF protection based on the application's configuration. If an
|
||||
exception occurs during connection, any automatically created client
|
||||
will be cleaned up automatically.
|
||||
"""
|
||||
from httpx_sse import connect_sse
|
||||
|
||||
# Extract client if provided, otherwise create one
|
||||
client = kwargs.pop("client", None)
|
||||
@@ -101,7 +127,9 @@ def ssrf_proxy_sse_connect(url, **kwargs):
|
||||
raise
|
||||
|
||||
|
||||
def create_mcp_error_response(request_id: int | str | None, code: int, message: str, data=None):
|
||||
def create_mcp_error_response(
|
||||
request_id: int | str | None, code: int, message: str, data=None
|
||||
) -> Generator[bytes, None, None]:
|
||||
"""Create MCP error response"""
|
||||
error_data = ErrorData(code=code, message=message, data=data)
|
||||
json_response = JSONRPCError(
|
||||
|
||||
@@ -151,12 +151,9 @@ def jsonable_encoder(
|
||||
return format(obj, "f")
|
||||
if isinstance(obj, dict):
|
||||
encoded_dict = {}
|
||||
allowed_keys = set(obj.keys())
|
||||
for key, value in obj.items():
|
||||
if (
|
||||
(not sqlalchemy_safe or (not isinstance(key, str)) or (not key.startswith("_sa")))
|
||||
and (value is not None or not exclude_none)
|
||||
and key in allowed_keys
|
||||
if (not sqlalchemy_safe or (not isinstance(key, str)) or (not key.startswith("_sa"))) and (
|
||||
value is not None or not exclude_none
|
||||
):
|
||||
encoded_key = jsonable_encoder(
|
||||
key,
|
||||
|
||||
@@ -4,15 +4,15 @@ from collections.abc import Sequence
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
from opentelemetry.trace import Link, Status, StatusCode
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.ops.aliyun_trace.data_exporter.traceclient import (
|
||||
TraceClient,
|
||||
convert_datetime_to_nanoseconds,
|
||||
convert_string_to_id,
|
||||
convert_to_span_id,
|
||||
convert_to_trace_id,
|
||||
create_link,
|
||||
generate_span_id,
|
||||
)
|
||||
from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData
|
||||
@@ -103,10 +103,11 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
|
||||
def workflow_trace(self, trace_info: WorkflowTraceInfo):
|
||||
trace_id = convert_to_trace_id(trace_info.workflow_run_id)
|
||||
links = []
|
||||
if trace_info.trace_id:
|
||||
trace_id = convert_string_to_id(trace_info.trace_id)
|
||||
links.append(create_link(trace_id_str=trace_info.trace_id))
|
||||
workflow_span_id = convert_to_span_id(trace_info.workflow_run_id, "workflow")
|
||||
self.add_workflow_span(trace_id, workflow_span_id, trace_info)
|
||||
self.add_workflow_span(trace_id, workflow_span_id, trace_info, links)
|
||||
|
||||
workflow_node_executions = self.get_workflow_node_executions(trace_info)
|
||||
for node_execution in workflow_node_executions:
|
||||
@@ -132,8 +133,9 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
status = Status(StatusCode.ERROR, trace_info.error)
|
||||
|
||||
trace_id = convert_to_trace_id(message_id)
|
||||
links = []
|
||||
if trace_info.trace_id:
|
||||
trace_id = convert_string_to_id(trace_info.trace_id)
|
||||
links.append(create_link(trace_id_str=trace_info.trace_id))
|
||||
|
||||
message_span_id = convert_to_span_id(message_id, "message")
|
||||
message_span = SpanData(
|
||||
@@ -152,6 +154,7 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
OUTPUT_VALUE: str(trace_info.outputs),
|
||||
},
|
||||
status=status,
|
||||
links=links,
|
||||
)
|
||||
self.trace_client.add_span(message_span)
|
||||
|
||||
@@ -192,8 +195,9 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
message_id = trace_info.message_id
|
||||
|
||||
trace_id = convert_to_trace_id(message_id)
|
||||
links = []
|
||||
if trace_info.trace_id:
|
||||
trace_id = convert_string_to_id(trace_info.trace_id)
|
||||
links.append(create_link(trace_id_str=trace_info.trace_id))
|
||||
|
||||
documents_data = extract_retrieval_documents(trace_info.documents)
|
||||
dataset_retrieval_span = SpanData(
|
||||
@@ -211,6 +215,7 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
INPUT_VALUE: str(trace_info.inputs),
|
||||
OUTPUT_VALUE: json.dumps(documents_data, ensure_ascii=False),
|
||||
},
|
||||
links=links,
|
||||
)
|
||||
self.trace_client.add_span(dataset_retrieval_span)
|
||||
|
||||
@@ -224,8 +229,9 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
status = Status(StatusCode.ERROR, trace_info.error)
|
||||
|
||||
trace_id = convert_to_trace_id(message_id)
|
||||
links = []
|
||||
if trace_info.trace_id:
|
||||
trace_id = convert_string_to_id(trace_info.trace_id)
|
||||
links.append(create_link(trace_id_str=trace_info.trace_id))
|
||||
|
||||
tool_span = SpanData(
|
||||
trace_id=trace_id,
|
||||
@@ -244,6 +250,7 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
OUTPUT_VALUE: str(trace_info.tool_outputs),
|
||||
},
|
||||
status=status,
|
||||
links=links,
|
||||
)
|
||||
self.trace_client.add_span(tool_span)
|
||||
|
||||
@@ -413,7 +420,9 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
status=self.get_workflow_node_status(node_execution),
|
||||
)
|
||||
|
||||
def add_workflow_span(self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo):
|
||||
def add_workflow_span(
|
||||
self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, links: Sequence[Link]
|
||||
):
|
||||
message_span_id = None
|
||||
if trace_info.message_id:
|
||||
message_span_id = convert_to_span_id(trace_info.message_id, "message")
|
||||
@@ -438,6 +447,7 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False),
|
||||
},
|
||||
status=status,
|
||||
links=links,
|
||||
)
|
||||
self.trace_client.add_span(message_span)
|
||||
|
||||
@@ -456,6 +466,7 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False),
|
||||
},
|
||||
status=status,
|
||||
links=links,
|
||||
)
|
||||
self.trace_client.add_span(workflow_span)
|
||||
|
||||
@@ -466,8 +477,9 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
status = Status(StatusCode.ERROR, trace_info.error)
|
||||
|
||||
trace_id = convert_to_trace_id(message_id)
|
||||
links = []
|
||||
if trace_info.trace_id:
|
||||
trace_id = convert_string_to_id(trace_info.trace_id)
|
||||
links.append(create_link(trace_id_str=trace_info.trace_id))
|
||||
|
||||
suggested_question_span = SpanData(
|
||||
trace_id=trace_id,
|
||||
@@ -487,6 +499,7 @@ class AliyunDataTrace(BaseTraceInstance):
|
||||
OUTPUT_VALUE: json.dumps(trace_info.suggested_question, ensure_ascii=False),
|
||||
},
|
||||
status=status,
|
||||
links=links,
|
||||
)
|
||||
self.trace_client.add_span(suggested_question_span)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import ReadableSpan
|
||||
from opentelemetry.sdk.util.instrumentation import InstrumentationScope
|
||||
from opentelemetry.semconv.resource import ResourceAttributes
|
||||
from opentelemetry.trace import Link, SpanContext, TraceFlags
|
||||
|
||||
from configs import dify_config
|
||||
from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData
|
||||
@@ -166,6 +167,16 @@ class SpanBuilder:
|
||||
return span
|
||||
|
||||
|
||||
def create_link(trace_id_str: str) -> Link:
|
||||
placeholder_span_id = 0x0000000000000000
|
||||
trace_id = int(trace_id_str, 16)
|
||||
span_context = SpanContext(
|
||||
trace_id=trace_id, span_id=placeholder_span_id, is_remote=False, trace_flags=TraceFlags(TraceFlags.SAMPLED)
|
||||
)
|
||||
|
||||
return Link(span_context)
|
||||
|
||||
|
||||
def generate_span_id() -> int:
|
||||
span_id = random.getrandbits(64)
|
||||
while span_id == INVALID_SPAN_ID:
|
||||
|
||||
@@ -523,7 +523,7 @@ class ProviderManager:
|
||||
# Init trial provider records if not exists
|
||||
if ProviderQuotaType.TRIAL not in provider_quota_to_provider_record_dict:
|
||||
try:
|
||||
# FIXME ignore the type errork, onyl TrialHostingQuota has limit need to change the logic
|
||||
# FIXME ignore the type error, only TrialHostingQuota has limit need to change the logic
|
||||
new_provider_record = Provider(
|
||||
tenant_id=tenant_id,
|
||||
# TODO: Use provider name with prefix after the data migration.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from typing import Any, Optional
|
||||
|
||||
import orjson
|
||||
from pydantic import BaseModel
|
||||
|
||||
from configs import dify_config
|
||||
@@ -134,13 +134,13 @@ class Jieba(BaseKeyword):
|
||||
dataset_keyword_table = self.dataset.dataset_keyword_table
|
||||
keyword_data_source_type = dataset_keyword_table.data_source_type
|
||||
if keyword_data_source_type == "database":
|
||||
dataset_keyword_table.keyword_table = json.dumps(keyword_table_dict, cls=SetEncoder)
|
||||
dataset_keyword_table.keyword_table = dumps_with_sets(keyword_table_dict)
|
||||
db.session.commit()
|
||||
else:
|
||||
file_key = "keyword_files/" + self.dataset.tenant_id + "/" + self.dataset.id + ".txt"
|
||||
if storage.exists(file_key):
|
||||
storage.delete(file_key)
|
||||
storage.save(file_key, json.dumps(keyword_table_dict, cls=SetEncoder).encode("utf-8"))
|
||||
storage.save(file_key, dumps_with_sets(keyword_table_dict).encode("utf-8"))
|
||||
|
||||
def _get_dataset_keyword_table(self) -> Optional[dict]:
|
||||
dataset_keyword_table = self.dataset.dataset_keyword_table
|
||||
@@ -156,12 +156,11 @@ class Jieba(BaseKeyword):
|
||||
data_source_type=keyword_data_source_type,
|
||||
)
|
||||
if keyword_data_source_type == "database":
|
||||
dataset_keyword_table.keyword_table = json.dumps(
|
||||
dataset_keyword_table.keyword_table = dumps_with_sets(
|
||||
{
|
||||
"__type__": "keyword_table",
|
||||
"__data__": {"index_id": self.dataset.id, "summary": None, "table": {}},
|
||||
},
|
||||
cls=SetEncoder,
|
||||
}
|
||||
)
|
||||
db.session.add(dataset_keyword_table)
|
||||
db.session.commit()
|
||||
@@ -252,8 +251,13 @@ class Jieba(BaseKeyword):
|
||||
self._save_dataset_keyword_table(keyword_table)
|
||||
|
||||
|
||||
class SetEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, set):
|
||||
return list(obj)
|
||||
return super().default(obj)
|
||||
def set_orjson_default(obj: Any) -> Any:
|
||||
"""Default function for orjson serialization of set types"""
|
||||
if isinstance(obj, set):
|
||||
return list(obj)
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
def dumps_with_sets(obj: Any) -> str:
|
||||
"""JSON dumps with set support using orjson"""
|
||||
return orjson.dumps(obj, default=set_orjson_default).decode("utf-8")
|
||||
|
||||
@@ -4,8 +4,8 @@ import math
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
from pyobvector import VECTOR, ObVecClient # type: ignore
|
||||
from sqlalchemy import JSON, Column, String, func
|
||||
from pyobvector import VECTOR, FtsIndexParam, FtsParser, ObVecClient, l2_distance # type: ignore
|
||||
from sqlalchemy import JSON, Column, String
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
from configs import dify_config
|
||||
@@ -119,14 +119,21 @@ class OceanBaseVector(BaseVector):
|
||||
)
|
||||
try:
|
||||
if self._hybrid_search_enabled:
|
||||
self._client.perform_raw_text_sql(f"""ALTER TABLE {self._collection_name}
|
||||
ADD FULLTEXT INDEX fulltext_index_for_col_text (text) WITH PARSER ik""")
|
||||
self._client.create_fts_idx_with_fts_index_param(
|
||||
table_name=self._collection_name,
|
||||
fts_idx_param=FtsIndexParam(
|
||||
index_name="fulltext_index_for_col_text",
|
||||
field_names=["text"],
|
||||
parser_type=FtsParser.IK,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
"Failed to add fulltext index to the target table, your OceanBase version must be 4.3.5.1 or above "
|
||||
+ "to support fulltext index and vector index in the same table",
|
||||
e,
|
||||
)
|
||||
self._client.refresh_metadata([self._collection_name])
|
||||
redis_client.set(collection_exist_cache_key, 1, ex=3600)
|
||||
|
||||
def _check_hybrid_search_support(self) -> bool:
|
||||
@@ -252,7 +259,7 @@ class OceanBaseVector(BaseVector):
|
||||
vec_column_name="vector",
|
||||
vec_data=query_vector,
|
||||
topk=topk,
|
||||
distance_func=func.l2_distance,
|
||||
distance_func=l2_distance,
|
||||
output_column_names=["text", "metadata"],
|
||||
with_dist=True,
|
||||
where_clause=_where_clause,
|
||||
|
||||
@@ -331,6 +331,12 @@ class QdrantVector(BaseVector):
|
||||
def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]:
|
||||
from qdrant_client.http import models
|
||||
|
||||
score_threshold = float(kwargs.get("score_threshold") or 0.0)
|
||||
if score_threshold >= 1:
|
||||
# return empty list because some versions of qdrant may response with 400 bad request,
|
||||
# and at the same time, the score_threshold with value 1 may be valid for other vector stores
|
||||
return []
|
||||
|
||||
filter = models.Filter(
|
||||
must=[
|
||||
models.FieldCondition(
|
||||
@@ -355,7 +361,7 @@ class QdrantVector(BaseVector):
|
||||
limit=kwargs.get("top_k", 4),
|
||||
with_payload=True,
|
||||
with_vectors=True,
|
||||
score_threshold=float(kwargs.get("score_threshold") or 0.0),
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
docs = []
|
||||
for result in results:
|
||||
@@ -363,7 +369,6 @@ class QdrantVector(BaseVector):
|
||||
continue
|
||||
metadata = result.payload.get(Field.METADATA_KEY.value) or {}
|
||||
# duplicate check score threshold
|
||||
score_threshold = float(kwargs.get("score_threshold") or 0.0)
|
||||
if result.score > score_threshold:
|
||||
metadata["score"] = result.score
|
||||
doc = Document(
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -108,10 +108,18 @@ class ApiProviderAuthType(Enum):
|
||||
:param value: mode value
|
||||
:return: mode
|
||||
"""
|
||||
# 'api_key' deprecated in PR #21656
|
||||
# normalize & tiny alias for backward compatibility
|
||||
v = (value or "").strip().lower()
|
||||
if v == "api_key":
|
||||
v = cls.API_KEY_HEADER.value
|
||||
|
||||
for mode in cls:
|
||||
if mode.value == value:
|
||||
if mode.value == v:
|
||||
return mode
|
||||
raise ValueError(f"invalid mode value {value}")
|
||||
|
||||
valid = ", ".join(m.value for m in cls)
|
||||
raise ValueError(f"invalid mode value '{value}', expected one of: {valid}")
|
||||
|
||||
|
||||
class ToolInvokeMessage(BaseModel):
|
||||
|
||||
+20
-13
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
from collections.abc import Iterable, Sequence
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
|
||||
from .segment_group import SegmentGroup
|
||||
from .segments import ArrayFileSegment, FileSegment, Segment
|
||||
@@ -12,15 +14,20 @@ def to_selector(node_id: str, name: str, paths: Iterable[str] = ()) -> Sequence[
|
||||
return selectors
|
||||
|
||||
|
||||
class SegmentJSONEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, ArrayFileSegment):
|
||||
return [v.model_dump() for v in o.value]
|
||||
elif isinstance(o, FileSegment):
|
||||
return o.value.model_dump()
|
||||
elif isinstance(o, SegmentGroup):
|
||||
return [self.default(seg) for seg in o.value]
|
||||
elif isinstance(o, Segment):
|
||||
return o.value
|
||||
else:
|
||||
super().default(o)
|
||||
def segment_orjson_default(o: Any) -> Any:
|
||||
"""Default function for orjson serialization of Segment types"""
|
||||
if isinstance(o, ArrayFileSegment):
|
||||
return [v.model_dump() for v in o.value]
|
||||
elif isinstance(o, FileSegment):
|
||||
return o.value.model_dump()
|
||||
elif isinstance(o, SegmentGroup):
|
||||
return [segment_orjson_default(seg) for seg in o.value]
|
||||
elif isinstance(o, Segment):
|
||||
return o.value
|
||||
raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
def dumps_with_segments(obj: Any, ensure_ascii: bool = False) -> str:
|
||||
"""JSON dumps with segment support using orjson"""
|
||||
option = orjson.OPT_NON_STR_KEYS
|
||||
return orjson.dumps(obj, default=segment_orjson_default, option=option).decode("utf-8")
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, ModelConfigWithCredentialsEntity
|
||||
from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity
|
||||
from core.file import FileType, file_manager
|
||||
from core.helper.code_executor import CodeExecutor, CodeLanguage
|
||||
from core.llm_generator.output_parser.errors import OutputParserError
|
||||
@@ -194,17 +194,6 @@ class LLMNode(BaseNode):
|
||||
else []
|
||||
)
|
||||
|
||||
# single step run fetch file from sys files
|
||||
if not files and self.invoke_from == InvokeFrom.DEBUGGER and not self.previous_node_id:
|
||||
files = (
|
||||
llm_utils.fetch_files(
|
||||
variable_pool=variable_pool,
|
||||
selector=["sys", "files"],
|
||||
)
|
||||
if self._node_data.vision.enabled
|
||||
else []
|
||||
)
|
||||
|
||||
if files:
|
||||
node_inputs["#files#"] = [file.to_dict() for file in files]
|
||||
|
||||
|
||||
@@ -318,33 +318,6 @@ class ToolNode(BaseNode):
|
||||
json.append(message.message.json_object)
|
||||
elif message.type == ToolInvokeMessage.MessageType.LINK:
|
||||
assert isinstance(message.message, ToolInvokeMessage.TextMessage)
|
||||
|
||||
if message.meta:
|
||||
transfer_method = message.meta.get("transfer_method", FileTransferMethod.TOOL_FILE)
|
||||
else:
|
||||
transfer_method = FileTransferMethod.TOOL_FILE
|
||||
|
||||
tool_file_id = message.message.text.split("/")[-1].split(".")[0]
|
||||
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ToolFile).where(ToolFile.id == tool_file_id)
|
||||
tool_file = session.scalar(stmt)
|
||||
if tool_file is None:
|
||||
raise ToolFileError(f"Tool file {tool_file_id} does not exist")
|
||||
|
||||
mapping = {
|
||||
"tool_file_id": tool_file_id,
|
||||
"type": file_factory.get_file_type_by_mime_type(tool_file.mimetype),
|
||||
"transfer_method": transfer_method,
|
||||
"url": message.message.text,
|
||||
}
|
||||
|
||||
file = file_factory.build_from_mapping(
|
||||
mapping=mapping,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
files.append(file)
|
||||
|
||||
stream_text = f"Link: {message.message.text}\n"
|
||||
text += stream_text
|
||||
yield RunStreamChunkEvent(chunk_content=stream_text, from_variable_selector=[node_id, "text"])
|
||||
|
||||
@@ -5,7 +5,7 @@ import click
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from core.indexing_runner import DocumentIsPausedError, IndexingRunner
|
||||
from events.event_handlers.document_index_event import document_index_created
|
||||
from events.document_index_event import document_index_created
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.dataset import Document
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ssl
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytz
|
||||
from celery import Celery, Task # type: ignore
|
||||
@@ -8,6 +10,40 @@ from configs import dify_config
|
||||
from dify_app import DifyApp
|
||||
|
||||
|
||||
def _get_celery_ssl_options() -> Optional[dict[str, Any]]:
|
||||
"""Get SSL configuration for Celery broker/backend connections."""
|
||||
# Use REDIS_USE_SSL for consistency with the main Redis client
|
||||
# Only apply SSL if we're using Redis as broker/backend
|
||||
if not dify_config.REDIS_USE_SSL:
|
||||
return None
|
||||
|
||||
# Check if Celery is actually using Redis
|
||||
broker_is_redis = dify_config.CELERY_BROKER_URL and (
|
||||
dify_config.CELERY_BROKER_URL.startswith("redis://") or dify_config.CELERY_BROKER_URL.startswith("rediss://")
|
||||
)
|
||||
|
||||
if not broker_is_redis:
|
||||
return None
|
||||
|
||||
# Map certificate requirement strings to SSL constants
|
||||
cert_reqs_map = {
|
||||
"CERT_NONE": ssl.CERT_NONE,
|
||||
"CERT_OPTIONAL": ssl.CERT_OPTIONAL,
|
||||
"CERT_REQUIRED": ssl.CERT_REQUIRED,
|
||||
}
|
||||
|
||||
ssl_cert_reqs = cert_reqs_map.get(dify_config.REDIS_SSL_CERT_REQS, ssl.CERT_NONE)
|
||||
|
||||
ssl_options = {
|
||||
"ssl_cert_reqs": ssl_cert_reqs,
|
||||
"ssl_ca_certs": dify_config.REDIS_SSL_CA_CERTS,
|
||||
"ssl_certfile": dify_config.REDIS_SSL_CERTFILE,
|
||||
"ssl_keyfile": dify_config.REDIS_SSL_KEYFILE,
|
||||
}
|
||||
|
||||
return ssl_options
|
||||
|
||||
|
||||
def init_app(app: DifyApp) -> Celery:
|
||||
class FlaskTask(Task):
|
||||
def __call__(self, *args: object, **kwargs: object) -> object:
|
||||
@@ -33,14 +69,6 @@ def init_app(app: DifyApp) -> Celery:
|
||||
task_ignore_result=True,
|
||||
)
|
||||
|
||||
# Add SSL options to the Celery configuration
|
||||
ssl_options = {
|
||||
"ssl_cert_reqs": None,
|
||||
"ssl_ca_certs": None,
|
||||
"ssl_certfile": None,
|
||||
"ssl_keyfile": None,
|
||||
}
|
||||
|
||||
celery_app.conf.update(
|
||||
result_backend=dify_config.CELERY_RESULT_BACKEND,
|
||||
broker_transport_options=broker_transport_options,
|
||||
@@ -51,9 +79,13 @@ def init_app(app: DifyApp) -> Celery:
|
||||
timezone=pytz.timezone(dify_config.LOG_TZ or "UTC"),
|
||||
)
|
||||
|
||||
if dify_config.BROKER_USE_SSL:
|
||||
# Apply SSL configuration if enabled
|
||||
ssl_options = _get_celery_ssl_options()
|
||||
if ssl_options:
|
||||
celery_app.conf.update(
|
||||
broker_use_ssl=ssl_options, # Add the SSL options to the broker configuration
|
||||
broker_use_ssl=ssl_options,
|
||||
# Also apply SSL to the backend if it's Redis
|
||||
redis_backend_use_ssl=ssl_options if dify_config.CELERY_BACKEND == "redis" else None,
|
||||
)
|
||||
|
||||
if dify_config.LOG_FILE:
|
||||
@@ -113,7 +145,7 @@ def init_app(app: DifyApp) -> Celery:
|
||||
minutes=dify_config.QUEUE_MONITOR_INTERVAL if dify_config.QUEUE_MONITOR_INTERVAL else 30
|
||||
),
|
||||
}
|
||||
if dify_config.ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK:
|
||||
if dify_config.ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK and dify_config.MARKETPLACE_ENABLED:
|
||||
imports.append("schedule.check_upgradable_plugin_task")
|
||||
beat_schedule["check_upgradable_plugin_task"] = {
|
||||
"task": "schedule.check_upgradable_plugin_task.check_upgradable_plugin_task",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from flask_orjson import OrjsonProvider
|
||||
|
||||
from dify_app import DifyApp
|
||||
|
||||
|
||||
def init_app(app: DifyApp) -> None:
|
||||
"""Initialize Flask-Orjson extension for faster JSON serialization"""
|
||||
app.json = OrjsonProvider(app)
|
||||
+118
-61
@@ -1,5 +1,6 @@
|
||||
import functools
|
||||
import logging
|
||||
import ssl
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any, Union
|
||||
@@ -116,76 +117,132 @@ class RedisClientWrapper:
|
||||
redis_client: RedisClientWrapper = RedisClientWrapper()
|
||||
|
||||
|
||||
def init_app(app: DifyApp):
|
||||
global redis_client
|
||||
connection_class: type[Union[Connection, SSLConnection]] = Connection
|
||||
if dify_config.REDIS_USE_SSL:
|
||||
connection_class = SSLConnection
|
||||
resp_protocol = dify_config.REDIS_SERIALIZATION_PROTOCOL
|
||||
if dify_config.REDIS_ENABLE_CLIENT_SIDE_CACHE:
|
||||
if resp_protocol >= 3:
|
||||
clientside_cache_config = CacheConfig()
|
||||
else:
|
||||
raise ValueError("Client side cache is only supported in RESP3")
|
||||
else:
|
||||
clientside_cache_config = None
|
||||
def _get_ssl_configuration() -> tuple[type[Union[Connection, SSLConnection]], dict[str, Any]]:
|
||||
"""Get SSL configuration for Redis connection."""
|
||||
if not dify_config.REDIS_USE_SSL:
|
||||
return Connection, {}
|
||||
|
||||
redis_params: dict[str, Any] = {
|
||||
cert_reqs_map = {
|
||||
"CERT_NONE": ssl.CERT_NONE,
|
||||
"CERT_OPTIONAL": ssl.CERT_OPTIONAL,
|
||||
"CERT_REQUIRED": ssl.CERT_REQUIRED,
|
||||
}
|
||||
ssl_cert_reqs = cert_reqs_map.get(dify_config.REDIS_SSL_CERT_REQS, ssl.CERT_NONE)
|
||||
|
||||
ssl_kwargs = {
|
||||
"ssl_cert_reqs": ssl_cert_reqs,
|
||||
"ssl_ca_certs": dify_config.REDIS_SSL_CA_CERTS,
|
||||
"ssl_certfile": dify_config.REDIS_SSL_CERTFILE,
|
||||
"ssl_keyfile": dify_config.REDIS_SSL_KEYFILE,
|
||||
}
|
||||
|
||||
return SSLConnection, ssl_kwargs
|
||||
|
||||
|
||||
def _get_cache_configuration() -> CacheConfig | None:
|
||||
"""Get client-side cache configuration if enabled."""
|
||||
if not dify_config.REDIS_ENABLE_CLIENT_SIDE_CACHE:
|
||||
return None
|
||||
|
||||
resp_protocol = dify_config.REDIS_SERIALIZATION_PROTOCOL
|
||||
if resp_protocol < 3:
|
||||
raise ValueError("Client side cache is only supported in RESP3")
|
||||
|
||||
return CacheConfig()
|
||||
|
||||
|
||||
def _get_base_redis_params() -> dict[str, Any]:
|
||||
"""Get base Redis connection parameters."""
|
||||
return {
|
||||
"username": dify_config.REDIS_USERNAME,
|
||||
"password": dify_config.REDIS_PASSWORD or None, # Temporary fix for empty password
|
||||
"password": dify_config.REDIS_PASSWORD or None,
|
||||
"db": dify_config.REDIS_DB,
|
||||
"encoding": "utf-8",
|
||||
"encoding_errors": "strict",
|
||||
"decode_responses": False,
|
||||
"protocol": resp_protocol,
|
||||
"cache_config": clientside_cache_config,
|
||||
"protocol": dify_config.REDIS_SERIALIZATION_PROTOCOL,
|
||||
"cache_config": _get_cache_configuration(),
|
||||
}
|
||||
|
||||
if dify_config.REDIS_USE_SENTINEL:
|
||||
assert dify_config.REDIS_SENTINELS is not None, "REDIS_SENTINELS must be set when REDIS_USE_SENTINEL is True"
|
||||
assert dify_config.REDIS_SENTINEL_SERVICE_NAME is not None, (
|
||||
"REDIS_SENTINEL_SERVICE_NAME must be set when REDIS_USE_SENTINEL is True"
|
||||
)
|
||||
sentinel_hosts = [
|
||||
(node.split(":")[0], int(node.split(":")[1])) for node in dify_config.REDIS_SENTINELS.split(",")
|
||||
]
|
||||
sentinel = Sentinel(
|
||||
sentinel_hosts,
|
||||
sentinel_kwargs={
|
||||
"socket_timeout": dify_config.REDIS_SENTINEL_SOCKET_TIMEOUT,
|
||||
"username": dify_config.REDIS_SENTINEL_USERNAME,
|
||||
"password": dify_config.REDIS_SENTINEL_PASSWORD,
|
||||
},
|
||||
)
|
||||
master = sentinel.master_for(dify_config.REDIS_SENTINEL_SERVICE_NAME, **redis_params)
|
||||
redis_client.initialize(master)
|
||||
elif dify_config.REDIS_USE_CLUSTERS:
|
||||
assert dify_config.REDIS_CLUSTERS is not None, "REDIS_CLUSTERS must be set when REDIS_USE_CLUSTERS is True"
|
||||
nodes = [
|
||||
ClusterNode(host=node.split(":")[0], port=int(node.split(":")[1]))
|
||||
for node in dify_config.REDIS_CLUSTERS.split(",")
|
||||
]
|
||||
redis_client.initialize(
|
||||
RedisCluster(
|
||||
startup_nodes=nodes,
|
||||
password=dify_config.REDIS_CLUSTERS_PASSWORD,
|
||||
protocol=resp_protocol,
|
||||
cache_config=clientside_cache_config,
|
||||
)
|
||||
)
|
||||
else:
|
||||
redis_params.update(
|
||||
{
|
||||
"host": dify_config.REDIS_HOST,
|
||||
"port": dify_config.REDIS_PORT,
|
||||
"connection_class": connection_class,
|
||||
"protocol": resp_protocol,
|
||||
"cache_config": clientside_cache_config,
|
||||
}
|
||||
)
|
||||
pool = redis.ConnectionPool(**redis_params)
|
||||
redis_client.initialize(redis.Redis(connection_pool=pool))
|
||||
|
||||
def _create_sentinel_client(redis_params: dict[str, Any]) -> Union[redis.Redis, RedisCluster]:
|
||||
"""Create Redis client using Sentinel configuration."""
|
||||
if not dify_config.REDIS_SENTINELS:
|
||||
raise ValueError("REDIS_SENTINELS must be set when REDIS_USE_SENTINEL is True")
|
||||
|
||||
if not dify_config.REDIS_SENTINEL_SERVICE_NAME:
|
||||
raise ValueError("REDIS_SENTINEL_SERVICE_NAME must be set when REDIS_USE_SENTINEL is True")
|
||||
|
||||
sentinel_hosts = [(node.split(":")[0], int(node.split(":")[1])) for node in dify_config.REDIS_SENTINELS.split(",")]
|
||||
|
||||
sentinel = Sentinel(
|
||||
sentinel_hosts,
|
||||
sentinel_kwargs={
|
||||
"socket_timeout": dify_config.REDIS_SENTINEL_SOCKET_TIMEOUT,
|
||||
"username": dify_config.REDIS_SENTINEL_USERNAME,
|
||||
"password": dify_config.REDIS_SENTINEL_PASSWORD,
|
||||
},
|
||||
)
|
||||
|
||||
master: redis.Redis = sentinel.master_for(dify_config.REDIS_SENTINEL_SERVICE_NAME, **redis_params)
|
||||
return master
|
||||
|
||||
|
||||
def _create_cluster_client() -> Union[redis.Redis, RedisCluster]:
|
||||
"""Create Redis cluster client."""
|
||||
if not dify_config.REDIS_CLUSTERS:
|
||||
raise ValueError("REDIS_CLUSTERS must be set when REDIS_USE_CLUSTERS is True")
|
||||
|
||||
nodes = [
|
||||
ClusterNode(host=node.split(":")[0], port=int(node.split(":")[1]))
|
||||
for node in dify_config.REDIS_CLUSTERS.split(",")
|
||||
]
|
||||
|
||||
cluster: RedisCluster = RedisCluster(
|
||||
startup_nodes=nodes,
|
||||
password=dify_config.REDIS_CLUSTERS_PASSWORD,
|
||||
protocol=dify_config.REDIS_SERIALIZATION_PROTOCOL,
|
||||
cache_config=_get_cache_configuration(),
|
||||
)
|
||||
return cluster
|
||||
|
||||
|
||||
def _create_standalone_client(redis_params: dict[str, Any]) -> Union[redis.Redis, RedisCluster]:
|
||||
"""Create standalone Redis client."""
|
||||
connection_class, ssl_kwargs = _get_ssl_configuration()
|
||||
|
||||
redis_params.update(
|
||||
{
|
||||
"host": dify_config.REDIS_HOST,
|
||||
"port": dify_config.REDIS_PORT,
|
||||
"connection_class": connection_class,
|
||||
}
|
||||
)
|
||||
|
||||
if ssl_kwargs:
|
||||
redis_params.update(ssl_kwargs)
|
||||
|
||||
pool = redis.ConnectionPool(**redis_params)
|
||||
client: redis.Redis = redis.Redis(connection_pool=pool)
|
||||
return client
|
||||
|
||||
|
||||
def init_app(app: DifyApp):
|
||||
"""Initialize Redis client and attach it to the app."""
|
||||
global redis_client
|
||||
|
||||
# Determine Redis mode and create appropriate client
|
||||
if dify_config.REDIS_USE_SENTINEL:
|
||||
redis_params = _get_base_redis_params()
|
||||
client = _create_sentinel_client(redis_params)
|
||||
elif dify_config.REDIS_USE_CLUSTERS:
|
||||
client = _create_cluster_client()
|
||||
else:
|
||||
redis_params = _get_base_redis_params()
|
||||
client = _create_standalone_client(redis_params)
|
||||
|
||||
# Initialize the wrapper and attach to app
|
||||
redis_client.initialize(client)
|
||||
app.extensions["redis"] = redis_client
|
||||
|
||||
|
||||
|
||||
@@ -1153,7 +1153,7 @@ class WorkflowDraftVariable(Base):
|
||||
value: The Segment object to store as the variable's value.
|
||||
"""
|
||||
self.__value = value
|
||||
self.value = json.dumps(value, cls=variable_utils.SegmentJSONEncoder)
|
||||
self.value = variable_utils.dumps_with_segments(value)
|
||||
self.value_type = value.value_type
|
||||
|
||||
def get_node_id(self) -> str | None:
|
||||
|
||||
+1
-2
@@ -5,8 +5,7 @@ check_untyped_defs = True
|
||||
cache_fine_grained = True
|
||||
sqlite_cache = True
|
||||
exclude = (?x)(
|
||||
core/model_runtime/model_providers/
|
||||
| tests/
|
||||
tests/
|
||||
| migrations/
|
||||
)
|
||||
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"flask-cors~=6.0.0",
|
||||
"flask-login~=0.6.3",
|
||||
"flask-migrate~=4.0.7",
|
||||
"flask-orjson~=2.0.0",
|
||||
"flask-restful~=0.3.10",
|
||||
"flask-sqlalchemy~=3.1.1",
|
||||
"gevent~=24.11.1",
|
||||
@@ -204,7 +205,7 @@ vdb = [
|
||||
"pgvector==0.2.5",
|
||||
"pymilvus~=2.5.0",
|
||||
"pymochow==1.3.1",
|
||||
"pyobvector~=0.1.6",
|
||||
"pyobvector~=0.2.15",
|
||||
"qdrant-client==1.9.0",
|
||||
"tablestore==6.2.0",
|
||||
"tcvectordb~=1.6.4",
|
||||
|
||||
@@ -103,10 +103,10 @@ class ConversationService:
|
||||
@classmethod
|
||||
def _build_filter_condition(cls, sort_field: str, sort_direction: Callable, reference_conversation: Conversation):
|
||||
field_value = getattr(reference_conversation, sort_field)
|
||||
if sort_direction == desc:
|
||||
if sort_direction is desc:
|
||||
return getattr(Conversation, sort_field) < field_value
|
||||
else:
|
||||
return getattr(Conversation, sort_field) > field_value
|
||||
|
||||
return getattr(Conversation, sort_field) > field_value
|
||||
|
||||
@classmethod
|
||||
def rename(
|
||||
@@ -147,7 +147,7 @@ class ConversationService:
|
||||
app_model.tenant_id, message.query, conversation.id, app_model.id
|
||||
)
|
||||
conversation.name = name
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.session.commit()
|
||||
@@ -277,6 +277,11 @@ class ConversationService:
|
||||
|
||||
# Validate that the new value type matches the expected variable type
|
||||
expected_type = SegmentType(current_variable.value_type)
|
||||
|
||||
# There is showing number in web ui but int in db
|
||||
if expected_type == SegmentType.INTEGER:
|
||||
expected_type = SegmentType.NUMBER
|
||||
|
||||
if not expected_type.is_valid(new_value):
|
||||
inferred_type = SegmentType.infer_segment_type(new_value)
|
||||
raise ConversationVariableTypeMismatchError(
|
||||
|
||||
@@ -47,7 +47,9 @@ class OAuthProxyService(BasePluginClient):
|
||||
if not context_id:
|
||||
raise ValueError("context_id is required")
|
||||
# get data from redis
|
||||
data = redis_client.getdel(f"{OAuthProxyService.__KEY_PREFIX__}{context_id}")
|
||||
key = f"{OAuthProxyService.__KEY_PREFIX__}{context_id}"
|
||||
data = redis_client.get(key)
|
||||
if not data:
|
||||
raise ValueError("context_id is invalid")
|
||||
redis_client.delete(key)
|
||||
return json.loads(data)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from core.rag.datasource.vdb.qdrant.qdrant_vector import QdrantConfig, QdrantVector
|
||||
from core.rag.models.document import Document
|
||||
from tests.integration_tests.vdb.test_vector_store import (
|
||||
AbstractVectorTest,
|
||||
setup_mock_redis,
|
||||
@@ -18,6 +19,14 @@ class QdrantVectorTest(AbstractVectorTest):
|
||||
),
|
||||
)
|
||||
|
||||
def search_by_vector(self):
|
||||
super().search_by_vector()
|
||||
# only test for qdrant, may not work on other vector stores
|
||||
hits_by_vector: list[Document] = self.vector.search_by_vector(
|
||||
query_vector=self.example_embedding, score_threshold=1
|
||||
)
|
||||
assert len(hits_by_vector) == 0
|
||||
|
||||
|
||||
def test_qdrant_vector(setup_mock_redis):
|
||||
QdrantVectorTest().run_all_tests()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+474
@@ -0,0 +1,474 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
|
||||
from models.account import TenantAccountJoin, TenantAccountRole
|
||||
from models.model import Account, Tenant
|
||||
from models.provider import LoadBalancingModelConfig, Provider, ProviderModelSetting
|
||||
from services.model_load_balancing_service import ModelLoadBalancingService
|
||||
|
||||
|
||||
class TestModelLoadBalancingService:
|
||||
"""Integration tests for ModelLoadBalancingService using testcontainers."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_external_service_dependencies(self):
|
||||
"""Mock setup for external service dependencies."""
|
||||
with (
|
||||
patch("services.model_load_balancing_service.ProviderManager") as mock_provider_manager,
|
||||
patch("services.model_load_balancing_service.LBModelManager") as mock_lb_model_manager,
|
||||
patch("services.model_load_balancing_service.ModelProviderFactory") as mock_model_provider_factory,
|
||||
patch("services.model_load_balancing_service.encrypter") as mock_encrypter,
|
||||
):
|
||||
# Setup default mock returns
|
||||
mock_provider_manager_instance = mock_provider_manager.return_value
|
||||
|
||||
# Mock provider configuration
|
||||
mock_provider_config = MagicMock()
|
||||
mock_provider_config.provider.provider = "openai"
|
||||
mock_provider_config.custom_configuration.provider = None
|
||||
|
||||
# Mock provider model setting
|
||||
mock_provider_model_setting = MagicMock()
|
||||
mock_provider_model_setting.load_balancing_enabled = False
|
||||
|
||||
mock_provider_config.get_provider_model_setting.return_value = mock_provider_model_setting
|
||||
|
||||
# Mock provider configurations dict
|
||||
mock_provider_configs = {"openai": mock_provider_config}
|
||||
mock_provider_manager_instance.get_configurations.return_value = mock_provider_configs
|
||||
|
||||
# Mock LBModelManager
|
||||
mock_lb_model_manager.get_config_in_cooldown_and_ttl.return_value = (False, 0)
|
||||
|
||||
# Mock ModelProviderFactory
|
||||
mock_model_provider_factory_instance = mock_model_provider_factory.return_value
|
||||
|
||||
# Mock credential schemas
|
||||
mock_credential_schema = MagicMock()
|
||||
mock_credential_schema.credential_form_schemas = []
|
||||
|
||||
# Mock provider configuration methods
|
||||
mock_provider_config.extract_secret_variables.return_value = []
|
||||
mock_provider_config.obfuscated_credentials.return_value = {}
|
||||
mock_provider_config._get_credential_schema.return_value = mock_credential_schema
|
||||
|
||||
yield {
|
||||
"provider_manager": mock_provider_manager,
|
||||
"lb_model_manager": mock_lb_model_manager,
|
||||
"model_provider_factory": mock_model_provider_factory,
|
||||
"encrypter": mock_encrypter,
|
||||
"provider_config": mock_provider_config,
|
||||
"provider_model_setting": mock_provider_model_setting,
|
||||
"credential_schema": mock_credential_schema,
|
||||
}
|
||||
|
||||
def _create_test_account_and_tenant(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
"""
|
||||
Helper method to create a test account and tenant for testing.
|
||||
|
||||
Args:
|
||||
db_session_with_containers: Database session from testcontainers infrastructure
|
||||
mock_external_service_dependencies: Mock dependencies
|
||||
|
||||
Returns:
|
||||
tuple: (account, tenant) - Created account and tenant instances
|
||||
"""
|
||||
fake = Faker()
|
||||
|
||||
# Create account
|
||||
account = Account(
|
||||
email=fake.email(),
|
||||
name=fake.name(),
|
||||
interface_language="en-US",
|
||||
status="active",
|
||||
)
|
||||
|
||||
from extensions.ext_database import db
|
||||
|
||||
db.session.add(account)
|
||||
db.session.commit()
|
||||
|
||||
# Create tenant for the account
|
||||
tenant = Tenant(
|
||||
name=fake.company(),
|
||||
status="normal",
|
||||
)
|
||||
db.session.add(tenant)
|
||||
db.session.commit()
|
||||
|
||||
# Create tenant-account join
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
role=TenantAccountRole.OWNER.value,
|
||||
current=True,
|
||||
)
|
||||
db.session.add(join)
|
||||
db.session.commit()
|
||||
|
||||
# Set current tenant for account
|
||||
account.current_tenant = tenant
|
||||
|
||||
return account, tenant
|
||||
|
||||
def _create_test_provider_and_setting(
|
||||
self, db_session_with_containers, tenant_id, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Helper method to create a test provider and provider model setting.
|
||||
|
||||
Args:
|
||||
db_session_with_containers: Database session from testcontainers infrastructure
|
||||
tenant_id: Tenant ID for the provider
|
||||
mock_external_service_dependencies: Mock dependencies
|
||||
|
||||
Returns:
|
||||
tuple: (provider, provider_model_setting) - Created provider and setting instances
|
||||
"""
|
||||
fake = Faker()
|
||||
|
||||
from extensions.ext_database import db
|
||||
|
||||
# Create provider
|
||||
provider = Provider(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
provider_type="custom",
|
||||
is_valid=True,
|
||||
)
|
||||
db.session.add(provider)
|
||||
db.session.commit()
|
||||
|
||||
# Create provider model setting
|
||||
provider_model_setting = ProviderModelSetting(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
model_name="gpt-3.5-turbo",
|
||||
model_type="text-generation", # Use the origin model type that matches the query
|
||||
enabled=True,
|
||||
load_balancing_enabled=False,
|
||||
)
|
||||
db.session.add(provider_model_setting)
|
||||
db.session.commit()
|
||||
|
||||
return provider, provider_model_setting
|
||||
|
||||
def test_enable_model_load_balancing_success(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
"""
|
||||
Test successful model load balancing enablement.
|
||||
|
||||
This test verifies:
|
||||
- Proper provider configuration retrieval
|
||||
- Successful enablement of model load balancing
|
||||
- Correct method calls to provider configuration
|
||||
"""
|
||||
# Arrange: Create test data
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
provider, provider_model_setting = self._create_test_provider_and_setting(
|
||||
db_session_with_containers, tenant.id, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
# Setup mocks for enable method
|
||||
mock_provider_config = mock_external_service_dependencies["provider_config"]
|
||||
mock_provider_config.enable_model_load_balancing = MagicMock()
|
||||
|
||||
# Act: Execute the method under test
|
||||
service = ModelLoadBalancingService()
|
||||
service.enable_model_load_balancing(
|
||||
tenant_id=tenant.id, provider="openai", model="gpt-3.5-turbo", model_type="llm"
|
||||
)
|
||||
|
||||
# Assert: Verify the expected outcomes
|
||||
mock_provider_config.enable_model_load_balancing.assert_called_once()
|
||||
call_args = mock_provider_config.enable_model_load_balancing.call_args
|
||||
assert call_args.kwargs["model"] == "gpt-3.5-turbo"
|
||||
assert call_args.kwargs["model_type"].value == "llm" # ModelType enum value
|
||||
|
||||
# Verify database state
|
||||
from extensions.ext_database import db
|
||||
|
||||
db.session.refresh(provider)
|
||||
db.session.refresh(provider_model_setting)
|
||||
assert provider.id is not None
|
||||
assert provider_model_setting.id is not None
|
||||
|
||||
def test_disable_model_load_balancing_success(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
"""
|
||||
Test successful model load balancing disablement.
|
||||
|
||||
This test verifies:
|
||||
- Proper provider configuration retrieval
|
||||
- Successful disablement of model load balancing
|
||||
- Correct method calls to provider configuration
|
||||
"""
|
||||
# Arrange: Create test data
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
provider, provider_model_setting = self._create_test_provider_and_setting(
|
||||
db_session_with_containers, tenant.id, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
# Setup mocks for disable method
|
||||
mock_provider_config = mock_external_service_dependencies["provider_config"]
|
||||
mock_provider_config.disable_model_load_balancing = MagicMock()
|
||||
|
||||
# Act: Execute the method under test
|
||||
service = ModelLoadBalancingService()
|
||||
service.disable_model_load_balancing(
|
||||
tenant_id=tenant.id, provider="openai", model="gpt-3.5-turbo", model_type="llm"
|
||||
)
|
||||
|
||||
# Assert: Verify the expected outcomes
|
||||
mock_provider_config.disable_model_load_balancing.assert_called_once()
|
||||
call_args = mock_provider_config.disable_model_load_balancing.call_args
|
||||
assert call_args.kwargs["model"] == "gpt-3.5-turbo"
|
||||
assert call_args.kwargs["model_type"].value == "llm" # ModelType enum value
|
||||
|
||||
# Verify database state
|
||||
from extensions.ext_database import db
|
||||
|
||||
db.session.refresh(provider)
|
||||
db.session.refresh(provider_model_setting)
|
||||
assert provider.id is not None
|
||||
assert provider_model_setting.id is not None
|
||||
|
||||
def test_enable_model_load_balancing_provider_not_found(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test error handling when provider does not exist.
|
||||
|
||||
This test verifies:
|
||||
- Proper error handling for non-existent provider
|
||||
- Correct exception type and message
|
||||
- No database state changes
|
||||
"""
|
||||
# Arrange: Create test data
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
# Setup mocks to return empty provider configurations
|
||||
mock_provider_manager = mock_external_service_dependencies["provider_manager"]
|
||||
mock_provider_manager_instance = mock_provider_manager.return_value
|
||||
mock_provider_manager_instance.get_configurations.return_value = {}
|
||||
|
||||
# Act & Assert: Verify proper error handling
|
||||
service = ModelLoadBalancingService()
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
service.enable_model_load_balancing(
|
||||
tenant_id=tenant.id, provider="nonexistent_provider", model="gpt-3.5-turbo", model_type="llm"
|
||||
)
|
||||
|
||||
# Verify correct error message
|
||||
assert "Provider nonexistent_provider does not exist." in str(exc_info.value)
|
||||
|
||||
# Verify no database state changes occurred
|
||||
from extensions.ext_database import db
|
||||
|
||||
db.session.rollback()
|
||||
|
||||
def test_get_load_balancing_configs_success(self, db_session_with_containers, mock_external_service_dependencies):
|
||||
"""
|
||||
Test successful retrieval of load balancing configurations.
|
||||
|
||||
This test verifies:
|
||||
- Proper provider configuration retrieval
|
||||
- Successful database query for load balancing configs
|
||||
- Correct return format and data structure
|
||||
"""
|
||||
# Arrange: Create test data
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
provider, provider_model_setting = self._create_test_provider_and_setting(
|
||||
db_session_with_containers, tenant.id, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
# Create load balancing config
|
||||
from extensions.ext_database import db
|
||||
|
||||
load_balancing_config = LoadBalancingModelConfig(
|
||||
tenant_id=tenant.id,
|
||||
provider_name="openai",
|
||||
model_name="gpt-3.5-turbo",
|
||||
model_type="text-generation", # Use the origin model type that matches the query
|
||||
name="config1",
|
||||
encrypted_config='{"api_key": "test_key"}',
|
||||
enabled=True,
|
||||
)
|
||||
db.session.add(load_balancing_config)
|
||||
db.session.commit()
|
||||
|
||||
# Verify the config was created
|
||||
db.session.refresh(load_balancing_config)
|
||||
assert load_balancing_config.id is not None
|
||||
|
||||
# Setup mocks for get_load_balancing_configs method
|
||||
mock_provider_config = mock_external_service_dependencies["provider_config"]
|
||||
mock_provider_model_setting = mock_external_service_dependencies["provider_model_setting"]
|
||||
mock_provider_model_setting.load_balancing_enabled = True
|
||||
|
||||
# Mock credential schema methods
|
||||
mock_credential_schema = mock_external_service_dependencies["credential_schema"]
|
||||
mock_credential_schema.credential_form_schemas = []
|
||||
|
||||
# Mock encrypter
|
||||
mock_encrypter = mock_external_service_dependencies["encrypter"]
|
||||
mock_encrypter.get_decrypt_decoding.return_value = ("key", "cipher")
|
||||
|
||||
# Mock _get_credential_schema method
|
||||
mock_provider_config._get_credential_schema.return_value = mock_credential_schema
|
||||
|
||||
# Mock extract_secret_variables method
|
||||
mock_provider_config.extract_secret_variables.return_value = []
|
||||
|
||||
# Mock obfuscated_credentials method
|
||||
mock_provider_config.obfuscated_credentials.return_value = {}
|
||||
|
||||
# Mock LBModelManager.get_config_in_cooldown_and_ttl
|
||||
mock_lb_model_manager = mock_external_service_dependencies["lb_model_manager"]
|
||||
mock_lb_model_manager.get_config_in_cooldown_and_ttl.return_value = (False, 0)
|
||||
|
||||
# Act: Execute the method under test
|
||||
service = ModelLoadBalancingService()
|
||||
is_enabled, configs = service.get_load_balancing_configs(
|
||||
tenant_id=tenant.id, provider="openai", model="gpt-3.5-turbo", model_type="llm"
|
||||
)
|
||||
|
||||
# Assert: Verify the expected outcomes
|
||||
assert is_enabled is True
|
||||
assert len(configs) == 1
|
||||
assert configs[0]["id"] == load_balancing_config.id
|
||||
assert configs[0]["name"] == "config1"
|
||||
assert configs[0]["enabled"] is True
|
||||
assert configs[0]["in_cooldown"] is False
|
||||
assert configs[0]["ttl"] == 0
|
||||
|
||||
# Verify database state
|
||||
db.session.refresh(load_balancing_config)
|
||||
assert load_balancing_config.id is not None
|
||||
|
||||
def test_get_load_balancing_configs_provider_not_found(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test error handling when provider does not exist in get_load_balancing_configs.
|
||||
|
||||
This test verifies:
|
||||
- Proper error handling for non-existent provider
|
||||
- Correct exception type and message
|
||||
- No database state changes
|
||||
"""
|
||||
# Arrange: Create test data
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
# Setup mocks to return empty provider configurations
|
||||
mock_provider_manager = mock_external_service_dependencies["provider_manager"]
|
||||
mock_provider_manager_instance = mock_provider_manager.return_value
|
||||
mock_provider_manager_instance.get_configurations.return_value = {}
|
||||
|
||||
# Act & Assert: Verify proper error handling
|
||||
service = ModelLoadBalancingService()
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
service.get_load_balancing_configs(
|
||||
tenant_id=tenant.id, provider="nonexistent_provider", model="gpt-3.5-turbo", model_type="llm"
|
||||
)
|
||||
|
||||
# Verify correct error message
|
||||
assert "Provider nonexistent_provider does not exist." in str(exc_info.value)
|
||||
|
||||
# Verify no database state changes occurred
|
||||
from extensions.ext_database import db
|
||||
|
||||
db.session.rollback()
|
||||
|
||||
def test_get_load_balancing_configs_with_inherit_config(
|
||||
self, db_session_with_containers, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test load balancing configs retrieval with inherit configuration.
|
||||
|
||||
This test verifies:
|
||||
- Proper handling of inherit configuration
|
||||
- Correct ordering of configurations
|
||||
- Inherit config initialization when needed
|
||||
"""
|
||||
# Arrange: Create test data
|
||||
fake = Faker()
|
||||
account, tenant = self._create_test_account_and_tenant(
|
||||
db_session_with_containers, mock_external_service_dependencies
|
||||
)
|
||||
provider, provider_model_setting = self._create_test_provider_and_setting(
|
||||
db_session_with_containers, tenant.id, mock_external_service_dependencies
|
||||
)
|
||||
|
||||
# Create load balancing config
|
||||
from extensions.ext_database import db
|
||||
|
||||
load_balancing_config = LoadBalancingModelConfig(
|
||||
tenant_id=tenant.id,
|
||||
provider_name="openai",
|
||||
model_name="gpt-3.5-turbo",
|
||||
model_type="text-generation", # Use the origin model type that matches the query
|
||||
name="config1",
|
||||
encrypted_config='{"api_key": "test_key"}',
|
||||
enabled=True,
|
||||
)
|
||||
db.session.add(load_balancing_config)
|
||||
db.session.commit()
|
||||
|
||||
# Setup mocks for inherit config scenario
|
||||
mock_provider_config = mock_external_service_dependencies["provider_config"]
|
||||
mock_provider_config.custom_configuration.provider = MagicMock() # Enable custom config
|
||||
|
||||
mock_provider_model_setting = mock_external_service_dependencies["provider_model_setting"]
|
||||
mock_provider_model_setting.load_balancing_enabled = True
|
||||
|
||||
# Mock credential schema methods
|
||||
mock_credential_schema = mock_external_service_dependencies["credential_schema"]
|
||||
mock_credential_schema.credential_form_schemas = []
|
||||
|
||||
# Mock encrypter
|
||||
mock_encrypter = mock_external_service_dependencies["encrypter"]
|
||||
mock_encrypter.get_decrypt_decoding.return_value = ("key", "cipher")
|
||||
|
||||
# Act: Execute the method under test
|
||||
service = ModelLoadBalancingService()
|
||||
is_enabled, configs = service.get_load_balancing_configs(
|
||||
tenant_id=tenant.id, provider="openai", model="gpt-3.5-turbo", model_type="llm"
|
||||
)
|
||||
|
||||
# Assert: Verify the expected outcomes
|
||||
assert is_enabled is True
|
||||
assert len(configs) == 2 # inherit config + existing config
|
||||
|
||||
# First config should be inherit config
|
||||
assert configs[0]["name"] == "__inherit__"
|
||||
assert configs[0]["enabled"] is True
|
||||
|
||||
# Second config should be the existing config
|
||||
assert configs[1]["id"] == load_balancing_config.id
|
||||
assert configs[1]["name"] == "config1"
|
||||
|
||||
# Verify database state
|
||||
db.session.refresh(load_balancing_config)
|
||||
assert load_balancing_config.id is not None
|
||||
|
||||
# Verify inherit config was created in database
|
||||
inherit_configs = (
|
||||
db.session.query(LoadBalancingModelConfig).filter(LoadBalancingModelConfig.name == "__inherit__").all()
|
||||
)
|
||||
assert len(inherit_configs) == 1
|
||||
@@ -262,26 +262,6 @@ def test_sse_client_queue_cleanup():
|
||||
# Note: In real implementation, cleanup should put None to signal shutdown
|
||||
|
||||
|
||||
def test_sse_client_url_processing():
|
||||
"""Test SSE client URL processing functions."""
|
||||
from core.mcp.client.sse_client import remove_request_params
|
||||
|
||||
# Test URL with parameters
|
||||
url_with_params = "http://example.com/sse?param1=value1¶m2=value2"
|
||||
cleaned_url = remove_request_params(url_with_params)
|
||||
assert cleaned_url == "http://example.com/sse"
|
||||
|
||||
# Test URL without parameters
|
||||
url_without_params = "http://example.com/sse"
|
||||
cleaned_url = remove_request_params(url_without_params)
|
||||
assert cleaned_url == "http://example.com/sse"
|
||||
|
||||
# Test URL with path and parameters
|
||||
complex_url = "http://example.com/path/to/sse?session=123&token=abc"
|
||||
cleaned_url = remove_request_params(complex_url)
|
||||
assert cleaned_url == "http://example.com/path/to/sse"
|
||||
|
||||
|
||||
def test_sse_client_headers_propagation():
|
||||
"""Test that custom headers are properly propagated in SSE client."""
|
||||
test_url = "http://test.example/sse"
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import pytz
|
||||
|
||||
from core.tools.entities.tool_entities import ToolInvokeMessage
|
||||
from core.tools.utils.message_transformer import ToolFileMessageTransformer, safe_json_dict, safe_json_value
|
||||
|
||||
|
||||
class TestSafeJsonValue:
|
||||
"""Test suite for safe_json_value function to ensure proper serialization of complex types"""
|
||||
|
||||
def test_datetime_conversion(self):
|
||||
"""Test datetime conversion with timezone handling"""
|
||||
# Test datetime with UTC timezone
|
||||
dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=pytz.UTC)
|
||||
result = safe_json_value(dt)
|
||||
assert isinstance(result, str)
|
||||
assert "2024-01-01T12:00:00+00:00" in result
|
||||
|
||||
# Test datetime without timezone (should default to UTC)
|
||||
dt_no_tz = datetime(2024, 1, 1, 12, 0, 0)
|
||||
result = safe_json_value(dt_no_tz)
|
||||
assert isinstance(result, str)
|
||||
# The exact time will depend on the system's timezone, so we check the format
|
||||
assert "T" in result # ISO format separator
|
||||
# Check that it's a valid ISO format datetime string
|
||||
assert len(result) >= 19 # At least YYYY-MM-DDTHH:MM:SS
|
||||
|
||||
def test_date_conversion(self):
|
||||
"""Test date conversion to ISO format"""
|
||||
test_date = date(2024, 1, 1)
|
||||
result = safe_json_value(test_date)
|
||||
assert result == "2024-01-01"
|
||||
|
||||
def test_uuid_conversion(self):
|
||||
"""Test UUID conversion to string"""
|
||||
test_uuid = uuid4()
|
||||
result = safe_json_value(test_uuid)
|
||||
assert isinstance(result, str)
|
||||
assert result == str(test_uuid)
|
||||
|
||||
def test_decimal_conversion(self):
|
||||
"""Test Decimal conversion to float"""
|
||||
test_decimal = Decimal("123.456")
|
||||
result = safe_json_value(test_decimal)
|
||||
assert result == 123.456
|
||||
assert isinstance(result, float)
|
||||
|
||||
def test_bytes_conversion(self):
|
||||
"""Test bytes conversion with UTF-8 decoding"""
|
||||
# Test valid UTF-8 bytes
|
||||
test_bytes = b"Hello, World!"
|
||||
result = safe_json_value(test_bytes)
|
||||
assert result == "Hello, World!"
|
||||
|
||||
# Test invalid UTF-8 bytes (should fall back to hex)
|
||||
invalid_bytes = b"\xff\xfe\xfd"
|
||||
result = safe_json_value(invalid_bytes)
|
||||
assert result == "fffefd"
|
||||
|
||||
def test_memoryview_conversion(self):
|
||||
"""Test memoryview conversion to hex string"""
|
||||
test_bytes = b"test data"
|
||||
test_memoryview = memoryview(test_bytes)
|
||||
result = safe_json_value(test_memoryview)
|
||||
assert result == "746573742064617461" # hex of "test data"
|
||||
|
||||
def test_numpy_ndarray_conversion(self):
|
||||
"""Test numpy ndarray conversion to list"""
|
||||
# Test 1D array
|
||||
test_array = np.array([1, 2, 3, 4])
|
||||
result = safe_json_value(test_array)
|
||||
assert result == [1, 2, 3, 4]
|
||||
|
||||
# Test 2D array
|
||||
test_2d_array = np.array([[1, 2], [3, 4]])
|
||||
result = safe_json_value(test_2d_array)
|
||||
assert result == [[1, 2], [3, 4]]
|
||||
|
||||
# Test array with float values
|
||||
test_float_array = np.array([1.5, 2.7, 3.14])
|
||||
result = safe_json_value(test_float_array)
|
||||
assert result == [1.5, 2.7, 3.14]
|
||||
|
||||
def test_dict_conversion(self):
|
||||
"""Test dictionary conversion using safe_json_dict"""
|
||||
test_dict = {
|
||||
"string": "value",
|
||||
"number": 42,
|
||||
"float": 3.14,
|
||||
"boolean": True,
|
||||
"list": [1, 2, 3],
|
||||
"nested": {"key": "value"},
|
||||
}
|
||||
result = safe_json_value(test_dict)
|
||||
assert isinstance(result, dict)
|
||||
assert result == test_dict
|
||||
|
||||
def test_list_conversion(self):
|
||||
"""Test list conversion with mixed types"""
|
||||
test_list = [
|
||||
"string",
|
||||
42,
|
||||
3.14,
|
||||
True,
|
||||
[1, 2, 3],
|
||||
{"key": "value"},
|
||||
datetime(2024, 1, 1, 12, 0, 0, tzinfo=pytz.UTC),
|
||||
Decimal("123.456"),
|
||||
uuid4(),
|
||||
]
|
||||
result = safe_json_value(test_list)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == len(test_list)
|
||||
assert isinstance(result[6], str) # datetime should be converted to string
|
||||
assert isinstance(result[7], float) # Decimal should be converted to float
|
||||
assert isinstance(result[8], str) # UUID should be converted to string
|
||||
|
||||
def test_tuple_conversion(self):
|
||||
"""Test tuple conversion to list"""
|
||||
test_tuple = (1, "string", 3.14)
|
||||
result = safe_json_value(test_tuple)
|
||||
assert isinstance(result, list)
|
||||
assert result == [1, "string", 3.14]
|
||||
|
||||
def test_set_conversion(self):
|
||||
"""Test set conversion to list"""
|
||||
test_set = {1, "string", 3.14}
|
||||
result = safe_json_value(test_set)
|
||||
assert isinstance(result, list)
|
||||
# Note: set order is not guaranteed, so we check length and content
|
||||
assert len(result) == 3
|
||||
assert 1 in result
|
||||
assert "string" in result
|
||||
assert 3.14 in result
|
||||
|
||||
def test_basic_types_passthrough(self):
|
||||
"""Test that basic types are passed through unchanged"""
|
||||
assert safe_json_value("string") == "string"
|
||||
assert safe_json_value(42) == 42
|
||||
assert safe_json_value(3.14) == 3.14
|
||||
assert safe_json_value(True) is True
|
||||
assert safe_json_value(False) is False
|
||||
assert safe_json_value(None) is None
|
||||
|
||||
def test_nested_complex_structure(self):
|
||||
"""Test complex nested structure with all types"""
|
||||
complex_data = {
|
||||
"dates": [date(2024, 1, 1), date(2024, 1, 2)],
|
||||
"timestamps": [
|
||||
datetime(2024, 1, 1, 12, 0, 0, tzinfo=pytz.UTC),
|
||||
datetime(2024, 1, 2, 12, 0, 0, tzinfo=pytz.UTC),
|
||||
],
|
||||
"numbers": [Decimal("123.456"), Decimal("789.012")],
|
||||
"identifiers": [uuid4(), uuid4()],
|
||||
"binary_data": [b"hello", b"world"],
|
||||
"arrays": [np.array([1, 2, 3]), np.array([4, 5, 6])],
|
||||
}
|
||||
|
||||
result = safe_json_value(complex_data)
|
||||
|
||||
# Verify structure is maintained
|
||||
assert isinstance(result, dict)
|
||||
assert "dates" in result
|
||||
assert "timestamps" in result
|
||||
assert "numbers" in result
|
||||
assert "identifiers" in result
|
||||
assert "binary_data" in result
|
||||
assert "arrays" in result
|
||||
|
||||
# Verify conversions
|
||||
assert all(isinstance(d, str) for d in result["dates"])
|
||||
assert all(isinstance(t, str) for t in result["timestamps"])
|
||||
assert all(isinstance(n, float) for n in result["numbers"])
|
||||
assert all(isinstance(i, str) for i in result["identifiers"])
|
||||
assert all(isinstance(b, str) for b in result["binary_data"])
|
||||
assert all(isinstance(a, list) for a in result["arrays"])
|
||||
|
||||
|
||||
class TestSafeJsonDict:
|
||||
"""Test suite for safe_json_dict function"""
|
||||
|
||||
def test_valid_dict_conversion(self):
|
||||
"""Test valid dictionary conversion"""
|
||||
test_dict = {
|
||||
"string": "value",
|
||||
"number": 42,
|
||||
"datetime": datetime(2024, 1, 1, 12, 0, 0, tzinfo=pytz.UTC),
|
||||
"decimal": Decimal("123.456"),
|
||||
}
|
||||
result = safe_json_dict(test_dict)
|
||||
assert isinstance(result, dict)
|
||||
assert result["string"] == "value"
|
||||
assert result["number"] == 42
|
||||
assert isinstance(result["datetime"], str)
|
||||
assert isinstance(result["decimal"], float)
|
||||
|
||||
def test_invalid_input_type(self):
|
||||
"""Test that invalid input types raise TypeError"""
|
||||
with pytest.raises(TypeError, match="safe_json_dict\\(\\) expects a dictionary \\(dict\\) as input"):
|
||||
safe_json_dict("not a dict")
|
||||
|
||||
with pytest.raises(TypeError, match="safe_json_dict\\(\\) expects a dictionary \\(dict\\) as input"):
|
||||
safe_json_dict([1, 2, 3])
|
||||
|
||||
with pytest.raises(TypeError, match="safe_json_dict\\(\\) expects a dictionary \\(dict\\) as input"):
|
||||
safe_json_dict(42)
|
||||
|
||||
def test_empty_dict(self):
|
||||
"""Test empty dictionary handling"""
|
||||
result = safe_json_dict({})
|
||||
assert result == {}
|
||||
|
||||
def test_nested_dict_conversion(self):
|
||||
"""Test nested dictionary conversion"""
|
||||
test_dict = {
|
||||
"level1": {
|
||||
"level2": {"datetime": datetime(2024, 1, 1, 12, 0, 0, tzinfo=pytz.UTC), "decimal": Decimal("123.456")}
|
||||
}
|
||||
}
|
||||
result = safe_json_dict(test_dict)
|
||||
assert isinstance(result["level1"]["level2"]["datetime"], str)
|
||||
assert isinstance(result["level1"]["level2"]["decimal"], float)
|
||||
|
||||
|
||||
class TestToolInvokeMessageJsonSerialization:
|
||||
"""Test suite for ToolInvokeMessage JSON serialization through safe_json_value"""
|
||||
|
||||
def test_json_message_serialization(self):
|
||||
"""Test JSON message serialization with complex data"""
|
||||
complex_data = {
|
||||
"timestamp": datetime(2024, 1, 1, 12, 0, 0, tzinfo=pytz.UTC),
|
||||
"amount": Decimal("123.45"),
|
||||
"id": uuid4(),
|
||||
"binary": b"test data",
|
||||
"array": np.array([1, 2, 3]),
|
||||
}
|
||||
|
||||
# Create JSON message
|
||||
json_message = ToolInvokeMessage.JsonMessage(json_object=complex_data)
|
||||
message = ToolInvokeMessage(type=ToolInvokeMessage.MessageType.JSON, message=json_message)
|
||||
|
||||
# Apply safe_json_value transformation
|
||||
transformed_data = safe_json_value(message.message.json_object)
|
||||
|
||||
# Verify transformations
|
||||
assert isinstance(transformed_data["timestamp"], str)
|
||||
assert isinstance(transformed_data["amount"], float)
|
||||
assert isinstance(transformed_data["id"], str)
|
||||
assert isinstance(transformed_data["binary"], str)
|
||||
assert isinstance(transformed_data["array"], list)
|
||||
|
||||
# Verify JSON serialization works
|
||||
json_string = json.dumps(transformed_data, ensure_ascii=False)
|
||||
assert isinstance(json_string, str)
|
||||
|
||||
# Verify we can deserialize back
|
||||
deserialized = json.loads(json_string)
|
||||
assert deserialized["amount"] == 123.45
|
||||
assert deserialized["array"] == [1, 2, 3]
|
||||
|
||||
def test_json_message_with_nested_structures(self):
|
||||
"""Test JSON message with deeply nested complex structures"""
|
||||
nested_data = {
|
||||
"level1": {
|
||||
"level2": {
|
||||
"level3": {
|
||||
"dates": [date(2024, 1, 1), date(2024, 1, 2)],
|
||||
"timestamps": [datetime(2024, 1, 1, 12, 0, 0, tzinfo=pytz.UTC)],
|
||||
"numbers": [Decimal("1.1"), Decimal("2.2")],
|
||||
"arrays": [np.array([1, 2]), np.array([3, 4])],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json_message = ToolInvokeMessage.JsonMessage(json_object=nested_data)
|
||||
message = ToolInvokeMessage(type=ToolInvokeMessage.MessageType.JSON, message=json_message)
|
||||
|
||||
# Transform the data
|
||||
transformed_data = safe_json_value(message.message.json_object)
|
||||
|
||||
# Verify nested transformations
|
||||
level3 = transformed_data["level1"]["level2"]["level3"]
|
||||
assert all(isinstance(d, str) for d in level3["dates"])
|
||||
assert all(isinstance(t, str) for t in level3["timestamps"])
|
||||
assert all(isinstance(n, float) for n in level3["numbers"])
|
||||
assert all(isinstance(a, list) for a in level3["arrays"])
|
||||
|
||||
# Test JSON serialization
|
||||
json_string = json.dumps(transformed_data, ensure_ascii=False)
|
||||
assert isinstance(json_string, str)
|
||||
|
||||
# Verify deserialization
|
||||
deserialized = json.loads(json_string)
|
||||
assert deserialized["level1"]["level2"]["level3"]["numbers"] == [1.1, 2.2]
|
||||
|
||||
def test_json_message_transformer_integration(self):
|
||||
"""Test integration with ToolFileMessageTransformer for JSON messages"""
|
||||
complex_data = {
|
||||
"metadata": {
|
||||
"created_at": datetime(2024, 1, 1, 12, 0, 0, tzinfo=pytz.UTC),
|
||||
"version": Decimal("1.0"),
|
||||
"tags": ["tag1", "tag2"],
|
||||
},
|
||||
"data": {"values": np.array([1.1, 2.2, 3.3]), "binary": b"binary content"},
|
||||
}
|
||||
|
||||
# Create message generator
|
||||
def message_generator():
|
||||
json_message = ToolInvokeMessage.JsonMessage(json_object=complex_data)
|
||||
message = ToolInvokeMessage(type=ToolInvokeMessage.MessageType.JSON, message=json_message)
|
||||
yield message
|
||||
|
||||
# Transform messages
|
||||
transformed_messages = list(
|
||||
ToolFileMessageTransformer.transform_tool_invoke_messages(
|
||||
message_generator(), user_id="test_user", tenant_id="test_tenant"
|
||||
)
|
||||
)
|
||||
|
||||
assert len(transformed_messages) == 1
|
||||
transformed_message = transformed_messages[0]
|
||||
assert transformed_message.type == ToolInvokeMessage.MessageType.JSON
|
||||
|
||||
# Verify the JSON object was transformed
|
||||
json_obj = transformed_message.message.json_object
|
||||
assert isinstance(json_obj["metadata"]["created_at"], str)
|
||||
assert isinstance(json_obj["metadata"]["version"], float)
|
||||
assert isinstance(json_obj["data"]["values"], list)
|
||||
assert isinstance(json_obj["data"]["binary"], str)
|
||||
|
||||
# Test final JSON serialization
|
||||
final_json = json.dumps(json_obj, ensure_ascii=False)
|
||||
assert isinstance(final_json, str)
|
||||
|
||||
# Verify we can deserialize
|
||||
deserialized = json.loads(final_json)
|
||||
assert deserialized["metadata"]["version"] == 1.0
|
||||
assert deserialized["data"]["values"] == [1.1, 2.2, 3.3]
|
||||
|
||||
def test_edge_cases_and_error_handling(self):
|
||||
"""Test edge cases and error handling in JSON serialization"""
|
||||
# Test with None values
|
||||
data_with_none = {"null_value": None, "empty_string": "", "zero": 0, "false_value": False}
|
||||
|
||||
json_message = ToolInvokeMessage.JsonMessage(json_object=data_with_none)
|
||||
message = ToolInvokeMessage(type=ToolInvokeMessage.MessageType.JSON, message=json_message)
|
||||
|
||||
transformed_data = safe_json_value(message.message.json_object)
|
||||
json_string = json.dumps(transformed_data, ensure_ascii=False)
|
||||
|
||||
# Verify serialization works with edge cases
|
||||
assert json_string is not None
|
||||
deserialized = json.loads(json_string)
|
||||
assert deserialized["null_value"] is None
|
||||
assert deserialized["empty_string"] == ""
|
||||
assert deserialized["zero"] == 0
|
||||
assert deserialized["false_value"] is False
|
||||
|
||||
# Test with very large numbers
|
||||
large_data = {
|
||||
"large_int": 2**63 - 1,
|
||||
"large_float": 1.7976931348623157e308,
|
||||
"small_float": 2.2250738585072014e-308,
|
||||
}
|
||||
|
||||
json_message = ToolInvokeMessage.JsonMessage(json_object=large_data)
|
||||
message = ToolInvokeMessage(type=ToolInvokeMessage.MessageType.JSON, message=json_message)
|
||||
|
||||
transformed_data = safe_json_value(message.message.json_object)
|
||||
json_string = json.dumps(transformed_data, ensure_ascii=False)
|
||||
|
||||
# Verify large numbers are handled correctly
|
||||
deserialized = json.loads(json_string)
|
||||
assert deserialized["large_int"] == 2**63 - 1
|
||||
assert deserialized["large_float"] == 1.7976931348623157e308
|
||||
assert deserialized["small_float"] == 2.2250738585072014e-308
|
||||
|
||||
|
||||
class TestEndToEndSerialization:
|
||||
"""Test suite for end-to-end serialization workflow"""
|
||||
|
||||
def test_complete_workflow_with_real_data(self):
|
||||
"""Test complete workflow from complex data to JSON string and back"""
|
||||
# Simulate real-world complex data structure
|
||||
real_world_data = {
|
||||
"user_profile": {
|
||||
"id": uuid4(),
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"created_at": datetime(2024, 1, 1, 12, 0, 0, tzinfo=pytz.UTC),
|
||||
"last_login": datetime(2024, 1, 15, 14, 30, 0, tzinfo=pytz.UTC),
|
||||
"preferences": {"theme": "dark", "language": "en", "timezone": "UTC"},
|
||||
},
|
||||
"analytics": {
|
||||
"session_count": 42,
|
||||
"total_time": Decimal("123.45"),
|
||||
"metrics": np.array([1.1, 2.2, 3.3, 4.4, 5.5]),
|
||||
"events": [
|
||||
{
|
||||
"timestamp": datetime(2024, 1, 1, 10, 0, 0, tzinfo=pytz.UTC),
|
||||
"action": "login",
|
||||
"duration": Decimal("5.67"),
|
||||
},
|
||||
{
|
||||
"timestamp": datetime(2024, 1, 1, 11, 0, 0, tzinfo=pytz.UTC),
|
||||
"action": "logout",
|
||||
"duration": Decimal("3600.0"),
|
||||
},
|
||||
],
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"id": uuid4(),
|
||||
"name": "document.pdf",
|
||||
"size": 1024,
|
||||
"uploaded_at": datetime(2024, 1, 1, 9, 0, 0, tzinfo=pytz.UTC),
|
||||
"checksum": b"abc123def456",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Step 1: Create ToolInvokeMessage
|
||||
json_message = ToolInvokeMessage.JsonMessage(json_object=real_world_data)
|
||||
message = ToolInvokeMessage(type=ToolInvokeMessage.MessageType.JSON, message=json_message)
|
||||
|
||||
# Step 2: Apply safe_json_value transformation
|
||||
transformed_data = safe_json_value(message.message.json_object)
|
||||
|
||||
# Step 3: Serialize to JSON string
|
||||
json_string = json.dumps(transformed_data, ensure_ascii=False)
|
||||
|
||||
# Step 4: Verify the string is valid JSON
|
||||
assert isinstance(json_string, str)
|
||||
assert json_string.startswith("{")
|
||||
assert json_string.endswith("}")
|
||||
|
||||
# Step 5: Deserialize back to Python object
|
||||
deserialized_data = json.loads(json_string)
|
||||
|
||||
# Step 6: Verify data integrity
|
||||
assert deserialized_data["user_profile"]["name"] == "John Doe"
|
||||
assert deserialized_data["user_profile"]["email"] == "john@example.com"
|
||||
assert isinstance(deserialized_data["user_profile"]["created_at"], str)
|
||||
assert isinstance(deserialized_data["analytics"]["total_time"], float)
|
||||
assert deserialized_data["analytics"]["total_time"] == 123.45
|
||||
assert isinstance(deserialized_data["analytics"]["metrics"], list)
|
||||
assert deserialized_data["analytics"]["metrics"] == [1.1, 2.2, 3.3, 4.4, 5.5]
|
||||
assert isinstance(deserialized_data["files"][0]["checksum"], str)
|
||||
|
||||
# Step 7: Verify all complex types were properly converted
|
||||
self._verify_all_complex_types_converted(deserialized_data)
|
||||
|
||||
def _verify_all_complex_types_converted(self, data):
|
||||
"""Helper method to verify all complex types were properly converted"""
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if key in ["id", "checksum"]:
|
||||
# These should be strings (UUID/bytes converted)
|
||||
assert isinstance(value, str)
|
||||
elif key in ["created_at", "last_login", "timestamp", "uploaded_at"]:
|
||||
# These should be strings (datetime converted)
|
||||
assert isinstance(value, str)
|
||||
elif key in ["total_time", "duration"]:
|
||||
# These should be floats (Decimal converted)
|
||||
assert isinstance(value, float)
|
||||
elif key == "metrics":
|
||||
# This should be a list (ndarray converted)
|
||||
assert isinstance(value, list)
|
||||
else:
|
||||
# Recursively check nested structures
|
||||
self._verify_all_complex_types_converted(value)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
self._verify_all_complex_types_converted(item)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for Celery SSL configuration."""
|
||||
|
||||
import ssl
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestCelerySSLConfiguration:
|
||||
"""Test suite for Celery SSL configuration."""
|
||||
|
||||
def test_get_celery_ssl_options_when_ssl_disabled(self):
|
||||
"""Test SSL options when REDIS_USE_SSL is False."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = False
|
||||
|
||||
with patch("extensions.ext_celery.dify_config", mock_config):
|
||||
from extensions.ext_celery import _get_celery_ssl_options
|
||||
|
||||
result = _get_celery_ssl_options()
|
||||
assert result is None
|
||||
|
||||
def test_get_celery_ssl_options_when_broker_not_redis(self):
|
||||
"""Test SSL options when broker is not Redis."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "amqp://localhost:5672"
|
||||
|
||||
with patch("extensions.ext_celery.dify_config", mock_config):
|
||||
from extensions.ext_celery import _get_celery_ssl_options
|
||||
|
||||
result = _get_celery_ssl_options()
|
||||
assert result is None
|
||||
|
||||
def test_get_celery_ssl_options_with_cert_none(self):
|
||||
"""Test SSL options with CERT_NONE requirement."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||
mock_config.REDIS_SSL_CERT_REQS = "CERT_NONE"
|
||||
mock_config.REDIS_SSL_CA_CERTS = None
|
||||
mock_config.REDIS_SSL_CERTFILE = None
|
||||
mock_config.REDIS_SSL_KEYFILE = None
|
||||
|
||||
with patch("extensions.ext_celery.dify_config", mock_config):
|
||||
from extensions.ext_celery import _get_celery_ssl_options
|
||||
|
||||
result = _get_celery_ssl_options()
|
||||
assert result is not None
|
||||
assert result["ssl_cert_reqs"] == ssl.CERT_NONE
|
||||
assert result["ssl_ca_certs"] is None
|
||||
assert result["ssl_certfile"] is None
|
||||
assert result["ssl_keyfile"] is None
|
||||
|
||||
def test_get_celery_ssl_options_with_cert_required(self):
|
||||
"""Test SSL options with CERT_REQUIRED and certificates."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "rediss://localhost:6380/0"
|
||||
mock_config.REDIS_SSL_CERT_REQS = "CERT_REQUIRED"
|
||||
mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt"
|
||||
mock_config.REDIS_SSL_CERTFILE = "/path/to/client.crt"
|
||||
mock_config.REDIS_SSL_KEYFILE = "/path/to/client.key"
|
||||
|
||||
with patch("extensions.ext_celery.dify_config", mock_config):
|
||||
from extensions.ext_celery import _get_celery_ssl_options
|
||||
|
||||
result = _get_celery_ssl_options()
|
||||
assert result is not None
|
||||
assert result["ssl_cert_reqs"] == ssl.CERT_REQUIRED
|
||||
assert result["ssl_ca_certs"] == "/path/to/ca.crt"
|
||||
assert result["ssl_certfile"] == "/path/to/client.crt"
|
||||
assert result["ssl_keyfile"] == "/path/to/client.key"
|
||||
|
||||
def test_get_celery_ssl_options_with_cert_optional(self):
|
||||
"""Test SSL options with CERT_OPTIONAL requirement."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||
mock_config.REDIS_SSL_CERT_REQS = "CERT_OPTIONAL"
|
||||
mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt"
|
||||
mock_config.REDIS_SSL_CERTFILE = None
|
||||
mock_config.REDIS_SSL_KEYFILE = None
|
||||
|
||||
with patch("extensions.ext_celery.dify_config", mock_config):
|
||||
from extensions.ext_celery import _get_celery_ssl_options
|
||||
|
||||
result = _get_celery_ssl_options()
|
||||
assert result is not None
|
||||
assert result["ssl_cert_reqs"] == ssl.CERT_OPTIONAL
|
||||
assert result["ssl_ca_certs"] == "/path/to/ca.crt"
|
||||
|
||||
def test_get_celery_ssl_options_with_invalid_cert_reqs(self):
|
||||
"""Test SSL options with invalid cert requirement defaults to CERT_NONE."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||
mock_config.REDIS_SSL_CERT_REQS = "INVALID_VALUE"
|
||||
mock_config.REDIS_SSL_CA_CERTS = None
|
||||
mock_config.REDIS_SSL_CERTFILE = None
|
||||
mock_config.REDIS_SSL_KEYFILE = None
|
||||
|
||||
with patch("extensions.ext_celery.dify_config", mock_config):
|
||||
from extensions.ext_celery import _get_celery_ssl_options
|
||||
|
||||
result = _get_celery_ssl_options()
|
||||
assert result is not None
|
||||
assert result["ssl_cert_reqs"] == ssl.CERT_NONE # Should default to CERT_NONE
|
||||
|
||||
def test_celery_init_applies_ssl_to_broker_and_backend(self):
|
||||
"""Test that SSL options are applied to both broker and backend when using Redis."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||
mock_config.CELERY_BACKEND = "redis"
|
||||
mock_config.CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
|
||||
mock_config.REDIS_SSL_CERT_REQS = "CERT_NONE"
|
||||
mock_config.REDIS_SSL_CA_CERTS = None
|
||||
mock_config.REDIS_SSL_CERTFILE = None
|
||||
mock_config.REDIS_SSL_KEYFILE = None
|
||||
mock_config.CELERY_USE_SENTINEL = False
|
||||
mock_config.LOG_FORMAT = "%(message)s"
|
||||
mock_config.LOG_TZ = "UTC"
|
||||
mock_config.LOG_FILE = None
|
||||
|
||||
# Mock all the scheduler configs
|
||||
mock_config.CELERY_BEAT_SCHEDULER_TIME = 1
|
||||
mock_config.ENABLE_CLEAN_EMBEDDING_CACHE_TASK = False
|
||||
mock_config.ENABLE_CLEAN_UNUSED_DATASETS_TASK = False
|
||||
mock_config.ENABLE_CREATE_TIDB_SERVERLESS_TASK = False
|
||||
mock_config.ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK = False
|
||||
mock_config.ENABLE_CLEAN_MESSAGES = False
|
||||
mock_config.ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK = False
|
||||
mock_config.ENABLE_DATASETS_QUEUE_MONITOR = False
|
||||
mock_config.ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK = False
|
||||
|
||||
with patch("extensions.ext_celery.dify_config", mock_config):
|
||||
from dify_app import DifyApp
|
||||
from extensions.ext_celery import init_app
|
||||
|
||||
app = DifyApp(__name__)
|
||||
celery_app = init_app(app)
|
||||
|
||||
# Check that SSL options were applied
|
||||
assert "broker_use_ssl" in celery_app.conf
|
||||
assert celery_app.conf["broker_use_ssl"] is not None
|
||||
assert celery_app.conf["broker_use_ssl"]["ssl_cert_reqs"] == ssl.CERT_NONE
|
||||
|
||||
# Check that SSL is also applied to Redis backend
|
||||
assert "redis_backend_use_ssl" in celery_app.conf
|
||||
assert celery_app.conf["redis_backend_use_ssl"] is not None
|
||||
Generated
+30
-4
@@ -1253,6 +1253,7 @@ dependencies = [
|
||||
{ name = "flask-cors" },
|
||||
{ name = "flask-login" },
|
||||
{ name = "flask-migrate" },
|
||||
{ name = "flask-orjson" },
|
||||
{ name = "flask-restful" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
{ name = "gevent" },
|
||||
@@ -1440,6 +1441,7 @@ requires-dist = [
|
||||
{ name = "flask-cors", specifier = "~=6.0.0" },
|
||||
{ name = "flask-login", specifier = "~=0.6.3" },
|
||||
{ name = "flask-migrate", specifier = "~=4.0.7" },
|
||||
{ name = "flask-orjson", specifier = "~=2.0.0" },
|
||||
{ name = "flask-restful", specifier = "~=0.3.10" },
|
||||
{ name = "flask-sqlalchemy", specifier = "~=3.1.1" },
|
||||
{ name = "gevent", specifier = "~=24.11.1" },
|
||||
@@ -1600,7 +1602,7 @@ vdb = [
|
||||
{ name = "pgvector", specifier = "==0.2.5" },
|
||||
{ name = "pymilvus", specifier = "~=2.5.0" },
|
||||
{ name = "pymochow", specifier = "==1.3.1" },
|
||||
{ name = "pyobvector", specifier = "~=0.1.6" },
|
||||
{ name = "pyobvector", specifier = "~=0.2.15" },
|
||||
{ name = "qdrant-client", specifier = "==1.9.0" },
|
||||
{ name = "tablestore", specifier = "==6.2.0" },
|
||||
{ name = "tcvectordb", specifier = "~=1.6.4" },
|
||||
@@ -1859,6 +1861,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/587023575286236f95d2ab8a826c320375ed5ea2102bb103ed89704ffa6b/Flask_Migrate-4.0.7-py3-none-any.whl", hash = "sha256:5c532be17e7b43a223b7500d620edae33795df27c75811ddf32560f7d48ec617", size = 21127, upload-time = "2024-03-11T18:42:59.462Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-orjson"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "orjson" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/49/575796f6ddca171d82dbb12762e33166c8b8f8616c946f0a6dfbb9bc3cd6/flask_orjson-2.0.0.tar.gz", hash = "sha256:6df6631437f9bc52cf9821735f896efa5583b5f80712f7d29d9ef69a79986a9c", size = 2974, upload-time = "2024-01-15T00:03:22.236Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/53e14be018a2284acf799830e8cd8e0b263c0fd3dff1ad7b35f8417e7067/flask_orjson-2.0.0-py3-none-any.whl", hash = "sha256:5d15f2ba94b8d6c02aee88fc156045016e83db9eda2c30545fabd640aebaec9d", size = 3622, upload-time = "2024-01-15T00:03:17.511Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-restful"
|
||||
version = "0.3.10"
|
||||
@@ -4554,17 +4569,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyobvector"
|
||||
version = "0.1.14"
|
||||
version = "0.2.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiomysql" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pymysql" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "sqlglot" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/59/7d762061808948dd6aad165a000b34e22163dc83fb5014184eeacc0fabe5/pyobvector-0.1.14.tar.gz", hash = "sha256:4f85cdd63064d040e94c0a96099a0cd5cda18ce625865382e89429f28422fc02", size = 26780, upload-time = "2024-11-20T11:46:18.017Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/7d/3f3aac6acf1fdd1782042d6eecd48efaa2ee355af0dbb61e93292d629391/pyobvector-0.2.15.tar.gz", hash = "sha256:5de258c1e952c88b385b5661e130c1cf8262c498c1f8a4a348a35962d379fce4", size = 39611, upload-time = "2025-08-18T02:49:26.683Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/68/ecb21b74c974e7be7f9034e205d08db62d614ff5c221581ae96d37ef853e/pyobvector-0.1.14-py3-none-any.whl", hash = "sha256:828e0bec49a177355b70c7a1270af3b0bf5239200ee0d096e4165b267eeff97c", size = 35526, upload-time = "2024-11-20T11:46:16.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/1f/a62754ba9b8a02c038d2a96cb641b71d3809f34d2ba4f921fecd7840d7fb/pyobvector-0.2.15-py3-none-any.whl", hash = "sha256:feeefe849ee5400e72a9a4d3844e425a58a99053dd02abe06884206923065ebb", size = 52680, upload-time = "2025-08-18T02:49:25.452Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5417,6 +5434,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlglot"
|
||||
version = "26.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/9d/fcd59b4612d5ad1e2257c67c478107f073b19e1097d3bfde2fb517884416/sqlglot-26.33.0.tar.gz", hash = "sha256:2817278779fa51d6def43aa0d70690b93a25c83eb18ec97130fdaf707abc0d73", size = 5353340, upload-time = "2025-07-01T13:09:06.311Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/8d/f1d9cb5b18e06aa45689fbeaaea6ebab66d5f01d1e65029a8f7657c06be5/sqlglot-26.33.0-py3-none-any.whl", hash = "sha256:031cee20c0c796a83d26d079a47fdce667604df430598c7eabfa4e4dfd147033", size = 477610, upload-time = "2025-07-01T13:09:03.926Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sseclient-py"
|
||||
version = "1.8.0"
|
||||
|
||||
+13
-1
@@ -264,6 +264,15 @@ REDIS_PORT=6379
|
||||
REDIS_USERNAME=
|
||||
REDIS_PASSWORD=difyai123456
|
||||
REDIS_USE_SSL=false
|
||||
# SSL configuration for Redis (when REDIS_USE_SSL=true)
|
||||
REDIS_SSL_CERT_REQS=CERT_NONE
|
||||
# Options: CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED
|
||||
REDIS_SSL_CA_CERTS=
|
||||
# Path to CA certificate file for SSL verification
|
||||
REDIS_SSL_CERTFILE=
|
||||
# Path to client certificate file for SSL authentication
|
||||
REDIS_SSL_KEYFILE=
|
||||
# Path to client private key file for SSL authentication
|
||||
REDIS_DB=0
|
||||
|
||||
# Whether to use Redis Sentinel mode.
|
||||
@@ -861,7 +870,7 @@ WORKFLOW_NODE_EXECUTION_STORAGE=rdbms
|
||||
|
||||
# Repository configuration
|
||||
# Core workflow execution repository implementation
|
||||
# Options:
|
||||
# Options:
|
||||
# - core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository (default)
|
||||
# - core.repositories.celery_workflow_execution_repository.CeleryWorkflowExecutionRepository
|
||||
CORE_WORKFLOW_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository
|
||||
@@ -1157,6 +1166,9 @@ MARKETPLACE_API_URL=https://marketplace.dify.ai
|
||||
|
||||
FORCE_VERIFYING_SIGNATURE=true
|
||||
|
||||
PLUGIN_STDIO_BUFFER_SIZE=1024
|
||||
PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880
|
||||
|
||||
PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT=600
|
||||
# PIP_MIRROR_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
@@ -181,6 +181,8 @@ services:
|
||||
FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true}
|
||||
PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
|
||||
PLUGIN_STDIO_BUFFER_SIZE: ${PLUGIN_STDIO_BUFFER_SIZE:-1024}
|
||||
PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
PLUGIN_STORAGE_TYPE: ${PLUGIN_STORAGE_TYPE:-local}
|
||||
PLUGIN_STORAGE_LOCAL_ROOT: ${PLUGIN_STORAGE_LOCAL_ROOT:-/app/storage}
|
||||
|
||||
@@ -71,6 +71,10 @@ x-shared-env: &shared-api-worker-env
|
||||
REDIS_USERNAME: ${REDIS_USERNAME:-}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-difyai123456}
|
||||
REDIS_USE_SSL: ${REDIS_USE_SSL:-false}
|
||||
REDIS_SSL_CERT_REQS: ${REDIS_SSL_CERT_REQS:-CERT_NONE}
|
||||
REDIS_SSL_CA_CERTS: ${REDIS_SSL_CA_CERTS:-}
|
||||
REDIS_SSL_CERTFILE: ${REDIS_SSL_CERTFILE:-}
|
||||
REDIS_SSL_KEYFILE: ${REDIS_SSL_KEYFILE:-}
|
||||
REDIS_DB: ${REDIS_DB:-0}
|
||||
REDIS_USE_SENTINEL: ${REDIS_USE_SENTINEL:-false}
|
||||
REDIS_SENTINELS: ${REDIS_SENTINELS:-}
|
||||
@@ -506,6 +510,8 @@ x-shared-env: &shared-api-worker-env
|
||||
MARKETPLACE_ENABLED: ${MARKETPLACE_ENABLED:-true}
|
||||
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
|
||||
FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true}
|
||||
PLUGIN_STDIO_BUFFER_SIZE: ${PLUGIN_STDIO_BUFFER_SIZE:-1024}
|
||||
PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880}
|
||||
PLUGIN_PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
@@ -747,6 +753,8 @@ services:
|
||||
FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true}
|
||||
PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
|
||||
PLUGIN_STDIO_BUFFER_SIZE: ${PLUGIN_STDIO_BUFFER_SIZE:-1024}
|
||||
PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
PLUGIN_STORAGE_TYPE: ${PLUGIN_STORAGE_TYPE:-local}
|
||||
PLUGIN_STORAGE_LOCAL_ROOT: ${PLUGIN_STORAGE_LOCAL_ROOT:-/app/storage}
|
||||
|
||||
+4
-1
@@ -6,7 +6,7 @@ LABEL maintainer="takatost@gmail.com"
|
||||
# RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
RUN npm install -g pnpm@10.13.1
|
||||
RUN corepack enable
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
@@ -19,6 +19,9 @@ WORKDIR /app/web
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
|
||||
# Use packageManager from package.json
|
||||
RUN corepack install
|
||||
|
||||
# if you located in China, you can use taobao registry to speed up
|
||||
# RUN pnpm install --frozen-lockfile --registry https://registry.npmmirror.com/
|
||||
|
||||
|
||||
@@ -216,8 +216,8 @@ const DatasetCard = ({
|
||||
}
|
||||
btnClassName={open =>
|
||||
cn(
|
||||
open ? '!bg-black/5 !shadow-none' : '!bg-transparent',
|
||||
'h-8 w-8 rounded-md border-none !p-2 hover:!bg-black/5',
|
||||
open ? '!bg-state-base-hover !shadow-none' : '!bg-transparent',
|
||||
'h-8 w-8 rounded-md border-none !p-2 hover:!bg-state-base-hover',
|
||||
)
|
||||
}
|
||||
className={'!z-20 h-fit !w-[128px]'}
|
||||
|
||||
@@ -13,7 +13,7 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import { AppType } from '@/types/app'
|
||||
import { getNewVar, getVars } from '@/utils/var'
|
||||
import AutomaticBtn from '@/app/components/app/configuration/config/automatic/automatic-btn'
|
||||
import type { AutomaticRes } from '@/service/debug'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
@@ -61,6 +61,7 @@ const Prompt: FC<ISimplePromptInput> = ({
|
||||
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const {
|
||||
appId,
|
||||
modelConfig,
|
||||
dataSets,
|
||||
setModelConfig,
|
||||
@@ -139,21 +140,21 @@ const Prompt: FC<ISimplePromptInput> = ({
|
||||
}
|
||||
|
||||
const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false)
|
||||
const handleAutomaticRes = (res: AutomaticRes) => {
|
||||
const handleAutomaticRes = (res: GenRes) => {
|
||||
// put eventEmitter in first place to prevent overwrite the configs.prompt_variables.But another problem is that prompt won't hight the prompt_variables.
|
||||
eventEmitter?.emit({
|
||||
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
|
||||
payload: res.prompt,
|
||||
payload: res.modified,
|
||||
} as any)
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.configs.prompt_template = res.prompt
|
||||
draft.configs.prompt_variables = res.variables.map(key => ({ key, name: key, type: 'string', required: true }))
|
||||
draft.configs.prompt_template = res.modified
|
||||
draft.configs.prompt_variables = (res.variables || []).map(key => ({ key, name: key, type: 'string', required: true }))
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
setPrevPromptConfig(modelConfig.configs)
|
||||
|
||||
if (mode !== AppType.completion) {
|
||||
setIntroduction(res.opening_statement)
|
||||
setIntroduction(res.opening_statement || '')
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.opening = {
|
||||
...draft.opening,
|
||||
@@ -272,10 +273,13 @@ const Prompt: FC<ISimplePromptInput> = ({
|
||||
|
||||
{showAutomatic && (
|
||||
<GetAutomaticResModal
|
||||
flowId={appId}
|
||||
mode={mode as AppType}
|
||||
isShow={showAutomatic}
|
||||
onClose={showAutomaticFalse}
|
||||
onFinished={handleAutomaticRes}
|
||||
currentPrompt={promptTemplate}
|
||||
isBasicMode
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useBoolean, useSessionStorageState } from 'ahooks'
|
||||
import {
|
||||
RiDatabase2Line,
|
||||
RiFileExcel2Line,
|
||||
@@ -14,24 +14,18 @@ import {
|
||||
RiTranslate,
|
||||
RiUser2Line,
|
||||
} from '@remixicon/react'
|
||||
import cn from 'classnames'
|
||||
import s from './style.module.css'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { generateRule } from '@/service/debug'
|
||||
import ConfigPrompt from '@/app/components/app/configuration/config-prompt'
|
||||
import { generateBasicAppFistTimeRule, generateRule } from '@/service/debug'
|
||||
import type { CompletionParams, Model } from '@/types/app'
|
||||
import { AppType } from '@/types/app'
|
||||
import ConfigVar from '@/app/components/app/configuration/config-var'
|
||||
import GroupName from '@/app/components/app/configuration/base/group-name'
|
||||
import type { AppType } from '@/types/app'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { LoveMessage } from '@/app/components/base/icons/src/vender/features'
|
||||
|
||||
// type
|
||||
import type { AutomaticRes } from '@/service/debug'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
|
||||
@@ -39,13 +33,25 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import type { ModelModeType } from '@/types/app'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import InstructionEditorInWorkflow from './instruction-editor-in-workflow'
|
||||
import InstructionEditorInBasic from './instruction-editor'
|
||||
import { GeneratorType } from './types'
|
||||
import Result from './result'
|
||||
import useGenData from './use-gen-data'
|
||||
import IdeaOutput from './idea-output'
|
||||
import ResPlaceholder from './res-placeholder'
|
||||
import { useGenerateRuleTemplate } from '@/service/use-apps'
|
||||
|
||||
const i18nPrefix = 'appDebug.generate'
|
||||
export type IGetAutomaticResProps = {
|
||||
mode: AppType
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
onFinished: (res: AutomaticRes) => void
|
||||
isInLLMNode?: boolean
|
||||
onFinished: (res: GenRes) => void
|
||||
flowId?: string
|
||||
nodeId?: string
|
||||
currentPrompt?: string
|
||||
isBasicMode?: boolean
|
||||
}
|
||||
|
||||
const TryLabel: FC<{
|
||||
@@ -68,7 +74,10 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
mode,
|
||||
isShow,
|
||||
onClose,
|
||||
isInLLMNode,
|
||||
flowId,
|
||||
nodeId,
|
||||
currentPrompt,
|
||||
isBasicMode,
|
||||
onFinished,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -123,13 +132,27 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
},
|
||||
]
|
||||
|
||||
const [instruction, setInstruction] = useState<string>('')
|
||||
const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}`}`)
|
||||
const instruction = instructionFromSessionStorage || ''
|
||||
const [ideaOutput, setIdeaOutput] = useState<string>('')
|
||||
|
||||
const [editorKey, setEditorKey] = useState(`${flowId}-0`)
|
||||
const handleChooseTemplate = useCallback((key: string) => {
|
||||
return () => {
|
||||
const template = t(`appDebug.generate.template.${key}.instruction`)
|
||||
setInstruction(template)
|
||||
setEditorKey(`${flowId}-${Date.now()}`)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const { data: instructionTemplate } = useGenerateRuleTemplate(GeneratorType.prompt, isBasicMode)
|
||||
useEffect(() => {
|
||||
if (!instruction && instructionTemplate)
|
||||
setInstruction(instructionTemplate.data)
|
||||
|
||||
setEditorKey(`${flowId}-${Date.now()}`)
|
||||
}, [instructionTemplate])
|
||||
|
||||
const isValid = () => {
|
||||
if (instruction.trim() === '') {
|
||||
Toast.notify({
|
||||
@@ -143,7 +166,10 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
return true
|
||||
}
|
||||
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
|
||||
const [res, setRes] = useState<AutomaticRes | null>(null)
|
||||
const storageKey = `${flowId}${isBasicMode ? '' : `-${nodeId}`}`
|
||||
const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenData({
|
||||
storageKey,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultModel) {
|
||||
@@ -170,16 +196,6 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderNoData = (
|
||||
<div className='flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8'>
|
||||
<Generator className='h-14 w-14 text-text-tertiary' />
|
||||
<div className='text-center text-[13px] font-normal leading-5 text-text-tertiary'>
|
||||
<div>{t('appDebug.generate.noDataLine1')}</div>
|
||||
<div>{t('appDebug.generate.noDataLine2')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleModelChange = useCallback((newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
@@ -207,28 +223,59 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
return
|
||||
setLoadingTrue()
|
||||
try {
|
||||
const { error, ...res } = await generateRule({
|
||||
instruction,
|
||||
model_config: model,
|
||||
no_variable: !!isInLLMNode,
|
||||
})
|
||||
setRes(res)
|
||||
if (error) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error,
|
||||
let apiRes: GenRes
|
||||
let hasError = false
|
||||
if (isBasicMode || !currentPrompt) {
|
||||
const { error, ...res } = await generateBasicAppFistTimeRule({
|
||||
instruction,
|
||||
model_config: model,
|
||||
no_variable: false,
|
||||
})
|
||||
apiRes = {
|
||||
...res,
|
||||
modified: res.prompt,
|
||||
} as GenRes
|
||||
if (error) {
|
||||
hasError = true
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
const { error, ...res } = await generateRule({
|
||||
flow_id: flowId,
|
||||
node_id: nodeId,
|
||||
current: currentPrompt,
|
||||
instruction,
|
||||
ideal_output: ideaOutput,
|
||||
model_config: model,
|
||||
})
|
||||
apiRes = res
|
||||
if (error) {
|
||||
hasError = true
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!hasError)
|
||||
addVersion(apiRes)
|
||||
}
|
||||
finally {
|
||||
setLoadingFalse()
|
||||
}
|
||||
}
|
||||
|
||||
const [showConfirmOverwrite, setShowConfirmOverwrite] = React.useState(false)
|
||||
const [isShowConfirmOverwrite, {
|
||||
setTrue: showConfirmOverwrite,
|
||||
setFalse: hideShowConfirmOverwrite,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const isShowAutoPromptResPlaceholder = () => {
|
||||
return !isLoading && !res
|
||||
return !isLoading && !current
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -236,15 +283,14 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
className='min-w-[1140px] !p-0'
|
||||
closable
|
||||
>
|
||||
<div className='flex h-[680px] flex-wrap'>
|
||||
<div className='h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6'>
|
||||
<div className='mb-8'>
|
||||
<div className='mb-5'>
|
||||
<div className={`text-lg font-bold leading-[28px] ${s.textGradient}`}>{t('appDebug.generate.title')}</div>
|
||||
<div className='mt-1 text-[13px] font-normal text-text-tertiary'>{t('appDebug.generate.description')}</div>
|
||||
</div>
|
||||
<div className='mb-8'>
|
||||
<div>
|
||||
<ModelParameterModal
|
||||
popupClassName='!w-[520px]'
|
||||
portalToFollowElemContentClassName='z-[1000]'
|
||||
@@ -258,116 +304,99 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
<div >
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-3 shrink-0 text-xs font-semibold uppercase leading-[18px] text-text-tertiary'>{t('appDebug.generate.tryIt')}</div>
|
||||
<div className='h-px grow' style={{
|
||||
background: 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))',
|
||||
}}></div>
|
||||
</div>
|
||||
<div className='flex flex-wrap'>
|
||||
{tryList.map(item => (
|
||||
<TryLabel
|
||||
key={item.key}
|
||||
Icon={item.icon}
|
||||
text={t(`appDebug.generate.template.${item.key}.name`)}
|
||||
onClick={handleChooseTemplate(item.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* inputs */}
|
||||
<div className='mt-6'>
|
||||
<div className='text-[0px]'>
|
||||
<div className='mb-2 text-sm font-medium leading-5 text-text-primary'>{t('appDebug.generate.instruction')}</div>
|
||||
<Textarea
|
||||
className="h-[200px] resize-none"
|
||||
placeholder={t('appDebug.generate.instructionPlaceHolder') as string}
|
||||
value={instruction}
|
||||
onChange={e => setInstruction(e.target.value)} />
|
||||
{isBasicMode && (
|
||||
<div className='mt-4'>
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-3 shrink-0 text-xs font-semibold uppercase leading-[18px] text-text-tertiary'>{t('appDebug.generate.tryIt')}</div>
|
||||
<div className='h-px grow' style={{
|
||||
background: 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))',
|
||||
}}></div>
|
||||
</div>
|
||||
<div className='flex flex-wrap'>
|
||||
{tryList.map(item => (
|
||||
<TryLabel
|
||||
key={item.key}
|
||||
Icon={item.icon}
|
||||
text={t(`appDebug.generate.template.${item.key}.name`)}
|
||||
onClick={handleChooseTemplate(item.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-5 flex justify-end'>
|
||||
{/* inputs */}
|
||||
<div className='mt-4'>
|
||||
<div>
|
||||
<div className='system-sm-semibold-uppercase mb-1.5 text-text-secondary'>{t('appDebug.generate.instruction')}</div>
|
||||
{isBasicMode ? (
|
||||
<InstructionEditorInBasic
|
||||
editorKey={editorKey}
|
||||
generatorType={GeneratorType.prompt}
|
||||
value={instruction}
|
||||
onChange={setInstruction}
|
||||
availableVars={[]}
|
||||
availableNodes={[]}
|
||||
isShowCurrentBlock={!!currentPrompt}
|
||||
isShowLastRunBlock={false}
|
||||
/>
|
||||
) : (
|
||||
<InstructionEditorInWorkflow
|
||||
editorKey={editorKey}
|
||||
generatorType={GeneratorType.prompt}
|
||||
value={instruction}
|
||||
onChange={setInstruction}
|
||||
nodeId={nodeId || ''}
|
||||
isShowCurrentBlock={!!currentPrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<IdeaOutput
|
||||
value={ideaOutput}
|
||||
onChange={setIdeaOutput}
|
||||
/>
|
||||
|
||||
<div className='mt-7 flex justify-end space-x-2'>
|
||||
<Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`)}</Button>
|
||||
<Button
|
||||
className='flex space-x-1'
|
||||
variant='primary'
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Generator className='h-4 w-4 text-white' />
|
||||
<span className='text-xs font-semibold text-white'>{t('appDebug.generate.generate')}</span>
|
||||
<Generator className='h-4 w-4' />
|
||||
<span className='text-xs font-semibold'>{t('appDebug.generate.generate')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(!isLoading && res) && (
|
||||
<div className='h-full w-0 grow p-6 pb-0'>
|
||||
<div className='mb-3 shrink-0 text-base font-semibold leading-[160%] text-text-secondary'>{t('appDebug.generate.resTitle')}</div>
|
||||
<div className={cn('max-h-[555px] overflow-y-auto', !isInLLMNode && 'pb-2')}>
|
||||
<ConfigPrompt
|
||||
mode={mode}
|
||||
promptTemplate={res?.prompt || ''}
|
||||
promptVariables={[]}
|
||||
readonly
|
||||
noTitle={isInLLMNode}
|
||||
gradientBorder
|
||||
editorHeight={isInLLMNode ? 524 : 0}
|
||||
noResize={isInLLMNode}
|
||||
/>
|
||||
{!isInLLMNode && (
|
||||
<>
|
||||
{(res?.variables?.length && res?.variables?.length > 0)
|
||||
? (
|
||||
<ConfigVar
|
||||
promptVariables={res?.variables.map(key => ({ key, name: key, type: 'string', required: true })) || []}
|
||||
readonly
|
||||
/>
|
||||
)
|
||||
: ''}
|
||||
|
||||
{(mode !== AppType.completion && res?.opening_statement) && (
|
||||
<div className='mt-7'>
|
||||
<GroupName name={t('appDebug.feature.groupChat.title')} />
|
||||
<div
|
||||
className='mb-1 rounded-xl border-l-[0.5px] border-t-[0.5px] border-effects-highlight bg-background-section-burn p-3'
|
||||
>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<div className='shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs'>
|
||||
<LoveMessage className='h-4 w-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
<div className='system-sm-semibold flex grow items-center text-text-secondary'>
|
||||
{t('appDebug.feature.conversationOpener.title')}
|
||||
</div>
|
||||
</div>
|
||||
<div className='system-xs-regular min-h-8 text-text-tertiary'>{res.opening_statement}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end bg-background-default py-4'>
|
||||
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' className='ml-2' onClick={() => {
|
||||
setShowConfirmOverwrite(true)
|
||||
}}>{t('appDebug.generate.apply')}</Button>
|
||||
</div>
|
||||
{(!isLoading && current) && (
|
||||
<div className='h-full w-0 grow bg-background-default-subtle p-6 pb-0'>
|
||||
<Result
|
||||
current={current!}
|
||||
isBasicMode={isBasicMode}
|
||||
nodeId={nodeId!}
|
||||
currentVersionIndex={currentVersionIndex || 0}
|
||||
setCurrentVersionIndex={setCurrentVersionIndex}
|
||||
versions={versions || []}
|
||||
onApply={showConfirmOverwrite}
|
||||
generatorType={GeneratorType.prompt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && renderLoading}
|
||||
{isShowAutoPromptResPlaceholder() && renderNoData}
|
||||
{showConfirmOverwrite && (
|
||||
{isShowAutoPromptResPlaceholder() && <ResPlaceholder />}
|
||||
{isShowConfirmOverwrite && (
|
||||
<Confirm
|
||||
title={t('appDebug.generate.overwriteTitle')}
|
||||
content={t('appDebug.generate.overwriteMessage')}
|
||||
isShow={showConfirmOverwrite}
|
||||
isShow
|
||||
onConfirm={() => {
|
||||
setShowConfirmOverwrite(false)
|
||||
onFinished(res!)
|
||||
hideShowConfirmOverwrite()
|
||||
onFinished(current!)
|
||||
}}
|
||||
onCancel={() => setShowConfirmOverwrite(false)}
|
||||
onCancel={hideShowConfirmOverwrite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const i18nPrefix = 'appDebug.generate'
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const IdeaOutput: FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isFoldIdeaOutput, {
|
||||
toggle: toggleFoldIdeaOutput,
|
||||
}] = useBoolean(true)
|
||||
|
||||
return (
|
||||
<div className='mt-4 text-[0px]'>
|
||||
<div
|
||||
className='mb-1.5 flex cursor-pointer items-center text-sm font-medium leading-5 text-text-primary'
|
||||
onClick={toggleFoldIdeaOutput}
|
||||
>
|
||||
<div className='system-sm-semibold-uppercase mr-1 text-text-secondary'>{t(`${i18nPrefix}.idealOutput`)}</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>({t(`${i18nPrefix}.optional`)})</div>
|
||||
<ArrowDownRoundFill className={cn('size text-text-quaternary', isFoldIdeaOutput && 'relative top-[1px] rotate-[-90deg]')} />
|
||||
</div>
|
||||
{!isFoldIdeaOutput && (
|
||||
<Textarea
|
||||
className="h-[80px]"
|
||||
placeholder={t(`${i18nPrefix}.idealOutputPlaceholder`)}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(IdeaOutput)
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import type { GeneratorType } from './types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
|
||||
import InstructionEditor from './instruction-editor'
|
||||
import { useWorkflowVariableType } from '@/app/components/workflow/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
value: string
|
||||
editorKey: string
|
||||
onChange: (text: string) => void
|
||||
generatorType: GeneratorType
|
||||
isShowCurrentBlock: boolean
|
||||
}
|
||||
|
||||
const InstructionEditorInWorkflow: FC<Props> = ({
|
||||
nodeId,
|
||||
value,
|
||||
editorKey,
|
||||
onChange,
|
||||
generatorType,
|
||||
isShowCurrentBlock,
|
||||
}) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const filterVar = useCallback((payload: Var, selector: ValueSelector) => {
|
||||
const { nodesWithInspectVars } = workflowStore.getState()
|
||||
const nodeId = selector?.[0]
|
||||
return !!nodesWithInspectVars.find(node => node.nodeId === nodeId) && payload.type !== VarType.file && payload.type !== VarType.arrayFile
|
||||
}, [workflowStore])
|
||||
const {
|
||||
availableVars,
|
||||
availableNodes,
|
||||
} = useAvailableVarList(nodeId, {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar,
|
||||
})
|
||||
const getVarType = useWorkflowVariableType()
|
||||
|
||||
return (
|
||||
<InstructionEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
editorKey={editorKey}
|
||||
generatorType={generatorType}
|
||||
availableVars={availableVars}
|
||||
availableNodes={availableNodes}
|
||||
getVarType={getVarType}
|
||||
isShowCurrentBlock={isShowCurrentBlock}
|
||||
isShowLastRunBlock
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(InstructionEditorInWorkflow)
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import type { GeneratorType } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
|
||||
type Props = {
|
||||
editorKey: string
|
||||
value: string
|
||||
onChange: (text: string) => void
|
||||
generatorType: GeneratorType
|
||||
availableVars: NodeOutPutVar[]
|
||||
availableNodes: Node[]
|
||||
getVarType?: (params: {
|
||||
nodeId: string,
|
||||
valueSelector: ValueSelector,
|
||||
}) => Type
|
||||
isShowCurrentBlock: boolean
|
||||
isShowLastRunBlock: boolean
|
||||
}
|
||||
|
||||
const i18nPrefix = 'appDebug.generate'
|
||||
|
||||
const InstructionEditor: FC<Props> = ({
|
||||
editorKey,
|
||||
generatorType,
|
||||
value,
|
||||
onChange,
|
||||
availableVars,
|
||||
availableNodes,
|
||||
getVarType = () => Type.string,
|
||||
isShowCurrentBlock,
|
||||
isShowLastRunBlock,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const isCode = generatorType === 'code'
|
||||
const placeholder = isCode ? <div className='system-sm-regular whitespace-break-spaces !leading-6 text-text-placeholder'>
|
||||
{t(`${i18nPrefix}.codeGenInstructionPlaceHolderLine`)}
|
||||
</div> : (
|
||||
<div className='system-sm-regular text-text-placeholder'>
|
||||
<div className='leading-6'>{t(`${i18nPrefix}.instructionPlaceHolderTitle`)}</div>
|
||||
<div className='mt-2'>
|
||||
<div>{t(`${i18nPrefix}.instructionPlaceHolderLine1`)}</div>
|
||||
<div>{t(`${i18nPrefix}.instructionPlaceHolderLine2`)}</div>
|
||||
<div>{t(`${i18nPrefix}.instructionPlaceHolderLine3`)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleInsertVariable = () => {
|
||||
eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId: editorKey } as any)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<PromptEditor
|
||||
wrapperClassName='border !border-components-input-bg-normal bg-components-input-bg-normal hover:!border-components-input-bg-hover rounded-[10px] px-4 pt-3'
|
||||
key={editorKey}
|
||||
instanceId={editorKey}
|
||||
placeholder={placeholder}
|
||||
placeholderClassName='px-4 pt-3'
|
||||
className={cn('min-h-[240px] pb-8')}
|
||||
value={value}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: availableVars,
|
||||
getVarType,
|
||||
workflowNodesMap: availableNodes.reduce((acc, node) => {
|
||||
acc[node.id] = {
|
||||
title: node.data.title,
|
||||
type: node.data.type,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
position: node.position,
|
||||
}
|
||||
if (node.data.type === BlockEnum.Start) {
|
||||
acc.sys = {
|
||||
title: t('workflow.blocks.start'),
|
||||
type: BlockEnum.Start,
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {} as any),
|
||||
}}
|
||||
currentBlock={{
|
||||
show: isShowCurrentBlock,
|
||||
generatorType,
|
||||
}}
|
||||
errorMessageBlock={{
|
||||
show: isCode,
|
||||
}}
|
||||
lastRunBlock={{
|
||||
show: isShowLastRunBlock,
|
||||
}}
|
||||
onChange={onChange}
|
||||
editable
|
||||
isSupportFileVar={false}
|
||||
/>
|
||||
<div className='system-xs-regular absolute bottom-0 left-4 flex h-8 items-center space-x-0.5 text-components-input-text-placeholder'>
|
||||
<span>{t('appDebug.generate.press')}</span>
|
||||
<span className='system-kbd flex h-4 w-3.5 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray text-text-placeholder'>/</span>
|
||||
<span>{t('appDebug.generate.to')}</span>
|
||||
<span onClick={handleInsertVariable} className='!ml-1 cursor-pointer hover:border-b hover:border-dotted hover:border-text-tertiary hover:text-text-tertiary'>{t('appDebug.generate.insertContext')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(InstructionEditor)
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import PromptRes from './prompt-res'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
nodeId: string
|
||||
}
|
||||
|
||||
const PromptResInWorkflow: FC<Props> = ({
|
||||
value,
|
||||
nodeId,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
availableVars,
|
||||
availableNodes,
|
||||
} = useAvailableVarList(nodeId, {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar: _payload => true,
|
||||
})
|
||||
return (
|
||||
<PromptRes
|
||||
value={value}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: availableVars || [],
|
||||
getVarType: () => Type.string,
|
||||
workflowNodesMap: availableNodes.reduce((acc, node) => {
|
||||
acc[node.id] = {
|
||||
title: node.data.title,
|
||||
type: node.data.type,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
position: node.position,
|
||||
}
|
||||
if (node.data.type === BlockEnum.Start) {
|
||||
acc.sys = {
|
||||
title: t('workflow.blocks.start'),
|
||||
type: BlockEnum.Start,
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {} as any),
|
||||
}}
|
||||
>
|
||||
</PromptRes>
|
||||
)
|
||||
}
|
||||
export default React.memo(PromptResInWorkflow)
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import type { WorkflowVariableBlockType } from '@/app/components/base/prompt-editor/types'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
workflowVariableBlock: WorkflowVariableBlockType
|
||||
}
|
||||
|
||||
const keyIdPrefix = 'prompt-res-editor'
|
||||
const PromptRes: FC<Props> = ({
|
||||
value,
|
||||
workflowVariableBlock,
|
||||
}) => {
|
||||
const [editorKey, setEditorKey] = React.useState<string>('keyIdPrefix-0')
|
||||
useEffect(() => {
|
||||
setEditorKey(`${keyIdPrefix}-${Date.now()}`)
|
||||
}, [value])
|
||||
return (
|
||||
<PromptEditor
|
||||
key={editorKey}
|
||||
value={value}
|
||||
editable={false}
|
||||
className='h-full bg-transparent pt-0'
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(PromptRes)
|
||||
@@ -0,0 +1,54 @@
|
||||
import { RiArrowDownSLine, RiSparklingFill } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import s from './style.module.css'
|
||||
|
||||
type Props = {
|
||||
message: string
|
||||
className?: string
|
||||
}
|
||||
const PromptToast = ({
|
||||
message,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [isFold, {
|
||||
toggle: toggleFold,
|
||||
}] = useBoolean(false)
|
||||
// const message = `
|
||||
// list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1
|
||||
// # h1
|
||||
// **strong text** ~~strikethrough~~
|
||||
|
||||
// * list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1
|
||||
// * list2
|
||||
|
||||
// xxxx
|
||||
|
||||
// ## h2
|
||||
// \`\`\`python
|
||||
// print('Hello, World!')
|
||||
// \`\`\`
|
||||
// `
|
||||
return (
|
||||
<div className={cn('rounded-xl border-[0.5px] border-components-panel-border bg-background-section-burn pl-4 shadow-xs', className)}>
|
||||
<div className='my-3 flex h-4 items-center justify-between pr-3'>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<RiSparklingFill className='size-3.5 text-components-input-border-active-prompt-1' />
|
||||
<span className={cn(s.optimizationNoteText, 'system-xs-semibold-uppercase')}>{t('appDebug.generate.optimizationNote')}</span>
|
||||
</div>
|
||||
<RiArrowDownSLine className={cn('size-4 cursor-pointer text-text-tertiary', isFold && 'rotate-[-90deg]')} onClick={toggleFold} />
|
||||
</div>
|
||||
{!isFold && (
|
||||
<div className='pb-4 pr-4'>
|
||||
<Markdown className="!text-sm" content={message} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptToast
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ResPlaceholder: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8'>
|
||||
<Generator className='size-8 text-text-quaternary' />
|
||||
<div className='text-center text-[13px] font-normal leading-5 text-text-tertiary'>
|
||||
<div>{t('appDebug.generate.newNoDataLine1')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ResPlaceholder)
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GeneratorType } from './types'
|
||||
import PromptToast from './prompt-toast'
|
||||
import Button from '@/app/components/base/button'
|
||||
import VersionSelector from './version-selector'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import { RiClipboardLine } from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor'
|
||||
import PromptRes from './prompt-res'
|
||||
import PromptResInWorkflow from './prompt-res-in-workflow'
|
||||
|
||||
type Props = {
|
||||
isBasicMode?: boolean
|
||||
nodeId?: string
|
||||
current: GenRes
|
||||
currentVersionIndex: number
|
||||
setCurrentVersionIndex: (index: number) => void
|
||||
versions: GenRes[]
|
||||
onApply: () => void
|
||||
generatorType: GeneratorType
|
||||
}
|
||||
|
||||
const Result: FC<Props> = ({
|
||||
isBasicMode,
|
||||
nodeId,
|
||||
current,
|
||||
currentVersionIndex,
|
||||
setCurrentVersionIndex,
|
||||
versions,
|
||||
onApply,
|
||||
generatorType,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isGeneratorPrompt = generatorType === GeneratorType.prompt
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='mb-3 flex shrink-0 items-center justify-between'>
|
||||
<div>
|
||||
<div className='shrink-0 text-base font-semibold leading-[160%] text-text-secondary'>{t('appDebug.generate.resTitle')}</div>
|
||||
<VersionSelector
|
||||
versionLen={versions.length}
|
||||
value={currentVersionIndex}
|
||||
onChange={setCurrentVersionIndex}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button className='px-2' onClick={() => {
|
||||
copy(current.modified)
|
||||
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
|
||||
}}>
|
||||
<RiClipboardLine className='h-4 w-4 text-text-secondary' />
|
||||
</Button>
|
||||
<Button variant='primary' onClick={onApply}>
|
||||
{t('appDebug.generate.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex grow flex-col overflow-y-auto'>
|
||||
{
|
||||
current?.message && (
|
||||
<PromptToast message={current.message} className='mb-3 shrink-0' />
|
||||
)
|
||||
}
|
||||
<div className='grow pb-6'>
|
||||
{isGeneratorPrompt ? (
|
||||
isBasicMode ? (
|
||||
<PromptRes
|
||||
value={current?.modified}
|
||||
workflowVariableBlock={{
|
||||
show: false,
|
||||
}}
|
||||
/>
|
||||
) : (<PromptResInWorkflow
|
||||
value={current?.modified || ''}
|
||||
nodeId={nodeId!}
|
||||
/>)
|
||||
) : (
|
||||
<CodeEditor
|
||||
editorWrapperClassName='h-full'
|
||||
className='bg-transparent pt-0'
|
||||
value={current?.modified}
|
||||
readOnly
|
||||
hideTopMenu
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Result)
|
||||
@@ -3,5 +3,11 @@
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.optimizationNoteText {
|
||||
background: linear-gradient(263deg, rgba(21, 90, 239, 0.95) -20.92%, rgba(11, 165, 236, 0.95) 87.04%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum GeneratorType {
|
||||
prompt = 'prompt',
|
||||
code = 'code',
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import { useSessionStorageState } from 'ahooks'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
type Params = {
|
||||
storageKey: string
|
||||
}
|
||||
const keyPrefix = 'gen-data-'
|
||||
const useGenData = ({ storageKey }: Params) => {
|
||||
const [versions, setVersions] = useSessionStorageState<GenRes[]>(`${keyPrefix}${storageKey}-versions`, {
|
||||
defaultValue: [],
|
||||
})
|
||||
|
||||
const [currentVersionIndex, setCurrentVersionIndex] = useSessionStorageState<number>(`${keyPrefix}${storageKey}-version-index`, {
|
||||
defaultValue: 0,
|
||||
})
|
||||
|
||||
const current = versions?.[currentVersionIndex || 0]
|
||||
|
||||
const addVersion = useCallback((version: GenRes) => {
|
||||
setCurrentVersionIndex(() => versions?.length || 0)
|
||||
setVersions((prev) => {
|
||||
return [...prev!, version]
|
||||
})
|
||||
}, [setVersions, setCurrentVersionIndex, versions?.length])
|
||||
|
||||
return {
|
||||
versions,
|
||||
addVersion,
|
||||
currentVersionIndex,
|
||||
setCurrentVersionIndex,
|
||||
current,
|
||||
}
|
||||
}
|
||||
|
||||
export default useGenData
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Option = {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
type VersionSelectorProps = {
|
||||
versionLen: number;
|
||||
value: number;
|
||||
onChange: (index: number) => void;
|
||||
}
|
||||
|
||||
const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, onChange }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isOpen, {
|
||||
setFalse: handleOpenFalse,
|
||||
toggle: handleOpenToggle,
|
||||
set: handleOpenSet,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const moreThanOneVersion = versionLen > 1
|
||||
const handleOpen = useCallback((value: boolean) => {
|
||||
if (moreThanOneVersion)
|
||||
handleOpenSet(value)
|
||||
}, [moreThanOneVersion, handleOpenToggle])
|
||||
const handleToggle = useCallback(() => {
|
||||
if (moreThanOneVersion)
|
||||
handleOpenToggle()
|
||||
}, [moreThanOneVersion, handleOpenToggle])
|
||||
|
||||
const versions = Array.from({ length: versionLen }, (_, index) => ({
|
||||
label: `${t('appDebug.generate.version')} ${index + 1}${index === versionLen - 1 ? ` · ${t('appDebug.generate.latest')}` : ''}`,
|
||||
value: index,
|
||||
}))
|
||||
|
||||
const isLatest = value === versionLen - 1
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={'bottom-start'}
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -12,
|
||||
}}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={handleToggle}
|
||||
asChild
|
||||
>
|
||||
|
||||
<div className={cn('system-xs-medium flex items-center text-text-tertiary', isOpen && 'text-text-secondary', moreThanOneVersion && 'cursor-pointer')}>
|
||||
<div>{t('appDebug.generate.version')} {value + 1}{isLatest && ` · ${t('appDebug.generate.latest')}`}</div>
|
||||
{moreThanOneVersion && <RiArrowDownSLine className='size-3 ' />}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger >
|
||||
<PortalToFollowElemContent className={cn(
|
||||
'z-[99]',
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'w-[208px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg',
|
||||
)}
|
||||
>
|
||||
<div className={cn('system-xs-medium-uppercase flex h-[22px] items-center px-3 pl-3 text-text-tertiary')}>
|
||||
{t('appDebug.generate.versions')}
|
||||
</div>
|
||||
{
|
||||
versions.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'system-sm-medium flex h-7 cursor-pointer items-center rounded-lg px-2 text-text-secondary hover:bg-state-base-hover',
|
||||
)}
|
||||
title={option.label}
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
handleOpenFalse()
|
||||
}}
|
||||
>
|
||||
<div className='mr-1 grow truncate px-1 pl-1'>
|
||||
{option.label}
|
||||
</div>
|
||||
{
|
||||
value === option.value && <RiCheckLine className='h-4 w-4 shrink-0 text-text-accent' />
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem >
|
||||
)
|
||||
}
|
||||
|
||||
export default VersionSelector
|
||||
+90
-84
@@ -1,16 +1,13 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import cn from 'classnames'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import useBoolean from 'ahooks/lib/useBoolean'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ConfigPrompt from '../../config-prompt'
|
||||
import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index'
|
||||
import { generateRuleCode } from '@/service/debug'
|
||||
import type { CodeGenRes } from '@/service/debug'
|
||||
import { generateRule } from '@/service/debug'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import type { ModelModeType } from '@/types/app'
|
||||
import type { AppType, CompletionParams, Model } from '@/types/app'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
@@ -21,17 +18,33 @@ import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/com
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import IdeaOutput from '../automatic/idea-output'
|
||||
import { GeneratorType } from '../automatic/types'
|
||||
import InstructionEditor from '../automatic/instruction-editor-in-workflow'
|
||||
import useGenData from '../automatic/use-gen-data'
|
||||
import Result from '../automatic/result'
|
||||
import ResPlaceholder from '../automatic/res-placeholder'
|
||||
import { useGenerateRuleTemplate } from '@/service/use-apps'
|
||||
import { useSessionStorageState } from 'ahooks'
|
||||
import s from '../automatic/style.module.css'
|
||||
|
||||
const i18nPrefix = 'appDebug.generate'
|
||||
export type IGetCodeGeneratorResProps = {
|
||||
flowId: string
|
||||
nodeId: string
|
||||
currentCode?: string
|
||||
mode: AppType
|
||||
isShow: boolean
|
||||
codeLanguages: CodeLanguage
|
||||
onClose: () => void
|
||||
onFinished: (res: CodeGenRes) => void
|
||||
onFinished: (res: GenRes) => void
|
||||
}
|
||||
|
||||
export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
{
|
||||
flowId,
|
||||
nodeId,
|
||||
currentCode,
|
||||
mode,
|
||||
isShow,
|
||||
codeLanguages,
|
||||
@@ -61,9 +74,25 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
const {
|
||||
defaultModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
const [instruction, setInstruction] = React.useState<string>('')
|
||||
const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}-${nodeId}`)
|
||||
const instruction = instructionFromSessionStorage || ''
|
||||
|
||||
const [ideaOutput, setIdeaOutput] = useState<string>('')
|
||||
|
||||
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
|
||||
const [res, setRes] = React.useState<CodeGenRes | null>(null)
|
||||
const storageKey = `${flowId}-${nodeId}`
|
||||
const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenData({
|
||||
storageKey,
|
||||
})
|
||||
const [editorKey, setEditorKey] = useState(`${flowId}-0`)
|
||||
const { data: instructionTemplate } = useGenerateRuleTemplate(GeneratorType.code)
|
||||
useEffect(() => {
|
||||
if (!instruction && instructionTemplate)
|
||||
setInstruction(instructionTemplate.data)
|
||||
|
||||
setEditorKey(`${flowId}-${Date.now()}`)
|
||||
}, [instructionTemplate])
|
||||
|
||||
const isValid = () => {
|
||||
if (instruction.trim() === '') {
|
||||
Toast.notify({
|
||||
@@ -97,7 +126,6 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
|
||||
const isInLLMNode = true
|
||||
const onGenerate = async () => {
|
||||
if (!isValid())
|
||||
return
|
||||
@@ -105,25 +133,37 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
return
|
||||
setLoadingTrue()
|
||||
try {
|
||||
const { error, ...res } = await generateRuleCode({
|
||||
const { error, ...res } = await generateRule({
|
||||
flow_id: flowId,
|
||||
node_id: nodeId,
|
||||
current: currentCode,
|
||||
instruction,
|
||||
model_config: model,
|
||||
no_variable: !!isInLLMNode,
|
||||
code_language: languageMap[codeLanguages] || 'javascript',
|
||||
ideal_output: ideaOutput,
|
||||
language: languageMap[codeLanguages] || 'javascript',
|
||||
})
|
||||
setRes(res)
|
||||
if(!currentCode)
|
||||
res.modified = (res as any).code
|
||||
|
||||
if (error) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error,
|
||||
})
|
||||
}
|
||||
else {
|
||||
addVersion(res)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setLoadingFalse()
|
||||
}
|
||||
}
|
||||
const [showConfirmOverwrite, setShowConfirmOverwrite] = React.useState(false)
|
||||
|
||||
const [isShowConfirmOverwrite, {
|
||||
setTrue: showConfirmOverwrite,
|
||||
setFalse: hideShowConfirmOverwrite,
|
||||
}] = useBoolean(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultModel) {
|
||||
@@ -155,30 +195,20 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
<div className='text-[13px] text-text-tertiary'>{t('appDebug.codegen.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
const renderNoData = (
|
||||
<div className='flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8'>
|
||||
<Generator className='h-14 w-14 text-text-tertiary' />
|
||||
<div className='text-center text-[13px] font-normal leading-5 text-text-tertiary'>
|
||||
<div>{t('appDebug.codegen.noDataLine1')}</div>
|
||||
<div>{t('appDebug.codegen.noDataLine2')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
className='min-w-[1140px] !p-0'
|
||||
closable
|
||||
>
|
||||
<div className='relative flex h-[680px] flex-wrap'>
|
||||
<div className='h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-8'>
|
||||
<div className='mb-8'>
|
||||
<div className={'text-lg font-bold leading-[28px] text-text-primary'}>{t('appDebug.codegen.title')}</div>
|
||||
<div className='h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6'>
|
||||
<div className='mb-5'>
|
||||
<div className={`text-lg font-bold leading-[28px] ${s.textGradient}`}>{t('appDebug.codegen.title')}</div>
|
||||
<div className='mt-1 text-[13px] font-normal text-text-tertiary'>{t('appDebug.codegen.description')}</div>
|
||||
</div>
|
||||
<div className='mb-8'>
|
||||
<div className='mb-4'>
|
||||
<ModelParameterModal
|
||||
popupClassName='!w-[520px]'
|
||||
portalToFollowElemContentClassName='z-[1000]'
|
||||
@@ -194,84 +224,60 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
</div>
|
||||
<div>
|
||||
<div className='text-[0px]'>
|
||||
<div className='mb-2 text-sm font-medium leading-5 text-text-primary'>{t('appDebug.codegen.instruction')}</div>
|
||||
<Textarea
|
||||
className="h-[200px] resize-none"
|
||||
placeholder={t('appDebug.codegen.instructionPlaceholder') || ''}
|
||||
<div className='system-sm-semibold-uppercase mb-1.5 text-text-secondary'>{t('appDebug.codegen.instruction')}</div>
|
||||
<InstructionEditor
|
||||
editorKey={editorKey}
|
||||
value={instruction}
|
||||
onChange={e => setInstruction(e.target.value)}
|
||||
onChange={setInstruction}
|
||||
nodeId={nodeId}
|
||||
generatorType={GeneratorType.code}
|
||||
isShowCurrentBlock={!!currentCode}
|
||||
/>
|
||||
</div>
|
||||
<IdeaOutput
|
||||
value={ideaOutput}
|
||||
onChange={setIdeaOutput}
|
||||
/>
|
||||
|
||||
<div className='mt-5 flex justify-end'>
|
||||
<div className='mt-7 flex justify-end space-x-2'>
|
||||
<Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`)}</Button>
|
||||
<Button
|
||||
className='flex space-x-1'
|
||||
variant='primary'
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Generator className='h-4 w-4 text-white' />
|
||||
<span className='text-xs font-semibold text-white'>{t('appDebug.codegen.generate')}</span>
|
||||
<Generator className='h-4 w-4' />
|
||||
<span className='text-xs font-semibold '>{t('appDebug.codegen.generate')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && renderLoading}
|
||||
{!isLoading && !res && renderNoData}
|
||||
{(!isLoading && res) && (
|
||||
<div className='h-full w-0 grow p-6 pb-0'>
|
||||
<div className='mb-3 shrink-0 text-base font-semibold leading-[160%] text-text-secondary'>{t('appDebug.codegen.resTitle')}</div>
|
||||
<div className={cn('max-h-[555px] overflow-y-auto', !isInLLMNode && 'pb-2')}>
|
||||
<ConfigPrompt
|
||||
mode={mode}
|
||||
promptTemplate={res?.code || ''}
|
||||
promptVariables={[]}
|
||||
readonly
|
||||
noTitle={isInLLMNode}
|
||||
gradientBorder
|
||||
editorHeight={isInLLMNode ? 524 : 0}
|
||||
noResize={isInLLMNode}
|
||||
/>
|
||||
{!isInLLMNode && (
|
||||
<>
|
||||
{res?.code && (
|
||||
<div className='mt-4'>
|
||||
<h3 className='mb-2 text-sm font-medium text-text-primary'>{t('appDebug.codegen.generatedCode')}</h3>
|
||||
<pre className='overflow-x-auto rounded-lg bg-gray-50 p-4'>
|
||||
<code className={`language-${res.language}`}>
|
||||
{res.code}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{res?.error && (
|
||||
<div className='mt-4 rounded-lg bg-red-50 p-4'>
|
||||
<p className='text-sm text-red-600'>{res.error}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end bg-background-default py-4'>
|
||||
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' className='ml-2' onClick={() => {
|
||||
setShowConfirmOverwrite(true)
|
||||
}}>{t('appDebug.codegen.apply')}</Button>
|
||||
</div>
|
||||
{!isLoading && !current && <ResPlaceholder />}
|
||||
{(!isLoading && current) && (
|
||||
<div className='h-full w-0 grow bg-background-default-subtle p-6 pb-0'>
|
||||
<Result
|
||||
current={current!}
|
||||
currentVersionIndex={currentVersionIndex || 0}
|
||||
setCurrentVersionIndex={setCurrentVersionIndex}
|
||||
versions={versions || []}
|
||||
onApply={showConfirmOverwrite}
|
||||
generatorType={GeneratorType.code}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showConfirmOverwrite && (
|
||||
{isShowConfirmOverwrite && (
|
||||
<Confirm
|
||||
title={t('appDebug.codegen.overwriteConfirmTitle')}
|
||||
content={t('appDebug.codegen.overwriteConfirmMessage')}
|
||||
isShow={showConfirmOverwrite}
|
||||
isShow
|
||||
onConfirm={() => {
|
||||
setShowConfirmOverwrite(false)
|
||||
onFinished(res!)
|
||||
hideShowConfirmOverwrite()
|
||||
onFinished(current!)
|
||||
}}
|
||||
onCancel={() => setShowConfirmOverwrite(false)}
|
||||
onCancel={hideShowConfirmOverwrite}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import GroupName from '../../base/group-name'
|
||||
import TextToSpeech from '../chat-group/text-to-speech'
|
||||
import MoreLikeThis from './more-like-this'
|
||||
|
||||
/*
|
||||
* Include
|
||||
* 1. More like this
|
||||
*/
|
||||
|
||||
type ExperienceGroupProps = {
|
||||
isShowTextToSpeech: boolean
|
||||
isShowMoreLike: boolean
|
||||
}
|
||||
|
||||
const ExperienceEnhanceGroup: FC<ExperienceGroupProps> = ({
|
||||
isShowTextToSpeech,
|
||||
isShowMoreLike,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='mt-7'>
|
||||
<GroupName name={t('appDebug.feature.groupExperience.title')}/>
|
||||
<div className='space-y-3'>
|
||||
{
|
||||
isShowMoreLike && (
|
||||
<MoreLikeThis/>
|
||||
)
|
||||
}
|
||||
{
|
||||
isShowTextToSpeech && (
|
||||
<TextToSpeech/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ExperienceEnhanceGroup)
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import MoreLikeThisIcon from '../../../base/icons/more-like-this-icon'
|
||||
import Panel from '@/app/components/app/configuration/base/feature-panel'
|
||||
|
||||
const GENERATE_NUM = 1
|
||||
|
||||
const warningIcon = (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M6.40616 0.834307C6.14751 0.719294 5.85222 0.719294 5.59356 0.834307C5.3938 0.923133 5.26403 1.07959 5.17373 1.20708C5.08495 1.33242 4.9899 1.49664 4.88536 1.67723L0.751783 8.81705C0.646828 8.9983 0.551451 9.16302 0.486781 9.3028C0.421056 9.44487 0.349754 9.63584 0.372478 9.85381C0.401884 10.1359 0.549654 10.3922 0.779012 10.5589C0.956259 10.6878 1.15726 10.7218 1.31314 10.7361C1.46651 10.7501 1.65684 10.7501 1.86628 10.7501H10.1334C10.3429 10.7501 10.5332 10.7501 10.6866 10.7361C10.8425 10.7218 11.0435 10.6878 11.2207 10.5589C11.4501 10.3922 11.5978 10.1359 11.6272 9.85381C11.65 9.63584 11.5787 9.44487 11.5129 9.3028C11.4483 9.16303 11.3529 8.99833 11.248 8.81709L7.11436 1.67722C7.00983 1.49663 6.91477 1.33242 6.82599 1.20708C6.73569 1.07959 6.60593 0.923133 6.40616 0.834307ZM6.49988 4.50012C6.49988 4.22398 6.27602 4.00012 5.99988 4.00012C5.72374 4.00012 5.49988 4.22398 5.49988 4.50012V6.50012C5.49988 6.77626 5.72374 7.00012 5.99988 7.00012C6.27602 7.00012 6.49988 6.77626 6.49988 6.50012V4.50012ZM5.99988 8.00012C5.72374 8.00012 5.49988 8.22398 5.49988 8.50012C5.49988 8.77626 5.72374 9.00012 5.99988 9.00012H6.00488C6.28102 9.00012 6.50488 8.77626 6.50488 8.50012C6.50488 8.22398 6.28102 8.00012 6.00488 8.00012H5.99988Z" fill="#F79009" />
|
||||
</svg>
|
||||
|
||||
)
|
||||
const MoreLikeThis: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isHideTip, setIsHideTip] = useLocalStorageState('isHideMoreLikeThisTip', {
|
||||
defaultValue: false,
|
||||
})
|
||||
|
||||
const headerRight = (
|
||||
<div className='text-xs text-gray-500'>{t('appDebug.feature.moreLikeThis.generateNumTip')} {GENERATE_NUM}</div>
|
||||
)
|
||||
return (
|
||||
<Panel
|
||||
className='mt-4'
|
||||
title={t('appDebug.feature.moreLikeThis.title')}
|
||||
headerIcon={<MoreLikeThisIcon />}
|
||||
headerRight={headerRight}
|
||||
noBodySpacing
|
||||
>
|
||||
{!isHideTip && (
|
||||
<div className='flex h-9 items-center justify-between rounded-b-xl bg-[#FFFAEB] px-3 text-xs text-gray-700'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div>{warningIcon}</div>
|
||||
<div>{t('appDebug.feature.moreLikeThis.tip')}</div>
|
||||
</div>
|
||||
<div className='flex h-4 w-4 cursor-pointer items-center justify-center' onClick={() => setIsHideTip(true)}>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
export default React.memo(MoreLikeThis)
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { RiCloseLine, RiEditFill } from '@remixicon/react'
|
||||
import { get } from 'lodash-es'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
@@ -111,7 +110,8 @@ const statusTdRender = (statusCount: StatusCount) => {
|
||||
|
||||
const getFormattedChatList = (messages: ChatMessage[], conversationId: string, timezone: string, format: string) => {
|
||||
const newChatList: IChatItem[] = []
|
||||
messages.forEach((item: ChatMessage) => {
|
||||
try {
|
||||
messages.forEach((item: ChatMessage) => {
|
||||
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
|
||||
newChatList.push({
|
||||
id: `question-${item.id}`,
|
||||
@@ -178,7 +178,13 @@ const getFormattedChatList = (messages: ChatMessage[], conversationId: string, t
|
||||
parentMessageId: `question-${item.id}`,
|
||||
})
|
||||
})
|
||||
return newChatList
|
||||
|
||||
return newChatList
|
||||
}
|
||||
catch (error) {
|
||||
console.error('getFormattedChatList processing failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
type IDetailPanel = {
|
||||
@@ -188,6 +194,9 @@ type IDetailPanel = {
|
||||
}
|
||||
|
||||
function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
const MIN_ITEMS_FOR_SCROLL_LOADING = 8
|
||||
const SCROLL_THRESHOLD_PX = 50
|
||||
const SCROLL_DEBOUNCE_MS = 200
|
||||
const { userProfile: { timezone } } = useAppContext()
|
||||
const { formatTime } = useTimestamp()
|
||||
const { onClose, appDetail } = useContext(DrawerContext)
|
||||
@@ -204,13 +213,19 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
const { t } = useTranslation()
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [varValues, setVarValues] = useState<Record<string, string>>({})
|
||||
const isLoadingRef = useRef(false)
|
||||
|
||||
const [allChatItems, setAllChatItems] = useState<IChatItem[]>([])
|
||||
const [chatItemTree, setChatItemTree] = useState<ChatItemInTree[]>([])
|
||||
const [threadChatItems, setThreadChatItems] = useState<IChatItem[]>([])
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (isLoadingRef.current)
|
||||
return
|
||||
|
||||
try {
|
||||
isLoadingRef.current = true
|
||||
|
||||
if (!hasMore)
|
||||
return
|
||||
|
||||
@@ -218,8 +233,11 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
conversation_id: detail.id,
|
||||
limit: 10,
|
||||
}
|
||||
if (allChatItems[0]?.id)
|
||||
params.first_id = allChatItems[0]?.id.replace('question-', '')
|
||||
// Use the oldest answer item ID for pagination
|
||||
const answerItems = allChatItems.filter(item => item.isAnswer)
|
||||
const oldestAnswerItem = answerItems[answerItems.length - 1]
|
||||
if (oldestAnswerItem?.id)
|
||||
params.first_id = oldestAnswerItem.id
|
||||
const messageRes = await fetchChatMessages({
|
||||
url: `/apps/${appDetail?.id}/chat-messages`,
|
||||
params,
|
||||
@@ -249,15 +267,20 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
}
|
||||
setChatItemTree(tree)
|
||||
|
||||
setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id))
|
||||
const lastMessageId = newAllChatItems.length > 0 ? newAllChatItems[newAllChatItems.length - 1].id : undefined
|
||||
setThreadChatItems(getThreadMessages(tree, lastMessageId))
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
console.error('fetchData execution failed:', err)
|
||||
}
|
||||
finally {
|
||||
isLoadingRef.current = false
|
||||
}
|
||||
}, [allChatItems, detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction])
|
||||
|
||||
const switchSibling = useCallback((siblingMessageId: string) => {
|
||||
setThreadChatItems(getThreadMessages(chatItemTree, siblingMessageId))
|
||||
const newThreadChatItems = getThreadMessages(chatItemTree, siblingMessageId)
|
||||
setThreadChatItems(newThreadChatItems)
|
||||
}, [chatItemTree])
|
||||
|
||||
const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
|
||||
@@ -344,13 +367,217 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
|
||||
const fetchInitiated = useRef(false)
|
||||
|
||||
// Only load initial messages, don't auto-load more
|
||||
useEffect(() => {
|
||||
if (appDetail?.id && detail.id && appDetail?.mode !== 'completion' && !fetchInitiated.current) {
|
||||
// Mark as initialized, but don't auto-load more messages
|
||||
fetchInitiated.current = true
|
||||
// Still call fetchData to get initial messages
|
||||
fetchData()
|
||||
}
|
||||
}, [appDetail?.id, detail.id, appDetail?.mode, fetchData])
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const loadMoreMessages = useCallback(async () => {
|
||||
if (isLoading || !hasMore || !appDetail?.id || !detail.id)
|
||||
return
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const params: ChatMessagesRequest = {
|
||||
conversation_id: detail.id,
|
||||
limit: 10,
|
||||
}
|
||||
|
||||
// Use the earliest response item as the first_id
|
||||
const answerItems = allChatItems.filter(item => item.isAnswer)
|
||||
const oldestAnswerItem = answerItems[answerItems.length - 1]
|
||||
if (oldestAnswerItem?.id) {
|
||||
params.first_id = oldestAnswerItem.id
|
||||
}
|
||||
else if (allChatItems.length > 0 && allChatItems[0]?.id) {
|
||||
const firstId = allChatItems[0].id.replace('question-', '').replace('answer-', '')
|
||||
params.first_id = firstId
|
||||
}
|
||||
|
||||
const messageRes = await fetchChatMessages({
|
||||
url: `/apps/${appDetail.id}/chat-messages`,
|
||||
params,
|
||||
})
|
||||
|
||||
if (!messageRes.data || messageRes.data.length === 0) {
|
||||
setHasMore(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (messageRes.data.length > 0) {
|
||||
const varValues = messageRes.data.at(-1)!.inputs
|
||||
setVarValues(varValues)
|
||||
}
|
||||
|
||||
setHasMore(messageRes.has_more)
|
||||
|
||||
const newItems = getFormattedChatList(
|
||||
messageRes.data,
|
||||
detail.id,
|
||||
timezone!,
|
||||
t('appLog.dateTimeFormat') as string,
|
||||
)
|
||||
|
||||
// Check for duplicate messages
|
||||
const existingIds = new Set(allChatItems.map(item => item.id))
|
||||
const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id))
|
||||
|
||||
if (uniqueNewItems.length === 0) {
|
||||
if (allChatItems.length > 1) {
|
||||
const nextId = allChatItems[1].id.replace('question-', '').replace('answer-', '')
|
||||
|
||||
const retryParams = {
|
||||
...params,
|
||||
first_id: nextId,
|
||||
}
|
||||
|
||||
const retryRes = await fetchChatMessages({
|
||||
url: `/apps/${appDetail.id}/chat-messages`,
|
||||
params: retryParams,
|
||||
})
|
||||
|
||||
if (retryRes.data && retryRes.data.length > 0) {
|
||||
const retryItems = getFormattedChatList(
|
||||
retryRes.data,
|
||||
detail.id,
|
||||
timezone!,
|
||||
t('appLog.dateTimeFormat') as string,
|
||||
)
|
||||
|
||||
const retryUniqueItems = retryItems.filter(item => !existingIds.has(item.id))
|
||||
if (retryUniqueItems.length > 0) {
|
||||
const newAllChatItems = [
|
||||
...retryUniqueItems,
|
||||
...allChatItems,
|
||||
]
|
||||
|
||||
setAllChatItems(newAllChatItems)
|
||||
|
||||
let tree = buildChatItemTree(newAllChatItems)
|
||||
if (retryRes.has_more === false && detail?.model_config?.configs?.introduction) {
|
||||
tree = [{
|
||||
id: 'introduction',
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
content: detail?.model_config?.configs?.introduction ?? 'hello',
|
||||
feedbackDisabled: true,
|
||||
children: tree,
|
||||
}]
|
||||
}
|
||||
setChatItemTree(tree)
|
||||
setHasMore(retryRes.has_more)
|
||||
setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newAllChatItems = [
|
||||
...uniqueNewItems,
|
||||
...allChatItems,
|
||||
]
|
||||
|
||||
setAllChatItems(newAllChatItems)
|
||||
|
||||
let tree = buildChatItemTree(newAllChatItems)
|
||||
if (messageRes.has_more === false && detail?.model_config?.configs?.introduction) {
|
||||
tree = [{
|
||||
id: 'introduction',
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
content: detail?.model_config?.configs?.introduction ?? 'hello',
|
||||
feedbackDisabled: true,
|
||||
children: tree,
|
||||
}]
|
||||
}
|
||||
setChatItemTree(tree)
|
||||
|
||||
setThreadChatItems(getThreadMessages(tree, newAllChatItems.at(-1)?.id))
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
setHasMore(false)
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [allChatItems, detail.id, hasMore, isLoading, timezone, t, appDetail])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollableDiv = document.getElementById('scrollableDiv')
|
||||
const outerDiv = scrollableDiv?.parentElement
|
||||
const chatContainer = document.querySelector('.mx-1.mb-1.grow.overflow-auto') as HTMLElement
|
||||
|
||||
let scrollContainer: HTMLElement | null = null
|
||||
|
||||
if (outerDiv && outerDiv.scrollHeight > outerDiv.clientHeight) {
|
||||
scrollContainer = outerDiv
|
||||
}
|
||||
else if (scrollableDiv && scrollableDiv.scrollHeight > scrollableDiv.clientHeight) {
|
||||
scrollContainer = scrollableDiv
|
||||
}
|
||||
else if (chatContainer && chatContainer.scrollHeight > chatContainer.clientHeight) {
|
||||
scrollContainer = chatContainer
|
||||
}
|
||||
else {
|
||||
const possibleContainers = document.querySelectorAll('.overflow-auto, .overflow-y-auto')
|
||||
for (let i = 0; i < possibleContainers.length; i++) {
|
||||
const container = possibleContainers[i] as HTMLElement
|
||||
if (container.scrollHeight > container.clientHeight) {
|
||||
scrollContainer = container
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!scrollContainer)
|
||||
return
|
||||
|
||||
let lastLoadTime = 0
|
||||
const throttleDelay = 200
|
||||
|
||||
const handleScroll = () => {
|
||||
const currentScrollTop = scrollContainer!.scrollTop
|
||||
const scrollHeight = scrollContainer!.scrollHeight
|
||||
const clientHeight = scrollContainer!.clientHeight
|
||||
|
||||
const distanceFromTop = currentScrollTop
|
||||
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const isNearTop = distanceFromTop < 30
|
||||
// eslint-disable-next-line sonarjs/no-unused-vars
|
||||
const _distanceFromBottom = distanceFromBottom < 30
|
||||
if (isNearTop && hasMore && !isLoading && (now - lastLoadTime > throttleDelay)) {
|
||||
lastLoadTime = now
|
||||
loadMoreMessages()
|
||||
}
|
||||
}
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY < 0)
|
||||
handleScroll()
|
||||
}
|
||||
scrollContainer.addEventListener('wheel', handleWheel, { passive: true })
|
||||
|
||||
return () => {
|
||||
scrollContainer!.removeEventListener('scroll', handleScroll)
|
||||
scrollContainer!.removeEventListener('wheel', handleWheel)
|
||||
}
|
||||
}, [hasMore, isLoading, loadMoreMessages])
|
||||
|
||||
const isChatMode = appDetail?.mode !== 'completion'
|
||||
const isAdvanced = appDetail?.mode === 'advanced-chat'
|
||||
|
||||
@@ -378,6 +605,36 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
|
||||
// Add scroll listener to ensure loading is triggered
|
||||
useEffect(() => {
|
||||
if (threadChatItems.length >= MIN_ITEMS_FOR_SCROLL_LOADING && hasMore) {
|
||||
const scrollableDiv = document.getElementById('scrollableDiv')
|
||||
|
||||
if (scrollableDiv) {
|
||||
let loadingTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop } = scrollableDiv
|
||||
|
||||
// Trigger loading when scrolling near the top
|
||||
if (scrollTop < SCROLL_THRESHOLD_PX && !isLoadingRef.current) {
|
||||
if (loadingTimeout)
|
||||
clearTimeout(loadingTimeout)
|
||||
|
||||
loadingTimeout = setTimeout(fetchData, SCROLL_DEBOUNCE_MS) // 200ms debounce
|
||||
}
|
||||
}
|
||||
|
||||
scrollableDiv.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
scrollableDiv.removeEventListener('scroll', handleScroll)
|
||||
if (loadingTimeout)
|
||||
clearTimeout(loadingTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [threadChatItems.length, hasMore, fetchData])
|
||||
|
||||
return (
|
||||
<div ref={ref} className='flex h-full flex-col rounded-xl border-[0.5px] border-components-panel-border'>
|
||||
{/* Panel Header */}
|
||||
@@ -439,8 +696,8 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
siteInfo={null}
|
||||
/>
|
||||
</div>
|
||||
: threadChatItems.length < 8
|
||||
? <div className="mb-4 pt-4">
|
||||
: threadChatItems.length < MIN_ITEMS_FOR_SCROLL_LOADING ? (
|
||||
<div className="mb-4 pt-4">
|
||||
<Chat
|
||||
config={{
|
||||
appId: appDetail?.id,
|
||||
@@ -466,35 +723,27 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
switchSibling={switchSibling}
|
||||
/>
|
||||
</div>
|
||||
: <div
|
||||
) : (
|
||||
<div
|
||||
className="py-4"
|
||||
id="scrollableDiv"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column-reverse',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
{/* Put the scroll bar always on the bottom */}
|
||||
<InfiniteScroll
|
||||
scrollableTarget="scrollableDiv"
|
||||
dataLength={threadChatItems.length}
|
||||
next={fetchData}
|
||||
hasMore={hasMore}
|
||||
loader={<div className='system-xs-regular text-center text-text-tertiary'>{t('appLog.detail.loading')}...</div>}
|
||||
// endMessage={<div className='text-center'>Nothing more to show</div>}
|
||||
// below props only if you need pull down functionality
|
||||
refreshFunction={fetchData}
|
||||
pullDownToRefresh
|
||||
pullDownToRefreshThreshold={50}
|
||||
// pullDownToRefreshContent={
|
||||
// <div className='text-center'>Pull down to refresh</div>
|
||||
// }
|
||||
// releaseToRefreshContent={
|
||||
// <div className='text-center'>Release to refresh</div>
|
||||
// }
|
||||
// To put endMessage and loader to the top.
|
||||
style={{ display: 'flex', flexDirection: 'column-reverse' }}
|
||||
inverse={true}
|
||||
>
|
||||
<div className="flex w-full flex-col-reverse" style={{ position: 'relative' }}>
|
||||
{/* Loading state indicator - only shown when loading */}
|
||||
{hasMore && isLoading && (
|
||||
<div className="sticky left-0 right-0 top-0 z-10 bg-primary-50/40 py-3 text-center">
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{t('appLog.detail.loading')}...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Chat
|
||||
config={{
|
||||
appId: appDetail?.id,
|
||||
@@ -519,8 +768,9 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
chatContainerInnerClassName='px-3'
|
||||
switchSibling={switchSibling}
|
||||
/>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{showMessageLogModal && (
|
||||
|
||||
@@ -407,8 +407,8 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
}
|
||||
btnClassName={open =>
|
||||
cn(
|
||||
open ? '!bg-black/5 !shadow-none' : '!bg-transparent',
|
||||
'h-8 w-8 rounded-md border-none !p-2 hover:!bg-black/5',
|
||||
open ? '!bg-state-base-hover !shadow-none' : '!bg-transparent',
|
||||
'h-8 w-8 rounded-md border-none !p-2 hover:!bg-state-base-hover',
|
||||
)
|
||||
}
|
||||
popupClassName={
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import s from './style.module.css'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type ILoadingAnimProps = {
|
||||
type: 'text' | 'avatar'
|
||||
@@ -11,7 +12,7 @@ const LoadingAnim: FC<ILoadingAnimProps> = ({
|
||||
type,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`${s['dot-flashing']} ${s[type]}`}></div>
|
||||
<div className={cn(s['dot-flashing'], s[type])} />
|
||||
)
|
||||
}
|
||||
export default React.memo(LoadingAnim)
|
||||
|
||||
@@ -8,6 +8,7 @@ import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import ConfirmAddVar from '@/app/components/app/configuration/config-prompt/confirm-add-var'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import type { OpeningStatement } from '@/app/components/base/features/types'
|
||||
import { getInputKeys } from '@/app/components/base/block-input'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
@@ -101,7 +102,7 @@ const OpeningSettingModal = ({
|
||||
<div>·</div>
|
||||
<div>{tempSuggestedQuestions.length}/{MAX_QUESTION_NUM}</div>
|
||||
</div>
|
||||
<Divider bgStyle='gradient' className='ml-3 h-px w-0 grow'/>
|
||||
<Divider bgStyle='gradient' className='ml-3 h-px w-0 grow' />
|
||||
</div>
|
||||
<ReactSortable
|
||||
className="space-y-1"
|
||||
@@ -178,19 +179,32 @@ const OpeningSettingModal = ({
|
||||
>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<div className='title-2xl-semi-bold text-text-primary'>{t('appDebug.feature.conversationOpener.title')}</div>
|
||||
<div className='cursor-pointer p-1' onClick={onCancel}><RiCloseLine className='h-4 w-4 text-text-tertiary'/></div>
|
||||
<div className='cursor-pointer p-1' onClick={onCancel}><RiCloseLine className='h-4 w-4 text-text-tertiary' /></div>
|
||||
</div>
|
||||
<div className='mb-8 flex gap-2'>
|
||||
<div className='mt-1.5 h-8 w-8 shrink-0 rounded-lg border-components-panel-border bg-util-colors-orange-dark-orange-dark-500 p-1.5'>
|
||||
<RiAsterisk className='h-5 w-5 text-text-primary-on-surface' />
|
||||
</div>
|
||||
<div className='grow rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg p-3 shadow-xs'>
|
||||
<textarea
|
||||
<PromptEditor
|
||||
value={tempValue}
|
||||
rows={3}
|
||||
onChange={e => setTempValue(e.target.value)}
|
||||
className="system-md-regular w-full border-0 bg-transparent px-0 text-text-secondary focus:outline-none"
|
||||
onChange={setTempValue}
|
||||
placeholder={t('appDebug.openingStatement.placeholder') as string}
|
||||
variableBlock={{
|
||||
show: true,
|
||||
variables: [
|
||||
// Prompt variables
|
||||
...promptVariables.map(item => ({
|
||||
name: item.name || item.key,
|
||||
value: item.key,
|
||||
})),
|
||||
// Workflow variables
|
||||
...workflowVariables.map(item => ({
|
||||
name: item.variable,
|
||||
value: item.variable,
|
||||
})),
|
||||
],
|
||||
}}
|
||||
/>
|
||||
{renderQuestions()}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ const BaseField = ({
|
||||
inputClassName,
|
||||
formSchema,
|
||||
field,
|
||||
disabled,
|
||||
disabled: propsDisabled,
|
||||
}: BaseFieldProps) => {
|
||||
const renderI18nObject = useRenderI18nObject()
|
||||
const {
|
||||
@@ -40,7 +40,9 @@ const BaseField = ({
|
||||
options,
|
||||
labelClassName: formLabelClassName,
|
||||
show_on = [],
|
||||
disabled: formSchemaDisabled,
|
||||
} = formSchema
|
||||
const disabled = propsDisabled || formSchemaDisabled
|
||||
|
||||
const memorizedLabel = useMemo(() => {
|
||||
if (isValidElement(label))
|
||||
@@ -85,7 +87,7 @@ const BaseField = ({
|
||||
value: option.value,
|
||||
}
|
||||
}) || []
|
||||
}, [options, renderI18nObject])
|
||||
}, [options, renderI18nObject, optionValues])
|
||||
const value = useStore(field.form.store, s => s.values[field.name])
|
||||
const values = useStore(field.form.store, (s) => {
|
||||
return show_on.reduce((acc, condition) => {
|
||||
@@ -182,9 +184,10 @@ const BaseField = ({
|
||||
className={cn(
|
||||
'system-sm-regular hover:bg-components-option-card-option-hover-bg hover:border-components-option-card-option-hover-border flex h-8 flex-[1] grow cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary',
|
||||
value === option.value && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
inputClassName,
|
||||
)}
|
||||
onClick={() => field.handleChange(option.value)}
|
||||
onClick={() => !disabled && field.handleChange(option.value)}
|
||||
>
|
||||
{
|
||||
formSchema.showRadioUI && (
|
||||
|
||||
@@ -1,34 +1,52 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
isValidElement,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { FormSchema } from '../types'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
|
||||
export const useGetValidators = () => {
|
||||
const { t } = useTranslation()
|
||||
const renderI18nObject = useRenderI18nObject()
|
||||
const getLabel = useCallback((label: string | Record<string, string> | ReactNode) => {
|
||||
if (isValidElement(label))
|
||||
return ''
|
||||
|
||||
if (typeof label === 'string')
|
||||
return label
|
||||
|
||||
if (typeof label === 'object' && label !== null)
|
||||
return renderI18nObject(label as Record<string, string>)
|
||||
}, [])
|
||||
const getValidators = useCallback((formSchema: FormSchema) => {
|
||||
const {
|
||||
name,
|
||||
validators,
|
||||
required,
|
||||
label,
|
||||
} = formSchema
|
||||
let mergedValidators = validators
|
||||
const memorizedLabel = getLabel(label)
|
||||
if (required && !validators) {
|
||||
mergedValidators = {
|
||||
onMount: ({ value }: any) => {
|
||||
if (!value)
|
||||
return t('common.errorMsg.fieldRequired', { field: name })
|
||||
return t('common.errorMsg.fieldRequired', { field: memorizedLabel || name })
|
||||
},
|
||||
onChange: ({ value }: any) => {
|
||||
if (!value)
|
||||
return t('common.errorMsg.fieldRequired', { field: name })
|
||||
return t('common.errorMsg.fieldRequired', { field: memorizedLabel || name })
|
||||
},
|
||||
onBlur: ({ value }: any) => {
|
||||
if (!value)
|
||||
return t('common.errorMsg.fieldRequired', { field: name })
|
||||
return t('common.errorMsg.fieldRequired', { field: memorizedLabel })
|
||||
},
|
||||
}
|
||||
}
|
||||
return mergedValidators
|
||||
}, [t])
|
||||
}, [t, getLabel])
|
||||
|
||||
return {
|
||||
getValidators,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user