Compare commits

..
Author SHA1 Message Date
Stream 47c1da05f2 Merge branch 'main' into feat/memory-orchestration-be 2025-11-24 17:03:32 +08:00
Stream 7008be9fb7 Merge branch 'main' into feat/memory-orchestration-be
Signed-off-by: Stream <Stream_2@qq.com>

# Conflicts:
#	api/controllers/console/app/generator.py
#	api/models/workflow.py
2025-11-14 17:05:31 +08:00
Stream b5cde91c92 chore: ruff
Signed-off-by: Stream <Stream_2@qq.com>
2025-11-14 16:43:50 +08:00
Stream b0f73d681c feat: new instruction-generate with new LLMGenerator
Signed-off-by: Stream <Stream_2@qq.com>
2025-11-14 16:43:33 +08:00
Stream 77f70c5973 Merge branch 'main' into feat/memory-orchestration-be 2025-11-12 16:15:09 +08:00
Stream e53147266c Merge branch 'main' into feat/memory-orchestration-be
# Conflicts:
#	api/core/app/apps/advanced_chat/app_runner.py
#	api/models/workflow.py
2025-11-05 17:40:48 +08:00
Stream e47133569b refactor: use underscore as separator for node_id and memory_id 2025-11-05 17:38:13 +08:00
Stream f0ff2e1f2c refactor: add node_id to MemoryBlockSpec 2025-10-28 13:45:30 +08:00
Stream 89d53ecf50 fix: memory model_config fields 2025-10-21 19:41:52 +08:00
Stream bb8b6b494d chore: run ruff 2025-10-21 16:28:26 +08:00
Stream 5d5485e49d Merge branch 'main' into feat/memory-orchestration-be 2025-10-21 15:19:48 +08:00
Stream d6197e73b3 fix: merge 2025-10-20 16:29:13 +08:00
Stream cc5ceca342 Merge branch 'main' into feat/memory-orchestration-be
# Conflicts:
#	api/controllers/console/app/workflow.py
#	api/core/app/apps/advanced_chat/app_runner.py
#	api/core/workflow/nodes/llm/node.py
2025-10-20 16:24:48 +08:00
Stream bfba8bec2d fix: workflow fields 2025-10-16 17:15:05 +08:00
Stream 65a3646ce7 fix: error handling with model validation 2025-10-16 16:33:33 +08:00
Stream cb73335599 chore: run ruff 2025-10-15 16:57:18 +08:00
Stream f4fa57dac9 fix: store memory_blocks in correct field 2025-10-15 16:56:12 +08:00
Stream 7ca06931ec fix: unify memory variable in VariablePool 2025-10-15 14:39:05 +08:00
Stream f4567fbf9e fix: fix circular ref 2025-10-11 16:27:40 +08:00
Stream 8fd088754a fix: fix circular ref 2025-10-11 16:16:51 +08:00
Stream 61d9428064 refactor: fix basedpyright error 2025-10-10 18:47:16 +08:00
Stream f6038a4557 Merge branch 'main' into feat/memory-orchestration-be 2025-10-10 18:43:59 +08:00
Stream c367f80ec5 Merge branch 'main' into feat/memory-orchestration-be 2025-10-09 15:01:03 +08:00
Stream 791f33fd0b Merge branch 'main' into feat/memory-orchestration-be 2025-09-28 22:41:24 +08:00
Stream 1e0a3b163e refactor: fix ruff 2025-09-28 22:41:07 +08:00
Stream bb1f1a56a5 feat: update MemoryListApi response format with ChatflowConversationMetadata 2025-09-28 22:36:10 +08:00
Stream 15be85514d fix: chatflow message visibility from index 2025-09-28 21:20:37 +08:00
Stream 8833fee232 feat: move version update logic out of save_memory 2025-09-23 23:17:34 +08:00
Stream 5bf642c3f9 feat: expose version to MemoryBlock 2025-09-23 23:09:45 +08:00
Stream 3d7d4182a6 feat: add endpoints to delete memory 2025-09-23 19:07:37 +08:00
Stream 75c221038d feat: add endpoints to __init__.py 2025-09-23 18:35:11 +08:00
Stream b7b5b0b8d0 Merge branch 'main' into feat/memory-orchestration-be 2025-09-23 17:43:52 +08:00
Stream 6eab6a675c feat: add created_by to memory blocks 2025-09-23 17:35:36 +08:00
Stream d94e598a89 revert: remove memory database migration 2025-09-23 14:19:40 +08:00
Stream 28acb70118 feat: add edited_by_user field 2025-09-22 18:37:54 +08:00
Stream 7c35aaa99d refactor: remove MemoryBlockWithVisibility 2025-09-22 18:16:37 +08:00
Stream a8c2a300f6 refactor: make memories API return MemoryBlock 2025-09-22 17:14:07 +08:00
Stream d654d9d8b1 refactor: make ChatflowMemoryVariable.value JSON 2025-09-22 16:46:39 +08:00
Stream 394b7d09b8 refactor: fix basedpyright/ruff errors 2025-09-22 15:17:19 +08:00
Stream e9313b9c1b Merge branch 'main' into feat/memory-orchestration-be
# Conflicts:
#	api/core/app/apps/advanced_chat/app_runner.py
#	api/core/workflow/constants.py
#	api/core/workflow/entities/variable_pool.py
#	api/core/workflow/nodes/llm/node.py
#	api/models/workflow.py
2025-09-22 14:46:30 +08:00
Stream ac5dd1f45a refactor: update MemoryApi(Resource) for version 2025-09-16 19:25:17 +08:00
Stream 3005cf3282 refactor: update MemoryApi(WebApiResource) for version 2025-09-16 19:12:08 +08:00
Stream 54b272206e refactor: add version param to get_session_memories and get_persistent_memories 2025-09-16 18:32:58 +08:00
Stream 3d761a3189 refactor: make save_memory and get_memory_by_spec work on latest version 2025-09-15 19:28:22 +08:00
Stream e3903f34e4 refactor: add version field to ChatflowMemoryVariable table 2025-09-15 19:27:41 +08:00
Stream f4f055fb36 refactor: add version field to MemoryBlockWithVisibility 2025-09-15 19:27:17 +08:00
Stream 8563ae5511 feat: add inference for VersionedMemory type when deserializing 2025-09-15 16:13:07 +08:00
Stream 2c765ccfae refactor: use VersionedMemoryVariable in ChatflowMemoryService.get_memory_by_spec 2025-09-15 15:47:02 +08:00
Stream 626e7b2211 refactor: use VersionedMemoryVariable in ChatflowMemoryService.save_memory 2025-09-15 15:41:33 +08:00
Stream 516b6b0fa8 refactor: use VersionedMemoryVariable in creation of WorkflowDraftVariable instead of StringVariable 2025-09-15 15:39:38 +08:00
Stream 613d086f1e refactor: give VersionedMemoryValue a default value 2025-09-15 15:38:20 +08:00
Stream 9e0630f012 fix: use correct description from spec 2025-09-15 15:30:08 +08:00
Stream d6d9554954 fix: fix basedpyright errors 2025-09-15 14:20:30 +08:00
Stream 2a532ab729 Merge branch 'main' into feat/memory-orchestration-be
# Conflicts:
#	api/core/app/apps/advanced_chat/app_runner.py
#	api/core/prompt/entities/advanced_prompt_entities.py
#	api/core/variables/segments.py
2025-09-15 14:14:56 +08:00
Stream 03eef65b25 feat: add VersionedMemorySegment and VersionedMemoryVariable 2025-09-15 14:00:54 +08:00
Stream ad07d63994 feat: add VersionedMemoryValueModel 2025-09-15 14:00:54 +08:00
Stream 8685f055ea fix: use model parameters from memory_spec in llm_generator 2025-09-15 14:00:54 +08:00
Stream 3b868a1cec feat: integrate VariablePool into memory update process 2025-09-15 14:00:53 +08:00
Stream ab389eaa8e fix: fix ruff 2025-09-15 14:00:53 +08:00
Stream 008f778e8f fix: fix mypy 2025-09-15 14:00:53 +08:00
Stream 6af168cb31 Merge branch 'main' into feat/memory-orchestration-be 2025-08-25 14:54:14 +08:00
Stream 29f56cf0cf chore: add database migration 2025-08-22 21:07:54 +08:00
Stream 11b6ea742d feat: add index for data tables 2025-08-22 20:43:49 +08:00
Stream 05d231ad33 fix: fix bugs check by Claude Code 2025-08-22 19:59:17 +08:00
Stream 48f3c69c69 fix: fix bugs check by Claude Code 2025-08-22 17:54:18 +08:00
Stream 8b68020453 refactor: refactor from ChatflowHistoryService and ChatflowMemoryService 2025-08-22 17:44:27 +08:00
Stream 4d2fc66a8d feat: refactor: refactor from ChatflowHistoryService and ChatflowMemoryService 2025-08-22 15:33:45 +08:00
Stream f72ed4898c refactor: refactor from ChatflowHistoryService and ChatflowMemoryService 2025-08-22 14:57:27 +08:00
Stream 85a73181cc chore: run ruff 2025-08-21 17:23:24 +08:00
Stream e31e4ab677 feat: add Service API for memory read and modify 2025-08-21 17:22:39 +08:00
Stream 0d95c2192e feat: add Web API for memory read and modify 2025-08-21 17:17:08 +08:00
Stream 1fa8b26e55 feat: fetch memory block from WorkflowDraftVariable when debugging single node 2025-08-21 15:17:25 +08:00
Stream 4b085d46f6 feat: update variable pool when update memory 2025-08-21 15:15:23 +08:00
Stream 635c4ed4ce feat: add memory update check in AdvancedChatAppRunner 2025-08-21 14:24:17 +08:00
Stream 7ffcf8dd6f feat: add memory update check in AdvancedChatAppRunner 2025-08-21 13:27:00 +08:00
Stream 97cd21d3be feat: sync conversation history with chatflow_ tables in chatflow 2025-08-21 13:03:19 +08:00
Stream a13cb7e1c5 feat: init memory block for VariablePool in AdvancedChatAppRunner.run 2025-08-21 11:40:30 +08:00
Stream 7b602e9003 feat: wait for sync memory update in AdvancedChatAppRunner.run 2025-08-21 11:32:27 +08:00
Stream 5a26ebec8f feat: add _fetch_memory_blocks for AdvancedChatAppRunner 2025-08-21 11:28:47 +08:00
Stream 8341b8b1c1 feat: add MemoryBlock config to LLM's memory config 2025-08-20 19:53:44 +08:00
Stream bbb640c9a2 feat: add MemoryBlock to VariablePool 2025-08-20 19:45:18 +08:00
Stream 0c97bbf137 chore: run ruff 2025-08-20 19:12:34 +08:00
Stream 45fddc70d5 feat: add ChatflowHistoryService and ChatflowMemoryService 2025-08-20 19:11:12 +08:00
Stream f977dc410a feat: add MemorySyncTimeoutError 2025-08-20 17:45:53 +08:00
Stream d535818505 feat: add new_memory_block_variable for WorkflowDraftVariable 2025-08-20 17:41:45 +08:00
Stream fcf4e1f37d feat: add MEMORY_BLOCK_VARIABLE_NODE_ID 2025-08-20 17:41:13 +08:00
Stream 38130c8502 feat: add memory_blocks property to workflow's graph for memory block configuration 2025-08-20 17:19:48 +08:00
Stream f284c91988 feat: add data tables for chatflow memory 2025-08-20 17:16:54 +08:00
Stream 584b2cefa3 feat: add pydantic models for memory 2025-08-20 17:03:15 +08:00
Stream 42091b4a79 feat: add MEMORY_BLOCK in DraftVariableType 2025-08-20 16:51:07 +08:00
444 changed files with 4310 additions and 18897 deletions
-6
View File
@@ -1,6 +0,0 @@
# Cursor Rules for Dify Project
## Automated Test Generation
- Use `web/testing/testing.md` as the canonical instruction set for generating frontend automated tests.
- When proposing or saving tests, re-read that document and follow every requirement.
-12
View File
@@ -1,12 +0,0 @@
# Copilot Instructions
GitHub Copilot must follow the unified frontend testing requirements documented in `web/testing/testing.md`.
Key reminders:
- Generate tests using the mandated tech stack, naming, and code style (AAA pattern, `fireEvent`, descriptive test names, cleans up mocks).
- Cover rendering, prop combinations, and edge cases by default; extend coverage for hooks, routing, async flows, and domain-specific components when applicable.
- Target >95% line and branch coverage and 100% function/statement coverage.
- Apply the project's mocking conventions for i18n, toast notifications, and Next.js utilities.
Any suggestions from Copilot that conflict with `web/testing/testing.md` should be revised before acceptance.
@@ -20,22 +20,22 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 2
token: ${{ secrets.GITHUB_TOKEN }}
- name: Check for file changes in i18n/en-US
id: check_files
run: |
git fetch origin "${{ github.event.before }}" || true
git fetch origin "${{ github.sha }}" || true
changed_files=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'i18n/en-US/*.ts')
recent_commit_sha=$(git rev-parse HEAD)
second_recent_commit_sha=$(git rev-parse HEAD~1)
changed_files=$(git diff --name-only $recent_commit_sha $second_recent_commit_sha -- 'i18n/en-US/*.ts')
echo "Changed files: $changed_files"
if [ -n "$changed_files" ]; then
echo "FILES_CHANGED=true" >> $GITHUB_ENV
file_args=""
for file in $changed_files; do
filename=$(basename "$file" .ts)
file_args="$file_args --file $filename"
file_args="$file_args --file=$filename"
done
echo "FILE_ARGS=$file_args" >> $GITHUB_ENV
echo "File arguments: $file_args"
-5
View File
@@ -1,5 +0,0 @@
# Windsurf Testing Rules
- Use `web/testing/testing.md` as the single source of truth for frontend automated testing.
- Honor every requirement in that document when generating or accepting tests.
- When proposing or saving tests, re-read that document and follow every requirement.
-2
View File
@@ -77,8 +77,6 @@ How we prioritize:
For setting up the frontend service, please refer to our comprehensive [guide](https://github.com/langgenius/dify/blob/main/web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly.
**Testing**: All React components must have comprehensive test coverage. See [web/testing/testing.md](https://github.com/langgenius/dify/blob/main/web/testing/testing.md) for the canonical frontend testing guidelines and follow every requirement described there.
#### Backend
For setting up the backend service, kindly refer to our detailed [instructions](https://github.com/langgenius/dify/blob/main/api/README.md) in the `api/README.md` file. This document contains step-by-step guidance to help you get the backend up and running smoothly.
-1
View File
@@ -176,7 +176,6 @@ WEAVIATE_ENDPOINT=http://localhost:8080
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_GRPC_ENABLED=false
WEAVIATE_BATCH_SIZE=100
WEAVIATE_TOKENIZATION=word
# OceanBase Vector configuration
OCEANBASE_VECTOR_HOST=127.0.0.1
+7 -1
View File
@@ -1,5 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CommitMessageInspectionProfile">
<profile version="1.0">
<inspection_tool class="CommitFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
<component name="IssueNavigationConfiguration">
<option name="links">
<list>
@@ -14,4 +20,4 @@
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
</project>
+2 -11
View File
@@ -57,7 +57,7 @@ RUN \
# for gmpy2 \
libgmp-dev libmpfr-dev libmpc-dev \
# For Security
expat libldap-2.5-0=2.5.13+dfsg-5 perl libsqlite3-0=3.40.1-2+deb12u2 zlib1g=1:1.2.13.dfsg-1 \
expat libldap-2.5-0 perl libsqlite3-0 zlib1g \
# install fonts to support the use of tools like pypdfium2
fonts-noto-cjk \
# install a package to improve the accuracy of guessing mime type and file extension
@@ -73,8 +73,7 @@ COPY --from=packages ${VIRTUAL_ENV} ${VIRTUAL_ENV}
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
# Download nltk data
RUN mkdir -p /usr/local/share/nltk_data && NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.download('punkt'); nltk.download('averaged_perceptron_tagger'); nltk.download('stopwords')" \
&& chmod -R 755 /usr/local/share/nltk_data
RUN python -c "import nltk; nltk.download('punkt'); nltk.download('averaged_perceptron_tagger')"
ENV TIKTOKEN_CACHE_DIR=/app/api/.tiktoken_cache
@@ -87,15 +86,7 @@ COPY . /app/api/
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create non-root user and set permissions
RUN groupadd -r -g 1001 dify && \
useradd -r -u 1001 -g 1001 -s /bin/bash dify && \
mkdir -p /home/dify && \
chown -R 1001:1001 /app /home/dify ${TIKTOKEN_CACHE_DIR} /entrypoint.sh
ARG COMMIT_SHA
ENV COMMIT_SHA=${COMMIT_SHA}
ENV NLTK_DATA=/usr/local/share/nltk_data
USER 1001
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
@@ -31,8 +31,3 @@ class WeaviateConfig(BaseSettings):
description="Number of objects to be processed in a single batch operation (default is 100)",
default=100,
)
WEAVIATE_TOKENIZATION: str | None = Field(
description="Tokenization for Weaviate (default is word)",
default="word",
)
+6 -12
View File
@@ -24,12 +24,6 @@ api_key_fields = {
api_key_list = {"data": fields.List(fields.Nested(api_key_fields), attribute="items")}
api_key_item_model = console_ns.model("ApiKeyItem", api_key_fields)
api_key_list_model = console_ns.model(
"ApiKeyList", {"data": fields.List(fields.Nested(api_key_item_model), attribute="items")}
)
def _get_resource(resource_id, tenant_id, resource_model):
if resource_model == App:
@@ -58,7 +52,7 @@ class BaseApiKeyListResource(Resource):
token_prefix: str | None = None
max_keys = 10
@marshal_with(api_key_list_model)
@marshal_with(api_key_list)
def get(self, resource_id):
assert self.resource_id_field is not None, "resource_id_field must be set"
resource_id = str(resource_id)
@@ -72,7 +66,7 @@ class BaseApiKeyListResource(Resource):
).all()
return {"items": keys}
@marshal_with(api_key_item_model)
@marshal_with(api_key_fields)
@edit_permission_required
def post(self, resource_id):
assert self.resource_id_field is not None, "resource_id_field must be set"
@@ -142,7 +136,7 @@ class AppApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc("get_app_api_keys")
@console_ns.doc(description="Get all API keys for an app")
@console_ns.doc(params={"resource_id": "App ID"})
@console_ns.response(200, "Success", api_key_list_model)
@console_ns.response(200, "Success", api_key_list)
def get(self, resource_id): # type: ignore
"""Get all API keys for an app"""
return super().get(resource_id)
@@ -150,7 +144,7 @@ class AppApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc("create_app_api_key")
@console_ns.doc(description="Create a new API key for an app")
@console_ns.doc(params={"resource_id": "App ID"})
@console_ns.response(201, "API key created successfully", api_key_item_model)
@console_ns.response(201, "API key created successfully", api_key_fields)
@console_ns.response(400, "Maximum keys exceeded")
def post(self, resource_id): # type: ignore
"""Create a new API key for an app"""
@@ -182,7 +176,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc("get_dataset_api_keys")
@console_ns.doc(description="Get all API keys for a dataset")
@console_ns.doc(params={"resource_id": "Dataset ID"})
@console_ns.response(200, "Success", api_key_list_model)
@console_ns.response(200, "Success", api_key_list)
def get(self, resource_id): # type: ignore
"""Get all API keys for a dataset"""
return super().get(resource_id)
@@ -190,7 +184,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc("create_dataset_api_key")
@console_ns.doc(description="Create a new API key for a dataset")
@console_ns.doc(params={"resource_id": "Dataset ID"})
@console_ns.response(201, "API key created successfully", api_key_item_model)
@console_ns.response(201, "API key created successfully", api_key_fields)
@console_ns.response(400, "Maximum keys exceeded")
def post(self, resource_id): # type: ignore
"""Create a new API key for a dataset"""
+4 -18
View File
@@ -15,7 +15,6 @@ from extensions.ext_redis import redis_client
from fields.annotation_fields import (
annotation_fields,
annotation_hit_history_fields,
build_annotation_model,
)
from libs.helper import uuid_value
from libs.login import login_required
@@ -185,7 +184,7 @@ class AnnotationApi(Resource):
},
)
)
@console_ns.response(201, "Annotation created successfully", build_annotation_model(console_ns))
@console_ns.response(201, "Annotation created successfully", annotation_fields)
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@@ -239,11 +238,7 @@ class AnnotationExportApi(Resource):
@console_ns.doc("export_annotations")
@console_ns.doc(description="Export all annotations for an app")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200,
"Annotations exported successfully",
console_ns.model("AnnotationList", {"data": fields.List(fields.Nested(build_annotation_model(console_ns)))}),
)
@console_ns.response(200, "Annotations exported successfully", fields.List(fields.Nested(annotation_fields)))
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@@ -268,7 +263,7 @@ class AnnotationUpdateDeleteApi(Resource):
@console_ns.doc("update_delete_annotation")
@console_ns.doc(description="Update or delete an annotation")
@console_ns.doc(params={"app_id": "Application ID", "annotation_id": "Annotation ID"})
@console_ns.response(200, "Annotation updated successfully", build_annotation_model(console_ns))
@console_ns.response(200, "Annotation updated successfully", annotation_fields)
@console_ns.response(204, "Annotation deleted successfully")
@console_ns.response(403, "Insufficient permissions")
@console_ns.expect(parser)
@@ -364,16 +359,7 @@ class AnnotationHitHistoryListApi(Resource):
.add_argument("limit", type=int, location="args", default=20, help="Page size")
)
@console_ns.response(
200,
"Hit histories retrieved successfully",
console_ns.model(
"AnnotationHitHistoryList",
{
"data": fields.List(
fields.Nested(console_ns.model("AnnotationHitHistoryItem", annotation_hit_history_fields))
)
},
),
200, "Hit histories retrieved successfully", fields.List(fields.Nested(annotation_hit_history_fields))
)
@console_ns.response(403, "Insufficient permissions")
@setup_required
+17 -130
View File
@@ -18,15 +18,7 @@ from controllers.console.wraps import (
from core.ops.ops_trace_manager import OpsTraceManager
from core.workflow.enums import NodeType
from extensions.ext_database import db
from fields.app_fields import (
deleted_tool_fields,
model_config_fields,
model_config_partial_fields,
site_fields,
tag_fields,
)
from fields.workflow_fields import workflow_partial_fields as _workflow_partial_fields_dict
from libs.helper import AppIconUrlField, TimestampField
from fields.app_fields import app_detail_fields, app_detail_fields_with_site, app_pagination_fields
from libs.login import current_account_with_tenant, login_required
from libs.validators import validate_description_length
from models import App, Workflow
@@ -37,111 +29,6 @@ from services.feature_service import FeatureService
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
# Register models for flask_restx to avoid dict type issues in Swagger
# Register base models first
tag_model = console_ns.model("Tag", tag_fields)
workflow_partial_model = console_ns.model("WorkflowPartial", _workflow_partial_fields_dict)
model_config_model = console_ns.model("ModelConfig", model_config_fields)
model_config_partial_model = console_ns.model("ModelConfigPartial", model_config_partial_fields)
deleted_tool_model = console_ns.model("DeletedTool", deleted_tool_fields)
site_model = console_ns.model("Site", site_fields)
app_partial_model = console_ns.model(
"AppPartial",
{
"id": fields.String,
"name": fields.String,
"max_active_requests": fields.Raw(),
"description": fields.String(attribute="desc_or_prompt"),
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
"model_config": fields.Nested(model_config_partial_model, attribute="app_model_config", allow_null=True),
"workflow": fields.Nested(workflow_partial_model, allow_null=True),
"use_icon_as_answer_icon": fields.Boolean,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
"tags": fields.List(fields.Nested(tag_model)),
"access_mode": fields.String,
"create_user_name": fields.String,
"author_name": fields.String,
"has_draft_trigger": fields.Boolean,
},
)
app_detail_model = console_ns.model(
"AppDetail",
{
"id": fields.String,
"name": fields.String,
"description": fields.String,
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon": fields.String,
"icon_background": fields.String,
"enable_site": fields.Boolean,
"enable_api": fields.Boolean,
"model_config": fields.Nested(model_config_model, attribute="app_model_config", allow_null=True),
"workflow": fields.Nested(workflow_partial_model, allow_null=True),
"tracing": fields.Raw,
"use_icon_as_answer_icon": fields.Boolean,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
"access_mode": fields.String,
"tags": fields.List(fields.Nested(tag_model)),
},
)
app_detail_with_site_model = console_ns.model(
"AppDetailWithSite",
{
"id": fields.String,
"name": fields.String,
"description": fields.String,
"mode": fields.String(attribute="mode_compatible_with_agent"),
"icon_type": fields.String,
"icon": fields.String,
"icon_background": fields.String,
"icon_url": AppIconUrlField,
"enable_site": fields.Boolean,
"enable_api": fields.Boolean,
"model_config": fields.Nested(model_config_model, attribute="app_model_config", allow_null=True),
"workflow": fields.Nested(workflow_partial_model, allow_null=True),
"api_base_url": fields.String,
"use_icon_as_answer_icon": fields.Boolean,
"max_active_requests": fields.Integer,
"created_by": fields.String,
"created_at": TimestampField,
"updated_by": fields.String,
"updated_at": TimestampField,
"deleted_tools": fields.List(fields.Nested(deleted_tool_model)),
"access_mode": fields.String,
"tags": fields.List(fields.Nested(tag_model)),
"site": fields.Nested(site_model),
},
)
app_pagination_model = console_ns.model(
"AppPagination",
{
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(app_partial_model), attribute="items"),
},
)
@console_ns.route("/apps")
class AppListApi(Resource):
@@ -163,7 +50,7 @@ class AppListApi(Resource):
.add_argument("tag_ids", type=str, location="args", help="Comma-separated tag IDs")
.add_argument("is_created_by_me", type=bool, location="args", help="Filter by creator")
)
@console_ns.response(200, "Success", app_pagination_model)
@console_ns.response(200, "Success", app_pagination_fields)
@setup_required
@login_required
@account_initialization_required
@@ -250,7 +137,7 @@ class AppListApi(Resource):
for app in app_pagination.items:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
return marshal(app_pagination, app_pagination_model), 200
return marshal(app_pagination, app_pagination_fields), 200
@console_ns.doc("create_app")
@console_ns.doc(description="Create a new application")
@@ -267,13 +154,13 @@ class AppListApi(Resource):
},
)
)
@console_ns.response(201, "App created successfully", app_detail_model)
@console_ns.response(201, "App created successfully", app_detail_fields)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@marshal_with(app_detail_model)
@marshal_with(app_detail_fields)
@cloud_edition_billing_resource_check("apps")
@edit_permission_required
def post(self):
@@ -304,13 +191,13 @@ class AppApi(Resource):
@console_ns.doc("get_app_detail")
@console_ns.doc(description="Get application details")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", app_detail_with_site_model)
@console_ns.response(200, "Success", app_detail_fields_with_site)
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@get_app_model
@marshal_with(app_detail_with_site_model)
@marshal_with(app_detail_fields_with_site)
def get(self, app_model):
"""Get app detail"""
app_service = AppService()
@@ -340,7 +227,7 @@ class AppApi(Resource):
},
)
)
@console_ns.response(200, "App updated successfully", app_detail_with_site_model)
@console_ns.response(200, "App updated successfully", app_detail_fields_with_site)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@@ -348,7 +235,7 @@ class AppApi(Resource):
@account_initialization_required
@get_app_model
@edit_permission_required
@marshal_with(app_detail_with_site_model)
@marshal_with(app_detail_fields_with_site)
def put(self, app_model):
"""Update app"""
parser = (
@@ -413,14 +300,14 @@ class AppCopyApi(Resource):
},
)
)
@console_ns.response(201, "App copied successfully", app_detail_with_site_model)
@console_ns.response(201, "App copied successfully", app_detail_fields_with_site)
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@get_app_model
@edit_permission_required
@marshal_with(app_detail_with_site_model)
@marshal_with(app_detail_fields_with_site)
def post(self, app_model):
"""Copy app"""
# The role of the current user in the ta table must be admin, owner, or editor
@@ -509,7 +396,7 @@ class AppNameApi(Resource):
@login_required
@account_initialization_required
@get_app_model
@marshal_with(app_detail_model)
@marshal_with(app_detail_fields)
@edit_permission_required
def post(self, app_model):
args = parser.parse_args()
@@ -541,7 +428,7 @@ class AppIconApi(Resource):
@login_required
@account_initialization_required
@get_app_model
@marshal_with(app_detail_model)
@marshal_with(app_detail_fields)
@edit_permission_required
def post(self, app_model):
parser = (
@@ -567,13 +454,13 @@ class AppSiteStatus(Resource):
"AppSiteStatusRequest", {"enable_site": fields.Boolean(required=True, description="Enable or disable site")}
)
)
@console_ns.response(200, "Site status updated successfully", app_detail_model)
@console_ns.response(200, "Site status updated successfully", app_detail_fields)
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@get_app_model
@marshal_with(app_detail_model)
@marshal_with(app_detail_fields)
@edit_permission_required
def post(self, app_model):
parser = reqparse.RequestParser().add_argument("enable_site", type=bool, required=True, location="json")
@@ -595,14 +482,14 @@ class AppApiStatus(Resource):
"AppApiStatusRequest", {"enable_api": fields.Boolean(required=True, description="Enable or disable API")}
)
)
@console_ns.response(200, "API status updated successfully", app_detail_model)
@console_ns.response(200, "API status updated successfully", app_detail_fields)
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
@get_app_model
@marshal_with(app_detail_model)
@marshal_with(app_detail_fields)
def post(self, app_model):
parser = reqparse.RequestParser().add_argument("enable_api", type=bool, required=True, location="json")
args = parser.parse_args()
+5 -22
View File
@@ -1,4 +1,4 @@
from flask_restx import Resource, fields, marshal_with, reqparse
from flask_restx import Resource, marshal_with, reqparse
from sqlalchemy.orm import Session
from controllers.console.app.wraps import get_app_model
@@ -9,11 +9,7 @@ from controllers.console.wraps import (
setup_required,
)
from extensions.ext_database import db
from fields.app_fields import (
app_import_check_dependencies_fields,
app_import_fields,
leaked_dependency_fields,
)
from fields.app_fields import app_import_check_dependencies_fields, app_import_fields
from libs.login import current_account_with_tenant, login_required
from models.model import App
from services.app_dsl_service import AppDslService, ImportStatus
@@ -22,19 +18,6 @@ from services.feature_service import FeatureService
from .. import console_ns
# Register models for flask_restx to avoid dict type issues in Swagger
# Register base model first
leaked_dependency_model = console_ns.model("LeakedDependency", leaked_dependency_fields)
app_import_model = console_ns.model("AppImport", app_import_fields)
# For nested models, need to replace nested dict with registered model
app_import_check_dependencies_fields_copy = app_import_check_dependencies_fields.copy()
app_import_check_dependencies_fields_copy["leaked_dependencies"] = fields.List(fields.Nested(leaked_dependency_model))
app_import_check_dependencies_model = console_ns.model(
"AppImportCheckDependencies", app_import_check_dependencies_fields_copy
)
parser = (
reqparse.RequestParser()
.add_argument("mode", type=str, required=True, location="json")
@@ -55,7 +38,7 @@ class AppImportApi(Resource):
@setup_required
@login_required
@account_initialization_required
@marshal_with(app_import_model)
@marshal_with(app_import_fields)
@cloud_edition_billing_resource_check("apps")
@edit_permission_required
def post(self):
@@ -98,7 +81,7 @@ class AppImportConfirmApi(Resource):
@setup_required
@login_required
@account_initialization_required
@marshal_with(app_import_model)
@marshal_with(app_import_fields)
@edit_permission_required
def post(self, import_id):
# Check user role first
@@ -124,7 +107,7 @@ class AppImportCheckDependenciesApi(Resource):
@login_required
@get_app_model
@account_initialization_required
@marshal_with(app_import_check_dependencies_model)
@marshal_with(app_import_check_dependencies_fields)
@edit_permission_required
def get(self, app_model: App):
with Session(db.engine) as session:
+3 -15
View File
@@ -17,6 +17,7 @@ from controllers.console.app.error import (
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -31,7 +32,6 @@ from libs.login import current_user, login_required
from models import Account
from models.model import AppMode
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.errors.llm import InvokeRateLimitError
logger = logging.getLogger(__name__)
@@ -121,13 +121,7 @@ class CompletionMessageStopApi(Resource):
def post(self, app_model, task_id):
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.DEBUGGER,
user_id=current_user.id,
app_mode=AppMode.value_of(app_model.mode),
)
AppQueueManager.set_stop_flag(task_id, InvokeFrom.DEBUGGER, current_user.id)
return {"result": "success"}, 200
@@ -226,12 +220,6 @@ class ChatMessageStopApi(Resource):
def post(self, app_model, task_id):
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.DEBUGGER,
user_id=current_user.id,
app_mode=AppMode.value_of(app_model.mode),
)
AppQueueManager.set_stop_flag(task_id, InvokeFrom.DEBUGGER, current_user.id)
return {"result": "success"}, 200
+16 -268
View File
@@ -1,6 +1,6 @@
import sqlalchemy as sa
from flask import abort
from flask_restx import Resource, fields, marshal_with, reqparse
from flask_restx import Resource, marshal_with, reqparse
from flask_restx.inputs import int_range
from sqlalchemy import func, or_
from sqlalchemy.orm import joinedload
@@ -11,272 +11,20 @@ from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from fields.conversation_fields import MessageTextField
from fields.raws import FilesContainedField
from fields.conversation_fields import (
conversation_detail_fields,
conversation_message_detail_fields,
conversation_pagination_fields,
conversation_with_summary_pagination_fields,
)
from libs.datetime_utils import naive_utc_now, parse_time_range
from libs.helper import DatetimeString, TimestampField
from libs.helper import DatetimeString
from libs.login import current_account_with_tenant, login_required
from models import Conversation, EndUser, Message, MessageAnnotation
from models.model import AppMode
from services.conversation_service import ConversationService
from services.errors.conversation import ConversationNotExistsError
# Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models
# Base models
simple_account_model = console_ns.model(
"SimpleAccount",
{
"id": fields.String,
"name": fields.String,
"email": fields.String,
},
)
feedback_stat_model = console_ns.model(
"FeedbackStat",
{
"like": fields.Integer,
"dislike": fields.Integer,
},
)
status_count_model = console_ns.model(
"StatusCount",
{
"success": fields.Integer,
"failed": fields.Integer,
"partial_success": fields.Integer,
},
)
message_file_model = console_ns.model(
"MessageFile",
{
"id": fields.String,
"filename": fields.String,
"type": fields.String,
"url": fields.String,
"mime_type": fields.String,
"size": fields.Integer,
"transfer_method": fields.String,
"belongs_to": fields.String(default="user"),
"upload_file_id": fields.String(default=None),
},
)
agent_thought_model = console_ns.model(
"AgentThought",
{
"id": fields.String,
"chain_id": fields.String,
"message_id": fields.String,
"position": fields.Integer,
"thought": fields.String,
"tool": fields.String,
"tool_labels": fields.Raw,
"tool_input": fields.String,
"created_at": TimestampField,
"observation": fields.String,
"files": fields.List(fields.String),
},
)
simple_model_config_model = console_ns.model(
"SimpleModelConfig",
{
"model": fields.Raw(attribute="model_dict"),
"pre_prompt": fields.String,
},
)
model_config_model = console_ns.model(
"ModelConfig",
{
"opening_statement": fields.String,
"suggested_questions": fields.Raw,
"model": fields.Raw,
"user_input_form": fields.Raw,
"pre_prompt": fields.String,
"agent_mode": fields.Raw,
},
)
# Models that depend on simple_account_model
feedback_model = console_ns.model(
"Feedback",
{
"rating": fields.String,
"content": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account": fields.Nested(simple_account_model, allow_null=True),
},
)
annotation_model = console_ns.model(
"Annotation",
{
"id": fields.String,
"question": fields.String,
"content": fields.String,
"account": fields.Nested(simple_account_model, allow_null=True),
"created_at": TimestampField,
},
)
annotation_hit_history_model = console_ns.model(
"AnnotationHitHistory",
{
"annotation_id": fields.String(attribute="id"),
"annotation_create_account": fields.Nested(simple_account_model, allow_null=True),
"created_at": TimestampField,
},
)
# Simple message detail model
simple_message_detail_model = console_ns.model(
"SimpleMessageDetail",
{
"inputs": FilesContainedField,
"query": fields.String,
"message": MessageTextField,
"answer": fields.String,
},
)
# Message detail model that depends on multiple models
message_detail_model = console_ns.model(
"MessageDetail",
{
"id": fields.String,
"conversation_id": fields.String,
"inputs": FilesContainedField,
"query": fields.String,
"message": fields.Raw,
"message_tokens": fields.Integer,
"answer": fields.String(attribute="re_sign_file_url_answer"),
"answer_tokens": fields.Integer,
"provider_response_latency": fields.Float,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"feedbacks": fields.List(fields.Nested(feedback_model)),
"workflow_run_id": fields.String,
"annotation": fields.Nested(annotation_model, allow_null=True),
"annotation_hit_history": fields.Nested(annotation_hit_history_model, allow_null=True),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_model)),
"message_files": fields.List(fields.Nested(message_file_model)),
"metadata": fields.Raw(attribute="message_metadata_dict"),
"status": fields.String,
"error": fields.String,
"parent_message_id": fields.String,
},
)
# Conversation models
conversation_fields_model = console_ns.model(
"Conversation",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_end_user_session_id": fields.String(),
"from_account_id": fields.String,
"from_account_name": fields.String,
"read_at": TimestampField,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotation": fields.Nested(annotation_model, allow_null=True),
"model_config": fields.Nested(simple_model_config_model),
"user_feedback_stats": fields.Nested(feedback_stat_model),
"admin_feedback_stats": fields.Nested(feedback_stat_model),
"message": fields.Nested(simple_message_detail_model, attribute="first_message"),
},
)
conversation_pagination_model = console_ns.model(
"ConversationPagination",
{
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(conversation_fields_model), attribute="items"),
},
)
conversation_message_detail_model = console_ns.model(
"ConversationMessageDetail",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"created_at": TimestampField,
"model_config": fields.Nested(model_config_model),
"message": fields.Nested(message_detail_model, attribute="first_message"),
},
)
conversation_with_summary_model = console_ns.model(
"ConversationWithSummary",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_end_user_session_id": fields.String,
"from_account_id": fields.String,
"from_account_name": fields.String,
"name": fields.String,
"summary": fields.String(attribute="summary_or_query"),
"read_at": TimestampField,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotated": fields.Boolean,
"model_config": fields.Nested(simple_model_config_model),
"message_count": fields.Integer,
"user_feedback_stats": fields.Nested(feedback_stat_model),
"admin_feedback_stats": fields.Nested(feedback_stat_model),
"status_count": fields.Nested(status_count_model),
},
)
conversation_with_summary_pagination_model = console_ns.model(
"ConversationWithSummaryPagination",
{
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(conversation_with_summary_model), attribute="items"),
},
)
conversation_detail_model = console_ns.model(
"ConversationDetail",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotated": fields.Boolean,
"introduction": fields.String,
"model_config": fields.Nested(model_config_model),
"message_count": fields.Integer,
"user_feedback_stats": fields.Nested(feedback_stat_model),
"admin_feedback_stats": fields.Nested(feedback_stat_model),
},
)
@console_ns.route("/apps/<uuid:app_id>/completion-conversations")
class CompletionConversationApi(Resource):
@@ -299,13 +47,13 @@ class CompletionConversationApi(Resource):
.add_argument("page", type=int, location="args", default=1, help="Page number")
.add_argument("limit", type=int, location="args", default=20, help="Page size (1-100)")
)
@console_ns.response(200, "Success", conversation_pagination_model)
@console_ns.response(200, "Success", conversation_pagination_fields)
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@marshal_with(conversation_pagination_model)
@marshal_with(conversation_pagination_fields)
@edit_permission_required
def get(self, app_model):
current_user, _ = current_account_with_tenant()
@@ -377,14 +125,14 @@ class CompletionConversationDetailApi(Resource):
@console_ns.doc("get_completion_conversation")
@console_ns.doc(description="Get completion conversation details with messages")
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
@console_ns.response(200, "Success", conversation_message_detail_model)
@console_ns.response(200, "Success", conversation_message_detail_fields)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Conversation not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@marshal_with(conversation_message_detail_model)
@marshal_with(conversation_message_detail_fields)
@edit_permission_required
def get(self, app_model, conversation_id):
conversation_id = str(conversation_id)
@@ -444,13 +192,13 @@ class ChatConversationApi(Resource):
help="Sort field and direction",
)
)
@console_ns.response(200, "Success", conversation_with_summary_pagination_model)
@console_ns.response(200, "Success", conversation_with_summary_pagination_fields)
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@marshal_with(conversation_with_summary_pagination_model)
@marshal_with(conversation_with_summary_pagination_fields)
@edit_permission_required
def get(self, app_model):
current_user, _ = current_account_with_tenant()
@@ -577,14 +325,14 @@ class ChatConversationDetailApi(Resource):
@console_ns.doc("get_chat_conversation")
@console_ns.doc(description="Get chat conversation details")
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
@console_ns.response(200, "Success", conversation_detail_model)
@console_ns.response(200, "Success", conversation_detail_fields)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Conversation not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@marshal_with(conversation_detail_model)
@marshal_with(conversation_detail_fields)
@edit_permission_required
def get(self, app_model, conversation_id):
conversation_id = str(conversation_id)
@@ -1,4 +1,4 @@
from flask_restx import Resource, fields, marshal_with, reqparse
from flask_restx import Resource, marshal_with, reqparse
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -6,27 +6,11 @@ from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from extensions.ext_database import db
from fields.conversation_variable_fields import (
conversation_variable_fields,
paginated_conversation_variable_fields,
)
from fields.conversation_variable_fields import paginated_conversation_variable_fields
from libs.login import login_required
from models import ConversationVariable
from models.model import AppMode
# Register models for flask_restx to avoid dict type issues in Swagger
# Register base model first
conversation_variable_model = console_ns.model("ConversationVariable", conversation_variable_fields)
# For nested models, need to replace nested dict with registered model
paginated_conversation_variable_fields_copy = paginated_conversation_variable_fields.copy()
paginated_conversation_variable_fields_copy["data"] = fields.List(
fields.Nested(conversation_variable_model), attribute="data"
)
paginated_conversation_variable_model = console_ns.model(
"PaginatedConversationVariable", paginated_conversation_variable_fields_copy
)
@console_ns.route("/apps/<uuid:app_id>/conversation-variables")
class ConversationVariablesApi(Resource):
@@ -38,12 +22,12 @@ class ConversationVariablesApi(Resource):
"conversation_id", type=str, location="args", help="Conversation ID to filter variables"
)
)
@console_ns.response(200, "Conversation variables retrieved successfully", paginated_conversation_variable_model)
@console_ns.response(200, "Conversation variables retrieved successfully", paginated_conversation_variable_fields)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.ADVANCED_CHAT)
@marshal_with(paginated_conversation_variable_model)
@marshal_with(paginated_conversation_variable_fields)
def get(self, app_model):
parser = reqparse.RequestParser().add_argument("conversation_id", type=str, location="args")
args = parser.parse_args()
+159 -76
View File
@@ -1,5 +1,3 @@
from collections.abc import Sequence
from flask_restx import Resource, fields, reqparse
from controllers.console import console_ns
@@ -11,14 +9,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 extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import App
from services.workflow_service import WorkflowService
@@ -178,11 +171,29 @@ class InstructionGenerateApi(Resource):
console_ns.model(
"InstructionGenerateRequest",
{
"flow_id": fields.String(required=True, description="Workflow/Flow ID"),
"node_id": fields.String(description="Node ID for workflow context"),
"current": fields.String(description="Current instruction text"),
"language": fields.String(default="javascript", description="Programming language (javascript/python)"),
"instruction": fields.String(required=True, description="Instruction for generation"),
"type": fields.String(
required=True,
description="Request type",
enum=[
"legacy_prompt_generate",
"workflow_prompt_generate",
"workflow_code_generate",
"workflow_prompt_edit",
"workflow_code_edit",
"memory_template_generate",
"memory_instruction_generate",
"memory_template_edit",
"memory_instruction_edit",
]
),
"flow_id": fields.String(description="Workflow/Flow ID"),
"node_id": fields.String(description="Node ID (optional)"),
"current": fields.String(description="Current content"),
"language": fields.String(
default="javascript",
description="Programming language (javascript/python)"
),
"instruction": fields.String(required=True, description="User instruction"),
"model_config": fields.Raw(required=True, description="Model configuration"),
"ideal_output": fields.String(description="Expected ideal output"),
},
@@ -197,7 +208,8 @@ class InstructionGenerateApi(Resource):
def post(self):
parser = (
reqparse.RequestParser()
.add_argument("flow_id", type=str, required=True, default="", location="json")
.add_argument("type", type=str, required=True, nullable=False, location="json")
.add_argument("flow_id", type=str, required=False, default="", location="json")
.add_argument("node_id", type=str, required=False, default="", location="json")
.add_argument("current", type=str, required=False, default="", location="json")
.add_argument("language", type=str, required=False, default="javascript", location="json")
@@ -207,70 +219,16 @@ class InstructionGenerateApi(Resource):
)
args = parser.parse_args()
_, current_tenant_id = current_account_with_tenant()
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"] != "":
app = db.session.query(App).where(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_tenant_id,
instruction=args["instruction"],
model_config=args["model_config"],
no_variable=True,
)
case "agent":
return LLMGenerator.generate_rule_config(
current_tenant_id,
instruction=args["instruction"],
model_config=args["model_config"],
no_variable=True,
)
case "code":
return LLMGenerator.generate_code(
tenant_id=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_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_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"],
workflow_service=WorkflowService(),
)
return {"error": "incompatible parameters"}, 400
# Validate parameters
is_valid, error_message = self._validate_params(args["type"], args)
if not is_valid:
return {"error": error_message}, 400
# Route based on type
return self._handle_by_type(args["type"], args, current_tenant_id)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -280,6 +238,131 @@ class InstructionGenerateApi(Resource):
except InvokeError as e:
raise CompletionRequestError(e.description)
def _validate_params(self, request_type: str, args: dict) -> tuple[bool, str]:
"""
Validate request parameters
Returns:
(is_valid, error_message)
"""
# All types require instruction and model_config
if not args.get("instruction"):
return False, "instruction is required"
if not args.get("model_config"):
return False, "model_config is required"
# Edit types require flow_id and current
if request_type.endswith("_edit"):
if not args.get("flow_id"):
return False, f"{request_type} requires flow_id"
if not args.get("current"):
return False, f"{request_type} requires current content"
# Code generate requires language
if request_type == "workflow_code_generate":
if args.get("language") not in ["python", "javascript"]:
return False, "language must be 'python' or 'javascript'"
return True, ""
def _handle_by_type(self, request_type: str, args: dict, tenant_id: str):
"""
Route handling based on type
"""
match request_type:
case "legacy_prompt_generate":
# Legacy prompt generation doesn't exist, this is actually an edit
if not args.get("flow_id"):
return {"error": "legacy_prompt_generate requires flow_id"}, 400
return LLMGenerator.instruction_modify_legacy(
tenant_id=tenant_id,
flow_id=args["flow_id"],
current=args["current"],
instruction=args["instruction"],
model_config=args["model_config"],
ideal_output=args["ideal_output"],
)
case "workflow_prompt_generate":
return LLMGenerator.generate_rule_config(
tenant_id,
instruction=args["instruction"],
model_config=args["model_config"],
no_variable=True,
)
case "workflow_code_generate":
return LLMGenerator.generate_code(
tenant_id=tenant_id,
instruction=args["instruction"],
model_config=args["model_config"],
code_language=args["language"],
)
case "workflow_prompt_edit":
return LLMGenerator.instruction_modify_workflow(
tenant_id=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"],
workflow_service=WorkflowService(),
)
case "workflow_code_edit":
# Code edit uses the same workflow edit logic
return LLMGenerator.instruction_modify_workflow(
tenant_id=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"],
workflow_service=WorkflowService(),
)
case "memory_template_generate":
return LLMGenerator.generate_memory_template(
tenant_id=tenant_id,
instruction=args["instruction"],
model_config=args["model_config"],
)
case "memory_instruction_generate":
return LLMGenerator.generate_memory_instruction(
tenant_id=tenant_id,
instruction=args["instruction"],
model_config=args["model_config"],
)
case "memory_template_edit":
return LLMGenerator.edit_memory_template(
tenant_id=tenant_id,
flow_id=args["flow_id"],
node_id=args.get("node_id") or None,
current=args["current"],
instruction=args["instruction"],
model_config=args["model_config"],
ideal_output=args["ideal_output"],
)
case "memory_instruction_edit":
return LLMGenerator.edit_memory_instruction(
tenant_id=tenant_id,
flow_id=args["flow_id"],
node_id=args.get("node_id") or None,
current=args["current"],
instruction=args["instruction"],
model_config=args["model_config"],
ideal_output=args["ideal_output"],
)
case _:
return {"error": f"Invalid request type: {request_type}"}, 400
@console_ns.route("/instruction-generate/template")
class InstructionGenerationTemplateApi(Resource):
+8 -11
View File
@@ -12,9 +12,6 @@ from fields.app_fields import app_server_fields
from libs.login import current_account_with_tenant, login_required
from models.model import AppMCPServer
# Register model for flask_restx to avoid dict type issues in Swagger
app_server_model = console_ns.model("AppServer", app_server_fields)
class AppMCPServerStatus(StrEnum):
ACTIVE = "active"
@@ -26,12 +23,12 @@ class AppMCPServerController(Resource):
@console_ns.doc("get_app_mcp_server")
@console_ns.doc(description="Get MCP server configuration for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "MCP server configuration retrieved successfully", app_server_model)
@console_ns.response(200, "MCP server configuration retrieved successfully", app_server_fields)
@login_required
@account_initialization_required
@setup_required
@get_app_model
@marshal_with(app_server_model)
@marshal_with(app_server_fields)
def get(self, app_model):
server = db.session.query(AppMCPServer).where(AppMCPServer.app_id == app_model.id).first()
return server
@@ -48,13 +45,13 @@ class AppMCPServerController(Resource):
},
)
)
@console_ns.response(201, "MCP server configuration created successfully", app_server_model)
@console_ns.response(201, "MCP server configuration created successfully", app_server_fields)
@console_ns.response(403, "Insufficient permissions")
@account_initialization_required
@get_app_model
@login_required
@setup_required
@marshal_with(app_server_model)
@marshal_with(app_server_fields)
@edit_permission_required
def post(self, app_model):
_, current_tenant_id = current_account_with_tenant()
@@ -96,14 +93,14 @@ class AppMCPServerController(Resource):
},
)
)
@console_ns.response(200, "MCP server configuration updated successfully", app_server_model)
@console_ns.response(200, "MCP server configuration updated successfully", app_server_fields)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found")
@get_app_model
@login_required
@setup_required
@account_initialization_required
@marshal_with(app_server_model)
@marshal_with(app_server_fields)
@edit_permission_required
def put(self, app_model):
parser = (
@@ -140,13 +137,13 @@ class AppMCPServerRefreshController(Resource):
@console_ns.doc("refresh_app_mcp_server")
@console_ns.doc(description="Refresh MCP server configuration and regenerate server code")
@console_ns.doc(params={"server_id": "Server ID"})
@console_ns.response(200, "MCP server refreshed successfully", app_server_model)
@console_ns.response(200, "MCP server refreshed successfully", app_server_fields)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found")
@setup_required
@login_required
@account_initialization_required
@marshal_with(app_server_model)
@marshal_with(app_server_fields)
@edit_permission_required
def get(self, server_id):
_, current_tenant_id = current_account_with_tenant()
+12 -175
View File
@@ -23,8 +23,8 @@ from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.model_runtime.errors.invoke import InvokeError
from extensions.ext_database import db
from fields.raws import FilesContainedField
from libs.helper import TimestampField, uuid_value
from fields.conversation_fields import message_detail_fields
from libs.helper import uuid_value
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.login import current_account_with_tenant, login_required
from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
@@ -34,126 +34,15 @@ from services.message_service import MessageService
logger = logging.getLogger(__name__)
# Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models
# Base models
simple_account_model = console_ns.model(
"SimpleAccount",
{
"id": fields.String,
"name": fields.String,
"email": fields.String,
},
)
message_file_model = console_ns.model(
"MessageFile",
{
"id": fields.String,
"filename": fields.String,
"type": fields.String,
"url": fields.String,
"mime_type": fields.String,
"size": fields.Integer,
"transfer_method": fields.String,
"belongs_to": fields.String(default="user"),
"upload_file_id": fields.String(default=None),
},
)
agent_thought_model = console_ns.model(
"AgentThought",
{
"id": fields.String,
"chain_id": fields.String,
"message_id": fields.String,
"position": fields.Integer,
"thought": fields.String,
"tool": fields.String,
"tool_labels": fields.Raw,
"tool_input": fields.String,
"created_at": TimestampField,
"observation": fields.String,
"files": fields.List(fields.String),
},
)
# Models that depend on simple_account_model
feedback_model = console_ns.model(
"Feedback",
{
"rating": fields.String,
"content": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account": fields.Nested(simple_account_model, allow_null=True),
},
)
annotation_model = console_ns.model(
"Annotation",
{
"id": fields.String,
"question": fields.String,
"content": fields.String,
"account": fields.Nested(simple_account_model, allow_null=True),
"created_at": TimestampField,
},
)
annotation_hit_history_model = console_ns.model(
"AnnotationHitHistory",
{
"annotation_id": fields.String(attribute="id"),
"annotation_create_account": fields.Nested(simple_account_model, allow_null=True),
"created_at": TimestampField,
},
)
# Message detail model that depends on multiple models
message_detail_model = console_ns.model(
"MessageDetail",
{
"id": fields.String,
"conversation_id": fields.String,
"inputs": FilesContainedField,
"query": fields.String,
"message": fields.Raw,
"message_tokens": fields.Integer,
"answer": fields.String(attribute="re_sign_file_url_answer"),
"answer_tokens": fields.Integer,
"provider_response_latency": fields.Float,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"feedbacks": fields.List(fields.Nested(feedback_model)),
"workflow_run_id": fields.String,
"annotation": fields.Nested(annotation_model, allow_null=True),
"annotation_hit_history": fields.Nested(annotation_hit_history_model, allow_null=True),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_model)),
"message_files": fields.List(fields.Nested(message_file_model)),
"metadata": fields.Raw(attribute="message_metadata_dict"),
"status": fields.String,
"error": fields.String,
"parent_message_id": fields.String,
},
)
# Message infinite scroll pagination model
message_infinite_scroll_pagination_model = console_ns.model(
"MessageInfiniteScrollPagination",
{
"limit": fields.Integer,
"has_more": fields.Boolean,
"data": fields.List(fields.Nested(message_detail_model)),
},
)
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
class ChatMessageListApi(Resource):
message_infinite_scroll_pagination_fields = {
"limit": fields.Integer,
"has_more": fields.Boolean,
"data": fields.List(fields.Nested(message_detail_fields)),
}
@console_ns.doc("list_chat_messages")
@console_ns.doc(description="Get chat messages for a conversation with pagination")
@console_ns.doc(params={"app_id": "Application ID"})
@@ -163,13 +52,13 @@ class ChatMessageListApi(Resource):
.add_argument("first_id", type=str, location="args", help="First message ID for pagination")
.add_argument("limit", type=int, location="args", default=20, help="Number of messages to return (1-100)")
)
@console_ns.response(200, "Success", message_infinite_scroll_pagination_model)
@console_ns.response(200, "Success", message_infinite_scroll_pagination_fields)
@console_ns.response(404, "Conversation not found")
@login_required
@account_initialization_required
@setup_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@marshal_with(message_infinite_scroll_pagination_model)
@marshal_with(message_infinite_scroll_pagination_fields)
@edit_permission_required
def get(self, app_model):
parser = (
@@ -369,70 +258,18 @@ class MessageSuggestedQuestionApi(Resource):
return {"data": questions}
# Shared parser for feedback export (used for both documentation and runtime parsing)
feedback_export_parser = (
console_ns.parser()
.add_argument("from_source", type=str, choices=["user", "admin"], location="args", help="Filter by feedback source")
.add_argument("rating", type=str, choices=["like", "dislike"], location="args", help="Filter by rating")
.add_argument("has_comment", type=bool, location="args", help="Only include feedback with comments")
.add_argument("start_date", type=str, location="args", help="Start date (YYYY-MM-DD)")
.add_argument("end_date", type=str, location="args", help="End date (YYYY-MM-DD)")
.add_argument("format", type=str, choices=["csv", "json"], default="csv", location="args", help="Export format")
)
@console_ns.route("/apps/<uuid:app_id>/feedbacks/export")
class MessageFeedbackExportApi(Resource):
@console_ns.doc("export_feedbacks")
@console_ns.doc(description="Export user feedback data for Google Sheets")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(feedback_export_parser)
@console_ns.response(200, "Feedback data exported successfully")
@console_ns.response(400, "Invalid parameters")
@console_ns.response(500, "Internal server error")
@get_app_model
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
args = feedback_export_parser.parse_args()
# Import the service function
from services.feedback_service import FeedbackService
try:
export_data = FeedbackService.export_feedbacks(
app_id=app_model.id,
from_source=args.get("from_source"),
rating=args.get("rating"),
has_comment=args.get("has_comment"),
start_date=args.get("start_date"),
end_date=args.get("end_date"),
format_type=args.get("format", "csv"),
)
return export_data
except ValueError as e:
logger.exception("Parameter validation error in feedback export")
return {"error": f"Parameter validation error: {str(e)}"}, 400
except Exception as e:
logger.exception("Error exporting feedback data")
raise InternalServerError(str(e))
@console_ns.route("/apps/<uuid:app_id>/messages/<uuid:message_id>")
class MessageApi(Resource):
@console_ns.doc("get_message")
@console_ns.doc(description="Get message details by ID")
@console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"})
@console_ns.response(200, "Message retrieved successfully", message_detail_model)
@console_ns.response(200, "Message retrieved successfully", message_detail_fields)
@console_ns.response(404, "Message not found")
@get_app_model
@setup_required
@login_required
@account_initialization_required
@marshal_with(message_detail_model)
@marshal_with(message_detail_fields)
def get(self, app_model, message_id: str):
message_id = str(message_id)
+4 -7
View File
@@ -16,9 +16,6 @@ from libs.datetime_utils import naive_utc_now
from libs.login import current_account_with_tenant, login_required
from models import Site
# Register model for flask_restx to avoid dict type issues in Swagger
app_site_model = console_ns.model("AppSite", app_site_fields)
def parse_app_site_args():
parser = (
@@ -79,7 +76,7 @@ class AppSite(Resource):
},
)
)
@console_ns.response(200, "Site configuration updated successfully", app_site_model)
@console_ns.response(200, "Site configuration updated successfully", app_site_fields)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "App not found")
@setup_required
@@ -87,7 +84,7 @@ class AppSite(Resource):
@edit_permission_required
@account_initialization_required
@get_app_model
@marshal_with(app_site_model)
@marshal_with(app_site_fields)
def post(self, app_model):
args = parse_app_site_args()
current_user, _ = current_account_with_tenant()
@@ -129,7 +126,7 @@ class AppSiteAccessTokenReset(Resource):
@console_ns.doc("reset_app_site_access_token")
@console_ns.doc(description="Reset access token for application site")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Access token reset successfully", app_site_model)
@console_ns.response(200, "Access token reset successfully", app_site_fields)
@console_ns.response(403, "Insufficient permissions (admin/owner required)")
@console_ns.response(404, "App or site not found")
@setup_required
@@ -137,7 +134,7 @@ class AppSiteAccessTokenReset(Resource):
@is_admin_or_owner_required
@account_initialization_required
@get_app_model
@marshal_with(app_site_model)
@marshal_with(app_site_fields)
def post(self, app_model):
current_user, _ = current_account_with_tenant()
site = db.session.query(Site).where(Site.app_id == app_model.id).first()
+24 -69
View File
@@ -5,6 +5,7 @@ from typing import cast
from flask import abort, request
from flask_restx import Resource, fields, inputs, marshal_with, reqparse
from pydantic_core import ValidationError
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
@@ -32,7 +33,6 @@ from core.workflow.enums import NodeType
from core.workflow.graph_engine.manager import GraphEngineManager
from extensions.ext_database import db
from factories import file_factory, variable_factory
from fields.member_fields import simple_account_fields
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
from fields.workflow_run_fields import workflow_run_node_execution_fields
from libs import helper
@@ -50,62 +50,6 @@ from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseE
logger = logging.getLogger(__name__)
LISTENING_RETRY_IN = 2000
# Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models
# Base models
simple_account_model = console_ns.model("SimpleAccount", simple_account_fields)
from fields.workflow_fields import pipeline_variable_fields, serialize_value_type
conversation_variable_model = console_ns.model(
"ConversationVariable",
{
"id": fields.String,
"name": fields.String,
"value_type": fields.String(attribute=serialize_value_type),
"value": fields.Raw,
"description": fields.String,
},
)
pipeline_variable_model = console_ns.model("PipelineVariable", pipeline_variable_fields)
# Workflow model with nested dependencies
workflow_fields_copy = workflow_fields.copy()
workflow_fields_copy["created_by"] = fields.Nested(simple_account_model, attribute="created_by_account")
workflow_fields_copy["updated_by"] = fields.Nested(
simple_account_model, attribute="updated_by_account", allow_null=True
)
workflow_fields_copy["conversation_variables"] = fields.List(fields.Nested(conversation_variable_model))
workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipeline_variable_model))
workflow_model = console_ns.model("Workflow", workflow_fields_copy)
# Workflow pagination model
workflow_pagination_fields_copy = workflow_pagination_fields.copy()
workflow_pagination_fields_copy["items"] = fields.List(fields.Nested(workflow_model), attribute="items")
workflow_pagination_model = console_ns.model("WorkflowPagination", workflow_pagination_fields_copy)
# Reuse workflow_run_node_execution_model from workflow_run.py if already registered
# Otherwise register it here
from fields.end_user_fields import simple_end_user_fields
simple_end_user_model = None
try:
simple_end_user_model = console_ns.models.get("SimpleEndUser")
except AttributeError:
pass
if simple_end_user_model is None:
simple_end_user_model = console_ns.model("SimpleEndUser", simple_end_user_fields)
workflow_run_node_execution_model = None
try:
workflow_run_node_execution_model = console_ns.models.get("WorkflowRunNodeExecution")
except AttributeError:
pass
if workflow_run_node_execution_model is None:
workflow_run_node_execution_model = console_ns.model("WorkflowRunNodeExecution", workflow_run_node_execution_fields)
# TODO(QuantumGhost): Refactor existing node run API to handle file parameter parsing
# at the controller level rather than in the workflow logic. This would improve separation
@@ -130,13 +74,13 @@ class DraftWorkflowApi(Resource):
@console_ns.doc("get_draft_workflow")
@console_ns.doc(description="Get draft workflow for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Draft workflow retrieved successfully", workflow_model)
@console_ns.response(200, "Draft workflow retrieved successfully", workflow_fields)
@console_ns.response(404, "Draft workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_model)
@marshal_with(workflow_fields)
@edit_permission_required
def get(self, app_model: App):
"""
@@ -167,6 +111,7 @@ class DraftWorkflowApi(Resource):
"hash": fields.String(description="Workflow hash for validation"),
"environment_variables": fields.List(fields.Raw, required=True, description="Environment variables"),
"conversation_variables": fields.List(fields.Raw, description="Conversation variables"),
"memory_blocks": fields.List(fields.Raw, description="Memory blocks"),
},
)
)
@@ -201,6 +146,7 @@ class DraftWorkflowApi(Resource):
.add_argument("hash", type=str, required=False, location="json")
.add_argument("environment_variables", type=list, required=True, location="json")
.add_argument("conversation_variables", type=list, required=False, location="json")
.add_argument("memory_blocks", type=list, required=False, location="json")
)
args = parser.parse_args()
elif "text/plain" in content_type:
@@ -218,6 +164,7 @@ class DraftWorkflowApi(Resource):
"hash": data.get("hash"),
"environment_variables": data.get("environment_variables"),
"conversation_variables": data.get("conversation_variables"),
"memory_blocks": data.get("memory_blocks"),
}
except json.JSONDecodeError:
return {"message": "Invalid JSON data"}, 400
@@ -234,6 +181,11 @@ class DraftWorkflowApi(Resource):
conversation_variables = [
variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list
]
memory_blocks_list = args.get("memory_blocks") or []
from core.memory.entities import MemoryBlockSpec
memory_blocks = [
MemoryBlockSpec.model_validate(obj) for obj in memory_blocks_list
]
workflow = workflow_service.sync_draft_workflow(
app_model=app_model,
graph=args["graph"],
@@ -242,9 +194,12 @@ class DraftWorkflowApi(Resource):
account=current_user,
environment_variables=environment_variables,
conversation_variables=conversation_variables,
memory_blocks=memory_blocks,
)
except WorkflowHashNotEqualError:
raise DraftWorkflowNotSync()
except ValidationError as e:
return {"message": str(e)}, 400
return {
"result": "success",
@@ -596,14 +551,14 @@ class DraftWorkflowNodeRunApi(Resource):
},
)
)
@console_ns.response(200, "Node run started successfully", workflow_run_node_execution_model)
@console_ns.response(200, "Node run started successfully", workflow_run_node_execution_fields)
@console_ns.response(403, "Permission denied")
@console_ns.response(404, "Node not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_run_node_execution_model)
@marshal_with(workflow_run_node_execution_fields)
@edit_permission_required
def post(self, app_model: App, node_id: str):
"""
@@ -655,13 +610,13 @@ class PublishedWorkflowApi(Resource):
@console_ns.doc("get_published_workflow")
@console_ns.doc(description="Get published workflow for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Published workflow retrieved successfully", workflow_model)
@console_ns.response(200, "Published workflow retrieved successfully", workflow_fields)
@console_ns.response(404, "Published workflow not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_model)
@marshal_with(workflow_fields)
@edit_permission_required
def get(self, app_model: App):
"""
@@ -838,12 +793,12 @@ class PublishedAllWorkflowApi(Resource):
@console_ns.doc("get_all_published_workflows")
@console_ns.doc(description="Get all published workflows for an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Published workflows retrieved successfully", workflow_pagination_model)
@console_ns.response(200, "Published workflows retrieved successfully", workflow_pagination_fields)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_pagination_model)
@marshal_with(workflow_pagination_fields)
@edit_permission_required
def get(self, app_model: App):
"""
@@ -895,14 +850,14 @@ class WorkflowByIdApi(Resource):
},
)
)
@console_ns.response(200, "Workflow updated successfully", workflow_model)
@console_ns.response(200, "Workflow updated successfully", workflow_fields)
@console_ns.response(404, "Workflow not found")
@console_ns.response(403, "Permission denied")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_model)
@marshal_with(workflow_fields)
@edit_permission_required
def patch(self, app_model: App, workflow_id: str):
"""
@@ -986,14 +941,14 @@ class DraftWorkflowNodeLastRunApi(Resource):
@console_ns.doc("get_draft_workflow_node_last_run")
@console_ns.doc(description="Get last run result for draft workflow node")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.response(200, "Node last run retrieved successfully", workflow_run_node_execution_model)
@console_ns.response(200, "Node last run retrieved successfully", workflow_run_node_execution_fields)
@console_ns.response(404, "Node last run not found")
@console_ns.response(403, "Permission denied")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_run_node_execution_model)
@marshal_with(workflow_run_node_execution_fields)
def get(self, app_model: App, node_id: str):
srv = WorkflowService()
workflow = srv.get_draft_workflow(app_model)
@@ -8,15 +8,12 @@ from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from core.workflow.enums import WorkflowExecutionStatus
from extensions.ext_database import db
from fields.workflow_app_log_fields import build_workflow_app_log_pagination_model
from fields.workflow_app_log_fields import workflow_app_log_pagination_fields
from libs.login import login_required
from models import App
from models.model import AppMode
from services.workflow_app_service import WorkflowAppService
# Register model for flask_restx to avoid dict type issues in Swagger
workflow_app_log_pagination_model = build_workflow_app_log_pagination_model(console_ns)
@console_ns.route("/apps/<uuid:app_id>/workflow-app-logs")
class WorkflowAppLogApi(Resource):
@@ -36,12 +33,12 @@ class WorkflowAppLogApi(Resource):
"limit": "Number of items per page (1-100)",
}
)
@console_ns.response(200, "Workflow app logs retrieved successfully", workflow_app_log_pagination_model)
@console_ns.response(200, "Workflow app logs retrieved successfully", workflow_app_log_pagination_fields)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
@marshal_with(workflow_app_log_pagination_model)
@marshal_with(workflow_app_log_pagination_fields)
def get(self, app_model: App):
"""
Get workflow app logs
@@ -141,37 +141,6 @@ _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS = {
"items": fields.List(fields.Nested(_WORKFLOW_DRAFT_VARIABLE_FIELDS), attribute=_get_items),
}
# Register models for flask_restx to avoid dict type issues in Swagger
workflow_draft_variable_without_value_model = console_ns.model(
"WorkflowDraftVariableWithoutValue", _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS
)
workflow_draft_variable_model = console_ns.model("WorkflowDraftVariable", _WORKFLOW_DRAFT_VARIABLE_FIELDS)
workflow_draft_env_variable_model = console_ns.model("WorkflowDraftEnvVariable", _WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS)
workflow_draft_env_variable_list_fields_copy = _WORKFLOW_DRAFT_ENV_VARIABLE_LIST_FIELDS.copy()
workflow_draft_env_variable_list_fields_copy["items"] = fields.List(fields.Nested(workflow_draft_env_variable_model))
workflow_draft_env_variable_list_model = console_ns.model(
"WorkflowDraftEnvVariableList", workflow_draft_env_variable_list_fields_copy
)
workflow_draft_variable_list_without_value_fields_copy = _WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS.copy()
workflow_draft_variable_list_without_value_fields_copy["items"] = fields.List(
fields.Nested(workflow_draft_variable_without_value_model), attribute=_get_items
)
workflow_draft_variable_list_without_value_model = console_ns.model(
"WorkflowDraftVariableListWithoutValue", workflow_draft_variable_list_without_value_fields_copy
)
workflow_draft_variable_list_fields_copy = _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS.copy()
workflow_draft_variable_list_fields_copy["items"] = fields.List(
fields.Nested(workflow_draft_variable_model), attribute=_get_items
)
workflow_draft_variable_list_model = console_ns.model(
"WorkflowDraftVariableList", workflow_draft_variable_list_fields_copy
)
P = ParamSpec("P")
R = TypeVar("R")
@@ -207,10 +176,10 @@ class WorkflowVariableCollectionApi(Resource):
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.doc(params={"page": "Page number (1-100000)", "limit": "Number of items per page (1-100)"})
@console_ns.response(
200, "Workflow variables retrieved successfully", workflow_draft_variable_list_without_value_model
200, "Workflow variables retrieved successfully", _WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS
)
@_api_prerequisite
@marshal_with(workflow_draft_variable_list_without_value_model)
@marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS)
def get(self, app_model: App):
"""
Get draft workflow
@@ -273,9 +242,9 @@ class NodeVariableCollectionApi(Resource):
@console_ns.doc("get_node_variables")
@console_ns.doc(description="Get variables for a specific node")
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
@console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model)
@console_ns.response(200, "Node variables retrieved successfully", _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
@_api_prerequisite
@marshal_with(workflow_draft_variable_list_model)
@marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
def get(self, app_model: App, node_id: str):
validate_node_id(node_id)
with Session(bind=db.engine, expire_on_commit=False) as session:
@@ -306,10 +275,10 @@ class VariableApi(Resource):
@console_ns.doc("get_variable")
@console_ns.doc(description="Get a specific workflow variable")
@console_ns.doc(params={"app_id": "Application ID", "variable_id": "Variable ID"})
@console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model)
@console_ns.response(200, "Variable retrieved successfully", _WORKFLOW_DRAFT_VARIABLE_FIELDS)
@console_ns.response(404, "Variable not found")
@_api_prerequisite
@marshal_with(workflow_draft_variable_model)
@marshal_with(_WORKFLOW_DRAFT_VARIABLE_FIELDS)
def get(self, app_model: App, variable_id: str):
draft_var_srv = WorkflowDraftVariableService(
session=db.session(),
@@ -332,10 +301,10 @@ class VariableApi(Resource):
},
)
)
@console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model)
@console_ns.response(200, "Variable updated successfully", _WORKFLOW_DRAFT_VARIABLE_FIELDS)
@console_ns.response(404, "Variable not found")
@_api_prerequisite
@marshal_with(workflow_draft_variable_model)
@marshal_with(_WORKFLOW_DRAFT_VARIABLE_FIELDS)
def patch(self, app_model: App, variable_id: str):
# Request payload for file types:
#
@@ -421,7 +390,7 @@ class VariableResetApi(Resource):
@console_ns.doc("reset_variable")
@console_ns.doc(description="Reset a workflow variable to its default value")
@console_ns.doc(params={"app_id": "Application ID", "variable_id": "Variable ID"})
@console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model)
@console_ns.response(200, "Variable reset successfully", _WORKFLOW_DRAFT_VARIABLE_FIELDS)
@console_ns.response(204, "Variable reset (no content)")
@console_ns.response(404, "Variable not found")
@_api_prerequisite
@@ -447,7 +416,7 @@ class VariableResetApi(Resource):
if resetted is None:
return Response("", 204)
else:
return marshal(resetted, workflow_draft_variable_model)
return marshal(resetted, _WORKFLOW_DRAFT_VARIABLE_FIELDS)
def _get_variable_list(app_model: App, node_id) -> WorkflowDraftVariableList:
@@ -469,10 +438,10 @@ class ConversationVariableCollectionApi(Resource):
@console_ns.doc("get_conversation_variables")
@console_ns.doc(description="Get conversation variables for workflow")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Conversation variables retrieved successfully", workflow_draft_variable_list_model)
@console_ns.response(200, "Conversation variables retrieved successfully", _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
@console_ns.response(404, "Draft workflow not found")
@_api_prerequisite
@marshal_with(workflow_draft_variable_list_model)
@marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
def get(self, app_model: App):
# NOTE(QuantumGhost): Prefill conversation variables into the draft variables table
# so their IDs can be returned to the caller.
@@ -491,9 +460,9 @@ class SystemVariableCollectionApi(Resource):
@console_ns.doc("get_system_variables")
@console_ns.doc(description="Get system variables for workflow")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model)
@console_ns.response(200, "System variables retrieved successfully", _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
@_api_prerequisite
@marshal_with(workflow_draft_variable_list_model)
@marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
def get(self, app_model: App):
return _get_variable_list(app_model, SYSTEM_VARIABLE_NODE_ID)
+13 -83
View File
@@ -1,20 +1,15 @@
from typing import cast
from flask_restx import Resource, fields, marshal_with, reqparse
from flask_restx import Resource, marshal_with, reqparse
from flask_restx.inputs import int_range
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from fields.end_user_fields import simple_end_user_fields
from fields.member_fields import simple_account_fields
from fields.workflow_run_fields import (
advanced_chat_workflow_run_for_list_fields,
advanced_chat_workflow_run_pagination_fields,
workflow_run_count_fields,
workflow_run_detail_fields,
workflow_run_for_list_fields,
workflow_run_node_execution_fields,
workflow_run_node_execution_list_fields,
workflow_run_pagination_fields,
)
@@ -27,71 +22,6 @@ from services.workflow_run_service import WorkflowRunService
# Workflow run status choices for filtering
WORKFLOW_RUN_STATUS_CHOICES = ["running", "succeeded", "failed", "stopped", "partial-succeeded"]
# Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models
# Base models
simple_account_model = console_ns.model("SimpleAccount", simple_account_fields)
simple_end_user_model = console_ns.model("SimpleEndUser", simple_end_user_fields)
# Models that depend on simple_account_fields
workflow_run_for_list_fields_copy = workflow_run_for_list_fields.copy()
workflow_run_for_list_fields_copy["created_by_account"] = fields.Nested(
simple_account_model, attribute="created_by_account", allow_null=True
)
workflow_run_for_list_model = console_ns.model("WorkflowRunForList", workflow_run_for_list_fields_copy)
advanced_chat_workflow_run_for_list_fields_copy = advanced_chat_workflow_run_for_list_fields.copy()
advanced_chat_workflow_run_for_list_fields_copy["created_by_account"] = fields.Nested(
simple_account_model, attribute="created_by_account", allow_null=True
)
advanced_chat_workflow_run_for_list_model = console_ns.model(
"AdvancedChatWorkflowRunForList", advanced_chat_workflow_run_for_list_fields_copy
)
workflow_run_detail_fields_copy = workflow_run_detail_fields.copy()
workflow_run_detail_fields_copy["created_by_account"] = fields.Nested(
simple_account_model, attribute="created_by_account", allow_null=True
)
workflow_run_detail_fields_copy["created_by_end_user"] = fields.Nested(
simple_end_user_model, attribute="created_by_end_user", allow_null=True
)
workflow_run_detail_model = console_ns.model("WorkflowRunDetail", workflow_run_detail_fields_copy)
workflow_run_node_execution_fields_copy = workflow_run_node_execution_fields.copy()
workflow_run_node_execution_fields_copy["created_by_account"] = fields.Nested(
simple_account_model, attribute="created_by_account", allow_null=True
)
workflow_run_node_execution_fields_copy["created_by_end_user"] = fields.Nested(
simple_end_user_model, attribute="created_by_end_user", allow_null=True
)
workflow_run_node_execution_model = console_ns.model(
"WorkflowRunNodeExecution", workflow_run_node_execution_fields_copy
)
# Simple models without nested dependencies
workflow_run_count_model = console_ns.model("WorkflowRunCount", workflow_run_count_fields)
# Pagination models that depend on list models
advanced_chat_workflow_run_pagination_fields_copy = advanced_chat_workflow_run_pagination_fields.copy()
advanced_chat_workflow_run_pagination_fields_copy["data"] = fields.List(
fields.Nested(advanced_chat_workflow_run_for_list_model), attribute="data"
)
advanced_chat_workflow_run_pagination_model = console_ns.model(
"AdvancedChatWorkflowRunPagination", advanced_chat_workflow_run_pagination_fields_copy
)
workflow_run_pagination_fields_copy = workflow_run_pagination_fields.copy()
workflow_run_pagination_fields_copy["data"] = fields.List(fields.Nested(workflow_run_for_list_model), attribute="data")
workflow_run_pagination_model = console_ns.model("WorkflowRunPagination", workflow_run_pagination_fields_copy)
workflow_run_node_execution_list_fields_copy = workflow_run_node_execution_list_fields.copy()
workflow_run_node_execution_list_fields_copy["data"] = fields.List(fields.Nested(workflow_run_node_execution_model))
workflow_run_node_execution_list_model = console_ns.model(
"WorkflowRunNodeExecutionList", workflow_run_node_execution_list_fields_copy
)
def _parse_workflow_run_list_args():
"""
@@ -170,12 +100,12 @@ class AdvancedChatAppWorkflowRunListApi(Resource):
@console_ns.doc(
params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"}
)
@console_ns.response(200, "Workflow runs retrieved successfully", advanced_chat_workflow_run_pagination_model)
@console_ns.response(200, "Workflow runs retrieved successfully", advanced_chat_workflow_run_pagination_fields)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
@marshal_with(advanced_chat_workflow_run_pagination_model)
@marshal_with(advanced_chat_workflow_run_pagination_fields)
def get(self, app_model: App):
"""
Get advanced chat app workflow run list
@@ -216,12 +146,12 @@ class AdvancedChatAppWorkflowRunCountApi(Resource):
@console_ns.doc(
params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"}
)
@console_ns.response(200, "Workflow runs count retrieved successfully", workflow_run_count_model)
@console_ns.response(200, "Workflow runs count retrieved successfully", workflow_run_count_fields)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
@marshal_with(workflow_run_count_model)
@marshal_with(workflow_run_count_fields)
def get(self, app_model: App):
"""
Get advanced chat workflow runs count statistics
@@ -258,12 +188,12 @@ class WorkflowRunListApi(Resource):
@console_ns.doc(
params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"}
)
@console_ns.response(200, "Workflow runs retrieved successfully", workflow_run_pagination_model)
@console_ns.response(200, "Workflow runs retrieved successfully", workflow_run_pagination_fields)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_run_pagination_model)
@marshal_with(workflow_run_pagination_fields)
def get(self, app_model: App):
"""
Get workflow run list
@@ -304,12 +234,12 @@ class WorkflowRunCountApi(Resource):
@console_ns.doc(
params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"}
)
@console_ns.response(200, "Workflow runs count retrieved successfully", workflow_run_count_model)
@console_ns.response(200, "Workflow runs count retrieved successfully", workflow_run_count_fields)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_run_count_model)
@marshal_with(workflow_run_count_fields)
def get(self, app_model: App):
"""
Get workflow runs count statistics
@@ -339,13 +269,13 @@ class WorkflowRunDetailApi(Resource):
@console_ns.doc("get_workflow_run_detail")
@console_ns.doc(description="Get workflow run detail")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(200, "Workflow run detail retrieved successfully", workflow_run_detail_model)
@console_ns.response(200, "Workflow run detail retrieved successfully", workflow_run_detail_fields)
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_run_detail_model)
@marshal_with(workflow_run_detail_fields)
def get(self, app_model: App, run_id):
"""
Get workflow run detail
@@ -363,13 +293,13 @@ class WorkflowRunNodeExecutionListApi(Resource):
@console_ns.doc("get_workflow_run_node_executions")
@console_ns.doc(description="Get workflow run node execution list")
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
@console_ns.response(200, "Node executions retrieved successfully", workflow_run_node_execution_list_model)
@console_ns.response(200, "Node executions retrieved successfully", workflow_run_node_execution_list_fields)
@console_ns.response(404, "Workflow run not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_run_node_execution_list_model)
@marshal_with(workflow_run_node_execution_list_fields)
def get(self, app_model: App, run_id):
"""
Get workflow run node execution list
+15 -20
View File
@@ -6,6 +6,9 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from configs import dify_config
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from extensions.ext_database import db
from fields.workflow_trigger_fields import trigger_fields, triggers_list_fields, webhook_trigger_fields
from libs.login import current_user, login_required
@@ -13,21 +16,12 @@ from models.enums import AppTriggerStatus
from models.model import Account, App, AppMode
from models.trigger import AppTrigger, WorkflowWebhookTrigger
from .. import console_ns
from ..app.wraps import get_app_model
from ..wraps import account_initialization_required, edit_permission_required, setup_required
logger = logging.getLogger(__name__)
parser = reqparse.RequestParser().add_argument("node_id", type=str, required=True, help="Node ID is required")
@console_ns.route("/apps/<uuid:app_id>/workflows/triggers/webhook")
class WebhookTriggerApi(Resource):
"""Webhook Trigger API"""
@console_ns.expect(parser)
@setup_required
@login_required
@account_initialization_required
@@ -35,6 +29,7 @@ class WebhookTriggerApi(Resource):
@marshal_with(webhook_trigger_fields)
def get(self, app_model: App):
"""Get webhook trigger for a node"""
parser = reqparse.RequestParser().add_argument("node_id", type=str, required=True, help="Node ID is required")
args = parser.parse_args()
node_id = str(args["node_id"])
@@ -56,7 +51,6 @@ class WebhookTriggerApi(Resource):
return webhook_trigger
@console_ns.route("/apps/<uuid:app_id>/triggers")
class AppTriggersApi(Resource):
"""App Triggers list API"""
@@ -96,16 +90,7 @@ class AppTriggersApi(Resource):
return {"data": triggers}
parser_enable = (
reqparse.RequestParser()
.add_argument("trigger_id", type=str, required=True, nullable=False, location="json")
.add_argument("enable_trigger", type=bool, required=True, nullable=False, location="json")
)
@console_ns.route("/apps/<uuid:app_id>/trigger-enable")
class AppTriggerEnableApi(Resource):
@console_ns.expect(parser_enable)
@setup_required
@login_required
@account_initialization_required
@@ -114,7 +99,12 @@ class AppTriggerEnableApi(Resource):
@marshal_with(trigger_fields)
def post(self, app_model: App):
"""Update app trigger (enable/disable)"""
args = parser_enable.parse_args()
parser = (
reqparse.RequestParser()
.add_argument("trigger_id", type=str, required=True, nullable=False, location="json")
.add_argument("enable_trigger", type=bool, required=True, nullable=False, location="json")
)
args = parser.parse_args()
assert current_user.current_tenant_id is not None
@@ -147,3 +137,8 @@ class AppTriggerEnableApi(Resource):
trigger.icon = "" # type: ignore
return trigger
console_ns.add_resource(WebhookTriggerApi, "/apps/<uuid:app_id>/workflows/triggers/webhook")
console_ns.add_resource(AppTriggersApi, "/apps/<uuid:app_id>/triggers")
console_ns.add_resource(AppTriggerEnableApi, "/apps/<uuid:app_id>/trigger-enable")
+12 -81
View File
@@ -8,10 +8,7 @@ from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
from controllers.console import console_ns
from controllers.console.apikey import (
api_key_item_model,
api_key_list_model,
)
from controllers.console.apikey import api_key_fields, api_key_list
from controllers.console.app.error import ProviderNotInitializeError
from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError
from controllers.console.wraps import (
@@ -30,22 +27,8 @@ from core.rag.extractor.entity.datasource_type import DatasourceType
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo, WebsiteInfo
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from extensions.ext_database import db
from fields.app_fields import app_detail_kernel_fields, related_app_list
from fields.dataset_fields import (
dataset_detail_fields,
dataset_fields,
dataset_query_detail_fields,
dataset_retrieval_model_fields,
doc_metadata_fields,
external_knowledge_info_fields,
external_retrieval_model_fields,
icon_info_fields,
keyword_setting_fields,
reranking_model_fields,
tag_fields,
vector_setting_fields,
weighted_score_fields,
)
from fields.app_fields import related_app_list
from fields.dataset_fields import dataset_detail_fields, dataset_query_detail_fields
from fields.document_fields import document_status_fields
from libs.login import current_account_with_tenant, login_required
from libs.validators import validate_description_length
@@ -55,58 +38,6 @@ from models.provider_ids import ModelProviderID
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
def _get_or_create_model(model_name: str, field_def):
existing = console_ns.models.get(model_name)
if existing is None:
existing = console_ns.model(model_name, field_def)
return existing
# Register models for flask_restx to avoid dict type issues in Swagger
dataset_base_model = _get_or_create_model("DatasetBase", dataset_fields)
tag_model = _get_or_create_model("Tag", tag_fields)
keyword_setting_model = _get_or_create_model("DatasetKeywordSetting", keyword_setting_fields)
vector_setting_model = _get_or_create_model("DatasetVectorSetting", vector_setting_fields)
weighted_score_fields_copy = weighted_score_fields.copy()
weighted_score_fields_copy["keyword_setting"] = fields.Nested(keyword_setting_model)
weighted_score_fields_copy["vector_setting"] = fields.Nested(vector_setting_model)
weighted_score_model = _get_or_create_model("DatasetWeightedScore", weighted_score_fields_copy)
reranking_model = _get_or_create_model("DatasetRerankingModel", reranking_model_fields)
dataset_retrieval_model_fields_copy = dataset_retrieval_model_fields.copy()
dataset_retrieval_model_fields_copy["reranking_model"] = fields.Nested(reranking_model)
dataset_retrieval_model_fields_copy["weights"] = fields.Nested(weighted_score_model, allow_null=True)
dataset_retrieval_model = _get_or_create_model("DatasetRetrievalModel", dataset_retrieval_model_fields_copy)
external_knowledge_info_model = _get_or_create_model("ExternalKnowledgeInfo", external_knowledge_info_fields)
external_retrieval_model = _get_or_create_model("ExternalRetrievalModel", external_retrieval_model_fields)
doc_metadata_model = _get_or_create_model("DatasetDocMetadata", doc_metadata_fields)
icon_info_model = _get_or_create_model("DatasetIconInfo", icon_info_fields)
dataset_detail_fields_copy = dataset_detail_fields.copy()
dataset_detail_fields_copy["retrieval_model_dict"] = fields.Nested(dataset_retrieval_model)
dataset_detail_fields_copy["tags"] = fields.List(fields.Nested(tag_model))
dataset_detail_fields_copy["external_knowledge_info"] = fields.Nested(external_knowledge_info_model)
dataset_detail_fields_copy["external_retrieval_model"] = fields.Nested(external_retrieval_model, allow_null=True)
dataset_detail_fields_copy["doc_metadata"] = fields.List(fields.Nested(doc_metadata_model))
dataset_detail_fields_copy["icon_info"] = fields.Nested(icon_info_model)
dataset_detail_model = _get_or_create_model("DatasetDetail", dataset_detail_fields_copy)
dataset_query_detail_model = _get_or_create_model("DatasetQueryDetail", dataset_query_detail_fields)
app_detail_kernel_model = _get_or_create_model("AppDetailKernel", app_detail_kernel_fields)
related_app_list_copy = related_app_list.copy()
related_app_list_copy["data"] = fields.List(fields.Nested(app_detail_kernel_model))
related_app_list_model = _get_or_create_model("RelatedAppList", related_app_list_copy)
def _validate_name(name: str) -> str:
if not name or len(name) < 1 or len(name) > 40:
raise ValueError("Name must be between 1 to 40 characters.")
@@ -351,7 +282,7 @@ class DatasetApi(Resource):
@console_ns.doc("get_dataset")
@console_ns.doc(description="Get dataset details")
@console_ns.doc(params={"dataset_id": "Dataset ID"})
@console_ns.response(200, "Dataset retrieved successfully", dataset_detail_model)
@console_ns.response(200, "Dataset retrieved successfully", dataset_detail_fields)
@console_ns.response(404, "Dataset not found")
@console_ns.response(403, "Permission denied")
@setup_required
@@ -411,7 +342,7 @@ class DatasetApi(Resource):
},
)
)
@console_ns.response(200, "Dataset updated successfully", dataset_detail_model)
@console_ns.response(200, "Dataset updated successfully", dataset_detail_fields)
@console_ns.response(404, "Dataset not found")
@console_ns.response(403, "Permission denied")
@setup_required
@@ -576,7 +507,7 @@ class DatasetQueryApi(Resource):
@console_ns.doc("get_dataset_queries")
@console_ns.doc(description="Get dataset query history")
@console_ns.doc(params={"dataset_id": "Dataset ID"})
@console_ns.response(200, "Query history retrieved successfully", dataset_query_detail_model)
@console_ns.response(200, "Query history retrieved successfully", dataset_query_detail_fields)
@setup_required
@login_required
@account_initialization_required
@@ -598,7 +529,7 @@ class DatasetQueryApi(Resource):
dataset_queries, total = DatasetService.get_dataset_queries(dataset_id=dataset.id, page=page, per_page=limit)
response = {
"data": marshal(dataset_queries, dataset_query_detail_model),
"data": marshal(dataset_queries, dataset_query_detail_fields),
"has_more": len(dataset_queries) == limit,
"limit": limit,
"total": total,
@@ -722,11 +653,11 @@ class DatasetRelatedAppListApi(Resource):
@console_ns.doc("get_dataset_related_apps")
@console_ns.doc(description="Get applications related to dataset")
@console_ns.doc(params={"dataset_id": "Dataset ID"})
@console_ns.response(200, "Related apps retrieved successfully", related_app_list_model)
@console_ns.response(200, "Related apps retrieved successfully", related_app_list)
@setup_required
@login_required
@account_initialization_required
@marshal_with(related_app_list_model)
@marshal_with(related_app_list)
def get(self, dataset_id):
current_user, _ = current_account_with_tenant()
dataset_id_str = str(dataset_id)
@@ -809,11 +740,11 @@ class DatasetApiKeyApi(Resource):
@console_ns.doc("get_dataset_api_keys")
@console_ns.doc(description="Get dataset API keys")
@console_ns.response(200, "API keys retrieved successfully", api_key_list_model)
@console_ns.response(200, "API keys retrieved successfully", api_key_list)
@setup_required
@login_required
@account_initialization_required
@marshal_with(api_key_list_model)
@marshal_with(api_key_list)
def get(self):
_, current_tenant_id = current_account_with_tenant()
keys = db.session.scalars(
@@ -825,7 +756,7 @@ class DatasetApiKeyApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
@marshal_with(api_key_item_model)
@marshal_with(api_key_fields)
def post(self):
_, current_tenant_id = current_account_with_tenant()
@@ -45,11 +45,9 @@ from core.plugin.impl.exc import PluginDaemonClientSideError
from core.rag.extractor.entity.datasource_type import DatasourceType
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo, WebsiteInfo
from extensions.ext_database import db
from fields.dataset_fields import dataset_fields
from fields.document_fields import (
dataset_and_document_fields,
document_fields,
document_metadata_fields,
document_status_fields,
document_with_segments_fields,
)
@@ -63,36 +61,6 @@ from services.entities.knowledge_entities.knowledge_entities import KnowledgeCon
logger = logging.getLogger(__name__)
def _get_or_create_model(model_name: str, field_def):
existing = console_ns.models.get(model_name)
if existing is None:
existing = console_ns.model(model_name, field_def)
return existing
# Register models for flask_restx to avoid dict type issues in Swagger
dataset_model = _get_or_create_model("Dataset", dataset_fields)
document_metadata_model = _get_or_create_model("DocumentMetadata", document_metadata_fields)
document_fields_copy = document_fields.copy()
document_fields_copy["doc_metadata"] = fields.List(
fields.Nested(document_metadata_model), attribute="doc_metadata_details"
)
document_model = _get_or_create_model("Document", document_fields_copy)
document_with_segments_fields_copy = document_with_segments_fields.copy()
document_with_segments_fields_copy["doc_metadata"] = fields.List(
fields.Nested(document_metadata_model), attribute="doc_metadata_details"
)
document_with_segments_model = _get_or_create_model("DocumentWithSegments", document_with_segments_fields_copy)
dataset_and_document_fields_copy = dataset_and_document_fields.copy()
dataset_and_document_fields_copy["dataset"] = fields.Nested(dataset_model)
dataset_and_document_fields_copy["documents"] = fields.List(fields.Nested(document_model))
dataset_and_document_model = _get_or_create_model("DatasetAndDocument", dataset_and_document_fields_copy)
class DocumentResource(Resource):
def get_document(self, dataset_id: str, document_id: str) -> Document:
current_user, current_tenant_id = current_account_with_tenant()
@@ -201,8 +169,9 @@ class DatasetDocumentListApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, dataset_id: str):
def get(self, dataset_id):
current_user, current_tenant_id = current_account_with_tenant()
dataset_id = str(dataset_id)
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
search = request.args.get("keyword", default=None, type=str)
@@ -307,7 +276,7 @@ class DatasetDocumentListApi(Resource):
@setup_required
@login_required
@account_initialization_required
@marshal_with(dataset_and_document_model)
@marshal_with(dataset_and_document_fields)
@cloud_edition_billing_resource_check("vector_space")
@cloud_edition_billing_rate_limit_check("knowledge")
def post(self, dataset_id):
@@ -401,12 +370,12 @@ class DatasetInitApi(Resource):
},
)
)
@console_ns.response(201, "Dataset initialized successfully", dataset_and_document_model)
@console_ns.response(201, "Dataset initialized successfully", dataset_and_document_fields)
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@marshal_with(dataset_and_document_model)
@marshal_with(dataset_and_document_fields)
@cloud_edition_billing_resource_check("vector_space")
@cloud_edition_billing_rate_limit_check("knowledge")
def post(self):
+2 -59
View File
@@ -6,19 +6,7 @@ import services
from controllers.console import console_ns
from controllers.console.datasets.error import DatasetNameDuplicateError
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from fields.dataset_fields import (
dataset_detail_fields,
dataset_retrieval_model_fields,
doc_metadata_fields,
external_knowledge_info_fields,
external_retrieval_model_fields,
icon_info_fields,
keyword_setting_fields,
reranking_model_fields,
tag_fields,
vector_setting_fields,
weighted_score_fields,
)
from fields.dataset_fields import dataset_detail_fields
from libs.login import current_account_with_tenant, login_required
from services.dataset_service import DatasetService
from services.external_knowledge_service import ExternalDatasetService
@@ -26,51 +14,6 @@ from services.hit_testing_service import HitTestingService
from services.knowledge_service import ExternalDatasetTestService
def _get_or_create_model(model_name: str, field_def):
existing = console_ns.models.get(model_name)
if existing is None:
existing = console_ns.model(model_name, field_def)
return existing
def _build_dataset_detail_model():
keyword_setting_model = _get_or_create_model("DatasetKeywordSetting", keyword_setting_fields)
vector_setting_model = _get_or_create_model("DatasetVectorSetting", vector_setting_fields)
weighted_score_fields_copy = weighted_score_fields.copy()
weighted_score_fields_copy["keyword_setting"] = fields.Nested(keyword_setting_model)
weighted_score_fields_copy["vector_setting"] = fields.Nested(vector_setting_model)
weighted_score_model = _get_or_create_model("DatasetWeightedScore", weighted_score_fields_copy)
reranking_model = _get_or_create_model("DatasetRerankingModel", reranking_model_fields)
dataset_retrieval_model_fields_copy = dataset_retrieval_model_fields.copy()
dataset_retrieval_model_fields_copy["reranking_model"] = fields.Nested(reranking_model)
dataset_retrieval_model_fields_copy["weights"] = fields.Nested(weighted_score_model, allow_null=True)
dataset_retrieval_model = _get_or_create_model("DatasetRetrievalModel", dataset_retrieval_model_fields_copy)
tag_model = _get_or_create_model("Tag", tag_fields)
doc_metadata_model = _get_or_create_model("DatasetDocMetadata", doc_metadata_fields)
external_knowledge_info_model = _get_or_create_model("ExternalKnowledgeInfo", external_knowledge_info_fields)
external_retrieval_model = _get_or_create_model("ExternalRetrievalModel", external_retrieval_model_fields)
icon_info_model = _get_or_create_model("DatasetIconInfo", icon_info_fields)
dataset_detail_fields_copy = dataset_detail_fields.copy()
dataset_detail_fields_copy["retrieval_model_dict"] = fields.Nested(dataset_retrieval_model)
dataset_detail_fields_copy["tags"] = fields.List(fields.Nested(tag_model))
dataset_detail_fields_copy["external_knowledge_info"] = fields.Nested(external_knowledge_info_model)
dataset_detail_fields_copy["external_retrieval_model"] = fields.Nested(external_retrieval_model, allow_null=True)
dataset_detail_fields_copy["doc_metadata"] = fields.List(fields.Nested(doc_metadata_model))
dataset_detail_fields_copy["icon_info"] = fields.Nested(icon_info_model)
return _get_or_create_model("DatasetDetail", dataset_detail_fields_copy)
try:
dataset_detail_model = console_ns.models["DatasetDetail"]
except KeyError:
dataset_detail_model = _build_dataset_detail_model()
def _validate_name(name: str) -> str:
if not name or len(name) < 1 or len(name) > 100:
raise ValueError("Name must be between 1 to 100 characters.")
@@ -251,7 +194,7 @@ class ExternalDatasetCreateApi(Resource):
},
)
)
@console_ns.response(201, "External dataset created successfully", dataset_detail_model)
@console_ns.response(201, "External dataset created successfully", dataset_detail_fields)
@console_ns.response(400, "Invalid parameters")
@console_ns.response(403, "Permission denied")
@setup_required
+5 -17
View File
@@ -15,6 +15,7 @@ from controllers.console.app.error import (
from controllers.console.explore.error import NotChatAppError, NotCompletionAppError
from controllers.console.explore.wraps import InstalledAppResource
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -30,7 +31,6 @@ from libs.login import current_user
from models import Account
from models.model import AppMode
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.errors.llm import InvokeRateLimitError
from .. import console_ns
@@ -46,7 +46,7 @@ logger = logging.getLogger(__name__)
class CompletionApi(InstalledAppResource):
def post(self, installed_app):
app_model = installed_app.app
if app_model.mode != AppMode.COMPLETION:
if app_model.mode != "completion":
raise NotCompletionAppError()
parser = (
@@ -102,18 +102,12 @@ class CompletionApi(InstalledAppResource):
class CompletionStopApi(InstalledAppResource):
def post(self, installed_app, task_id):
app_model = installed_app.app
if app_model.mode != AppMode.COMPLETION:
if app_model.mode != "completion":
raise NotCompletionAppError()
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=current_user.id,
app_mode=AppMode.value_of(app_model.mode),
)
AppQueueManager.set_stop_flag(task_id, InvokeFrom.EXPLORE, current_user.id)
return {"result": "success"}, 200
@@ -190,12 +184,6 @@ class ChatStopApi(InstalledAppResource):
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=current_user.id,
app_mode=app_mode,
)
AppQueueManager.set_stop_flag(task_id, InvokeFrom.EXPLORE, current_user.id)
return {"result": "success"}, 200
+8 -12
View File
@@ -9,10 +9,6 @@ from models.api_based_extension import APIBasedExtension
from services.api_based_extension_service import APIBasedExtensionService
from services.code_based_extension_service import CodeBasedExtensionService
api_based_extension_model = console_ns.model("ApiBasedExtensionModel", api_based_extension_fields)
api_based_extension_list_model = fields.List(fields.Nested(api_based_extension_model))
@console_ns.route("/code-based-extension")
class CodeBasedExtensionAPI(Resource):
@@ -45,11 +41,11 @@ class CodeBasedExtensionAPI(Resource):
class APIBasedExtensionAPI(Resource):
@console_ns.doc("get_api_based_extensions")
@console_ns.doc(description="Get all API-based extensions for current tenant")
@console_ns.response(200, "Success", api_based_extension_list_model)
@console_ns.response(200, "Success", fields.List(fields.Nested(api_based_extension_fields)))
@setup_required
@login_required
@account_initialization_required
@marshal_with(api_based_extension_model)
@marshal_with(api_based_extension_fields)
def get(self):
_, tenant_id = current_account_with_tenant()
return APIBasedExtensionService.get_all_by_tenant_id(tenant_id)
@@ -66,11 +62,11 @@ class APIBasedExtensionAPI(Resource):
},
)
)
@console_ns.response(201, "Extension created successfully", api_based_extension_model)
@console_ns.response(201, "Extension created successfully", api_based_extension_fields)
@setup_required
@login_required
@account_initialization_required
@marshal_with(api_based_extension_model)
@marshal_with(api_based_extension_fields)
def post(self):
args = console_ns.payload
_, current_tenant_id = current_account_with_tenant()
@@ -90,11 +86,11 @@ class APIBasedExtensionDetailAPI(Resource):
@console_ns.doc("get_api_based_extension")
@console_ns.doc(description="Get API-based extension by ID")
@console_ns.doc(params={"id": "Extension ID"})
@console_ns.response(200, "Success", api_based_extension_model)
@console_ns.response(200, "Success", api_based_extension_fields)
@setup_required
@login_required
@account_initialization_required
@marshal_with(api_based_extension_model)
@marshal_with(api_based_extension_fields)
def get(self, id):
api_based_extension_id = str(id)
_, tenant_id = current_account_with_tenant()
@@ -114,11 +110,11 @@ class APIBasedExtensionDetailAPI(Resource):
},
)
)
@console_ns.response(200, "Extension updated successfully", api_based_extension_model)
@console_ns.response(200, "Extension updated successfully", api_based_extension_fields)
@setup_required
@login_required
@account_initialization_required
@marshal_with(api_based_extension_model)
@marshal_with(api_based_extension_fields)
def post(self, id):
api_based_extension_id = str(id)
_, current_tenant_id = current_account_with_tenant()
@@ -6,6 +6,8 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, Forbidden
from configs import dify_config
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
from controllers.web.error import NotFoundError
from core.model_runtime.utils.encoders import jsonable_encoder
from core.plugin.entities.plugin_daemon import CredentialType
@@ -21,13 +23,9 @@ from services.trigger.trigger_provider_service import TriggerProviderService
from services.trigger.trigger_subscription_builder_service import TriggerSubscriptionBuilderService
from services.trigger.trigger_subscription_operator_service import TriggerSubscriptionOperatorService
from .. import console_ns
from ..wraps import account_initialization_required, is_admin_or_owner_required, setup_required
logger = logging.getLogger(__name__)
@console_ns.route("/workspaces/current/trigger-provider/<path:provider>/icon")
class TriggerProviderIconApi(Resource):
@setup_required
@login_required
@@ -40,7 +38,6 @@ class TriggerProviderIconApi(Resource):
return TriggerManager.get_trigger_plugin_icon(tenant_id=user.current_tenant_id, provider_id=provider)
@console_ns.route("/workspaces/current/triggers")
class TriggerProviderListApi(Resource):
@setup_required
@login_required
@@ -53,7 +50,6 @@ class TriggerProviderListApi(Resource):
return jsonable_encoder(TriggerProviderService.list_trigger_providers(user.current_tenant_id))
@console_ns.route("/workspaces/current/trigger-provider/<path:provider>/info")
class TriggerProviderInfoApi(Resource):
@setup_required
@login_required
@@ -68,7 +64,6 @@ class TriggerProviderInfoApi(Resource):
)
@console_ns.route("/workspaces/current/trigger-provider/<path:provider>/subscriptions/list")
class TriggerSubscriptionListApi(Resource):
@setup_required
@login_required
@@ -92,16 +87,7 @@ class TriggerSubscriptionListApi(Resource):
raise
parser = reqparse.RequestParser().add_argument(
"credential_type", type=str, required=False, nullable=True, location="json"
)
@console_ns.route(
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/create",
)
class TriggerSubscriptionBuilderCreateApi(Resource):
@console_ns.expect(parser)
@setup_required
@login_required
@is_admin_or_owner_required
@@ -111,6 +97,9 @@ class TriggerSubscriptionBuilderCreateApi(Resource):
user = current_user
assert user.current_tenant_id is not None
parser = reqparse.RequestParser().add_argument(
"credential_type", type=str, required=False, nullable=True, location="json"
)
args = parser.parse_args()
try:
@@ -127,9 +116,6 @@ class TriggerSubscriptionBuilderCreateApi(Resource):
raise
@console_ns.route(
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/<path:subscription_builder_id>",
)
class TriggerSubscriptionBuilderGetApi(Resource):
@setup_required
@login_required
@@ -141,18 +127,7 @@ class TriggerSubscriptionBuilderGetApi(Resource):
)
parser_api = (
reqparse.RequestParser()
# The credentials of the subscription builder
.add_argument("credentials", type=dict, required=False, nullable=True, location="json")
)
@console_ns.route(
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/verify/<path:subscription_builder_id>",
)
class TriggerSubscriptionBuilderVerifyApi(Resource):
@console_ns.expect(parser_api)
@setup_required
@login_required
@is_admin_or_owner_required
@@ -161,8 +136,12 @@ class TriggerSubscriptionBuilderVerifyApi(Resource):
"""Verify a subscription instance for a trigger provider"""
user = current_user
assert user.current_tenant_id is not None
args = parser_api.parse_args()
parser = (
reqparse.RequestParser()
# The credentials of the subscription builder
.add_argument("credentials", type=dict, required=False, nullable=True, location="json")
)
args = parser.parse_args()
try:
# Use atomic update_and_verify to prevent race conditions
@@ -180,24 +159,7 @@ class TriggerSubscriptionBuilderVerifyApi(Resource):
raise ValueError(str(e)) from e
parser_update_api = (
reqparse.RequestParser()
# The name of the subscription builder
.add_argument("name", type=str, required=False, nullable=True, location="json")
# The parameters of the subscription builder
.add_argument("parameters", type=dict, required=False, nullable=True, location="json")
# The properties of the subscription builder
.add_argument("properties", type=dict, required=False, nullable=True, location="json")
# The credentials of the subscription builder
.add_argument("credentials", type=dict, required=False, nullable=True, location="json")
)
@console_ns.route(
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/update/<path:subscription_builder_id>",
)
class TriggerSubscriptionBuilderUpdateApi(Resource):
@console_ns.expect(parser_update_api)
@setup_required
@login_required
@account_initialization_required
@@ -207,7 +169,18 @@ class TriggerSubscriptionBuilderUpdateApi(Resource):
assert isinstance(user, Account)
assert user.current_tenant_id is not None
args = parser_update_api.parse_args()
parser = (
reqparse.RequestParser()
# The name of the subscription builder
.add_argument("name", type=str, required=False, nullable=True, location="json")
# The parameters of the subscription builder
.add_argument("parameters", type=dict, required=False, nullable=True, location="json")
# The properties of the subscription builder
.add_argument("properties", type=dict, required=False, nullable=True, location="json")
# The credentials of the subscription builder
.add_argument("credentials", type=dict, required=False, nullable=True, location="json")
)
args = parser.parse_args()
try:
return jsonable_encoder(
TriggerSubscriptionBuilderService.update_trigger_subscription_builder(
@@ -227,9 +200,6 @@ class TriggerSubscriptionBuilderUpdateApi(Resource):
raise
@console_ns.route(
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/logs/<path:subscription_builder_id>",
)
class TriggerSubscriptionBuilderLogsApi(Resource):
@setup_required
@login_required
@@ -248,11 +218,7 @@ class TriggerSubscriptionBuilderLogsApi(Resource):
raise
@console_ns.route(
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/build/<path:subscription_builder_id>",
)
class TriggerSubscriptionBuilderBuildApi(Resource):
@console_ns.expect(parser_update_api)
@setup_required
@login_required
@is_admin_or_owner_required
@@ -261,7 +227,18 @@ class TriggerSubscriptionBuilderBuildApi(Resource):
"""Build a subscription instance for a trigger provider"""
user = current_user
assert user.current_tenant_id is not None
args = parser_update_api.parse_args()
parser = (
reqparse.RequestParser()
# The name of the subscription builder
.add_argument("name", type=str, required=False, nullable=True, location="json")
# The parameters of the subscription builder
.add_argument("parameters", type=dict, required=False, nullable=True, location="json")
# The properties of the subscription builder
.add_argument("properties", type=dict, required=False, nullable=True, location="json")
# The credentials of the subscription builder
.add_argument("credentials", type=dict, required=False, nullable=True, location="json")
)
args = parser.parse_args()
try:
# Use atomic update_and_build to prevent race conditions
TriggerSubscriptionBuilderService.update_and_build_builder(
@@ -281,9 +258,6 @@ class TriggerSubscriptionBuilderBuildApi(Resource):
raise ValueError(str(e)) from e
@console_ns.route(
"/workspaces/current/trigger-provider/<path:subscription_id>/subscriptions/delete",
)
class TriggerSubscriptionDeleteApi(Resource):
@setup_required
@login_required
@@ -317,7 +291,6 @@ class TriggerSubscriptionDeleteApi(Resource):
raise
@console_ns.route("/workspaces/current/trigger-provider/<path:provider>/subscriptions/oauth/authorize")
class TriggerOAuthAuthorizeApi(Resource):
@setup_required
@login_required
@@ -401,7 +374,6 @@ class TriggerOAuthAuthorizeApi(Resource):
raise
@console_ns.route("/oauth/plugin/<path:provider>/trigger/callback")
class TriggerOAuthCallbackApi(Resource):
@setup_required
def get(self, provider):
@@ -466,14 +438,6 @@ class TriggerOAuthCallbackApi(Resource):
return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback")
parser_oauth_client = (
reqparse.RequestParser()
.add_argument("client_params", type=dict, required=False, nullable=True, location="json")
.add_argument("enabled", type=bool, required=False, nullable=True, location="json")
)
@console_ns.route("/workspaces/current/trigger-provider/<path:provider>/oauth/client")
class TriggerOAuthClientManageApi(Resource):
@setup_required
@login_required
@@ -520,7 +484,6 @@ class TriggerOAuthClientManageApi(Resource):
logger.exception("Error getting OAuth client", exc_info=e)
raise
@console_ns.expect(parser_oauth_client)
@setup_required
@login_required
@is_admin_or_owner_required
@@ -530,7 +493,12 @@ class TriggerOAuthClientManageApi(Resource):
user = current_user
assert user.current_tenant_id is not None
args = parser_oauth_client.parse_args()
parser = (
reqparse.RequestParser()
.add_argument("client_params", type=dict, required=False, nullable=True, location="json")
.add_argument("enabled", type=bool, required=False, nullable=True, location="json")
)
args = parser.parse_args()
try:
provider_id = TriggerProviderID(provider)
@@ -568,3 +536,52 @@ class TriggerOAuthClientManageApi(Resource):
except Exception as e:
logger.exception("Error removing OAuth client", exc_info=e)
raise
# Trigger Subscription
console_ns.add_resource(TriggerProviderIconApi, "/workspaces/current/trigger-provider/<path:provider>/icon")
console_ns.add_resource(TriggerProviderListApi, "/workspaces/current/triggers")
console_ns.add_resource(TriggerProviderInfoApi, "/workspaces/current/trigger-provider/<path:provider>/info")
console_ns.add_resource(
TriggerSubscriptionListApi, "/workspaces/current/trigger-provider/<path:provider>/subscriptions/list"
)
console_ns.add_resource(
TriggerSubscriptionDeleteApi,
"/workspaces/current/trigger-provider/<path:subscription_id>/subscriptions/delete",
)
# Trigger Subscription Builder
console_ns.add_resource(
TriggerSubscriptionBuilderCreateApi,
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/create",
)
console_ns.add_resource(
TriggerSubscriptionBuilderGetApi,
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/<path:subscription_builder_id>",
)
console_ns.add_resource(
TriggerSubscriptionBuilderUpdateApi,
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/update/<path:subscription_builder_id>",
)
console_ns.add_resource(
TriggerSubscriptionBuilderVerifyApi,
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/verify/<path:subscription_builder_id>",
)
console_ns.add_resource(
TriggerSubscriptionBuilderBuildApi,
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/build/<path:subscription_builder_id>",
)
console_ns.add_resource(
TriggerSubscriptionBuilderLogsApi,
"/workspaces/current/trigger-provider/<path:provider>/subscriptions/builder/logs/<path:subscription_builder_id>",
)
# OAuth
console_ns.add_resource(
TriggerOAuthAuthorizeApi, "/workspaces/current/trigger-provider/<path:provider>/subscriptions/oauth/authorize"
)
console_ns.add_resource(TriggerOAuthCallbackApi, "/oauth/plugin/<path:provider>/trigger/callback")
console_ns.add_resource(
TriggerOAuthClientManageApi, "/workspaces/current/trigger-provider/<path:provider>/oauth/client"
)
+2
View File
@@ -19,6 +19,7 @@ from .app import (
annotation,
app,
audio,
chatflow_memory,
completion,
conversation,
file,
@@ -40,6 +41,7 @@ __all__ = [
"annotation",
"app",
"audio",
"chatflow_memory",
"completion",
"conversation",
"dataset",
@@ -0,0 +1,124 @@
from flask_restx import Resource, reqparse
from controllers.service_api import api
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.memory.entities import MemoryBlock, MemoryCreatedBy
from core.workflow.runtime import VariablePool
from models import App, EndUser
from services.chatflow_memory_service import ChatflowMemoryService
from services.workflow_service import WorkflowService
class MemoryListApi(Resource):
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
def get(self, app_model: App, end_user: EndUser):
parser = reqparse.RequestParser()
parser.add_argument("conversation_id", required=False, type=str | None, default=None)
parser.add_argument("memory_id", required=False, type=str | None, default=None)
parser.add_argument("version", required=False, type=int | None, default=None)
args = parser.parse_args()
conversation_id: str | None = args.get("conversation_id")
memory_id = args.get("memory_id")
version = args.get("version")
if conversation_id:
result = ChatflowMemoryService.get_persistent_memories_with_conversation(
app_model,
MemoryCreatedBy(end_user_id=end_user.id),
conversation_id,
version
)
session_memories = ChatflowMemoryService.get_session_memories_with_conversation(
app_model,
MemoryCreatedBy(end_user_id=end_user.id),
conversation_id,
version
)
result = [*result, *session_memories]
else:
result = ChatflowMemoryService.get_persistent_memories(
app_model,
MemoryCreatedBy(end_user_id=end_user.id),
version
)
if memory_id:
result = [it for it in result if it.spec.id == memory_id]
return [it for it in result if it.spec.end_user_visible]
class MemoryEditApi(Resource):
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
def put(self, app_model: App, end_user: EndUser):
parser = reqparse.RequestParser()
parser.add_argument('id', type=str, required=True)
parser.add_argument("conversation_id", type=str | None, required=False, default=None)
parser.add_argument('node_id', type=str | None, required=False, default=None)
parser.add_argument('update', type=str, required=True)
args = parser.parse_args()
workflow = WorkflowService().get_published_workflow(app_model)
update = args.get("update")
conversation_id = args.get("conversation_id")
node_id = args.get("node_id")
if not isinstance(update, str):
return {'error': 'Invalid update'}, 400
if not workflow:
return {'error': 'Workflow not found'}, 404
memory_spec = next((it for it in workflow.memory_blocks if it.id == args['id']), None)
if not memory_spec:
return {'error': 'Memory not found'}, 404
# First get existing memory
existing_memory = ChatflowMemoryService.get_memory_by_spec(
spec=memory_spec,
tenant_id=app_model.tenant_id,
app_id=app_model.id,
created_by=MemoryCreatedBy(end_user_id=end_user.id),
conversation_id=conversation_id,
node_id=node_id,
is_draft=False
)
# Create updated memory instance with incremented version
updated_memory = MemoryBlock(
spec=existing_memory.spec,
tenant_id=existing_memory.tenant_id,
app_id=existing_memory.app_id,
conversation_id=existing_memory.conversation_id,
node_id=existing_memory.node_id,
value=update, # New value
version=existing_memory.version + 1, # Increment version for update
edited_by_user=True,
created_by=existing_memory.created_by,
)
ChatflowMemoryService.save_memory(updated_memory, VariablePool(), False)
return '', 204
class MemoryDeleteApi(Resource):
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
def delete(self, app_model: App, end_user: EndUser):
parser = reqparse.RequestParser()
parser.add_argument('id', type=str, required=False, default=None)
args = parser.parse_args()
memory_id = args.get('id')
if memory_id:
ChatflowMemoryService.delete_memory(
app_model,
memory_id,
MemoryCreatedBy(end_user_id=end_user.id)
)
return '', 204
else:
ChatflowMemoryService.delete_all_user_memories(
app_model,
MemoryCreatedBy(end_user_id=end_user.id)
)
return '', 200
api.add_resource(MemoryListApi, '/memories')
api.add_resource(MemoryEditApi, '/memory-edit')
api.add_resource(MemoryDeleteApi, '/memories')
+5 -15
View File
@@ -17,6 +17,7 @@ from controllers.service_api.app.error import (
)
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -29,7 +30,6 @@ from libs import helper
from libs.helper import uuid_value
from models.model import App, AppMode, EndUser
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
@@ -88,7 +88,7 @@ class CompletionApi(Resource):
This endpoint generates a completion based on the provided inputs and query.
Supports both blocking and streaming response modes.
"""
if app_model.mode != AppMode.COMPLETION:
if app_model.mode != "completion":
raise AppUnavailableError()
args = completion_parser.parse_args()
@@ -147,15 +147,10 @@ class CompletionStopApi(Resource):
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
def post(self, app_model: App, end_user: EndUser, task_id: str):
"""Stop a running completion task."""
if app_model.mode != AppMode.COMPLETION:
if app_model.mode != "completion":
raise AppUnavailableError()
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.SERVICE_API,
user_id=end_user.id,
app_mode=AppMode.value_of(app_model.mode),
)
AppQueueManager.set_stop_flag(task_id, InvokeFrom.SERVICE_API, end_user.id)
return {"result": "success"}, 200
@@ -249,11 +244,6 @@ class ChatStopApi(Resource):
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.SERVICE_API,
user_id=end_user.id,
app_mode=app_mode,
)
AppQueueManager.set_stop_flag(task_id, InvokeFrom.SERVICE_API, end_user.id)
return {"result": "success"}, 200
+3 -9
View File
@@ -1,7 +1,7 @@
import logging
import time
from flask import jsonify, request
from flask import jsonify
from werkzeug.exceptions import NotFound, RequestEntityTooLarge
from controllers.trigger import bp
@@ -28,14 +28,8 @@ def _prepare_webhook_execution(webhook_id: str, is_debug: bool = False):
webhook_data = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)
return webhook_trigger, workflow, node_config, webhook_data, None
except ValueError as e:
# Provide minimal context for error reporting without risking another parse failure
webhook_data = {
"method": request.method,
"headers": dict(request.headers),
"query_params": dict(request.args),
"body": {},
"files": {},
}
# Fall back to raw extraction for error reporting
webhook_data = WebhookService.extract_webhook_data(webhook_trigger)
return webhook_trigger, workflow, node_config, webhook_data, str(e)
+2
View File
@@ -18,6 +18,7 @@ web_ns = Namespace("web", description="Web application API operations", path="/"
from . import (
app,
audio,
chatflow_memory,
completion,
conversation,
feature,
@@ -39,6 +40,7 @@ __all__ = [
"app",
"audio",
"bp",
"chatflow_memory",
"completion",
"conversation",
"feature",
+123
View File
@@ -0,0 +1,123 @@
from flask_restx import reqparse
from controllers.web import api
from controllers.web.wraps import WebApiResource
from core.memory.entities import MemoryBlock, MemoryCreatedBy
from core.workflow.runtime import VariablePool
from models import App, EndUser
from services.chatflow_memory_service import ChatflowMemoryService
from services.workflow_service import WorkflowService
class MemoryListApi(WebApiResource):
def get(self, app_model: App, end_user: EndUser):
parser = reqparse.RequestParser()
parser.add_argument("conversation_id", required=False, type=str | None, default=None)
parser.add_argument("memory_id", required=False, type=str | None, default=None)
parser.add_argument("version", required=False, type=int | None, default=None)
args = parser.parse_args()
conversation_id: str | None = args.get("conversation_id")
memory_id = args.get("memory_id")
version = args.get("version")
if conversation_id:
result = ChatflowMemoryService.get_persistent_memories_with_conversation(
app_model,
MemoryCreatedBy(end_user_id=end_user.id),
conversation_id,
version
)
session_memories = ChatflowMemoryService.get_session_memories_with_conversation(
app_model,
MemoryCreatedBy(end_user_id=end_user.id),
conversation_id,
version
)
result = [*result, *session_memories]
else:
result = ChatflowMemoryService.get_persistent_memories(
app_model,
MemoryCreatedBy(end_user_id=end_user.id),
version
)
if memory_id:
result = [it for it in result if it.spec.id == memory_id]
return [it for it in result if it.spec.end_user_visible]
class MemoryEditApi(WebApiResource):
def put(self, app_model: App, end_user: EndUser):
parser = reqparse.RequestParser()
parser.add_argument('id', type=str, required=True)
parser.add_argument("conversation_id", type=str | None, required=False, default=None)
parser.add_argument('node_id', type=str | None, required=False, default=None)
parser.add_argument('update', type=str, required=True)
args = parser.parse_args()
workflow = WorkflowService().get_published_workflow(app_model)
update = args.get("update")
conversation_id = args.get("conversation_id")
node_id = args.get("node_id")
if not isinstance(update, str):
return {'error': 'Update must be a string'}, 400
if not workflow:
return {'error': 'Workflow not found'}, 404
memory_spec = next((it for it in workflow.memory_blocks if it.id == args['id']), None)
if not memory_spec:
return {'error': 'Memory not found'}, 404
if not memory_spec.end_user_editable:
return {'error': 'Memory not editable'}, 403
# First get existing memory
existing_memory = ChatflowMemoryService.get_memory_by_spec(
spec=memory_spec,
tenant_id=app_model.tenant_id,
app_id=app_model.id,
created_by=MemoryCreatedBy(end_user_id=end_user.id),
conversation_id=conversation_id,
node_id=node_id,
is_draft=False
)
# Create updated memory instance with incremented version
updated_memory = MemoryBlock(
spec=existing_memory.spec,
tenant_id=existing_memory.tenant_id,
app_id=existing_memory.app_id,
conversation_id=existing_memory.conversation_id,
node_id=existing_memory.node_id,
value=update, # New value
version=existing_memory.version + 1, # Increment version for update
edited_by_user=True,
created_by=existing_memory.created_by,
)
ChatflowMemoryService.save_memory(updated_memory, VariablePool(), False)
return '', 204
class MemoryDeleteApi(WebApiResource):
def delete(self, app_model: App, end_user: EndUser):
parser = reqparse.RequestParser()
parser.add_argument('id', type=str, required=False, default=None)
args = parser.parse_args()
memory_id = args.get('id')
if memory_id:
ChatflowMemoryService.delete_memory(
app_model,
memory_id,
MemoryCreatedBy(end_user_id=end_user.id)
)
return '', 204
else:
ChatflowMemoryService.delete_all_user_memories(
app_model,
MemoryCreatedBy(end_user_id=end_user.id)
)
return '', 200
api.add_resource(MemoryListApi, '/memories')
api.add_resource(MemoryEditApi, '/memory-edit')
api.add_resource(MemoryDeleteApi, '/memories')
+5 -15
View File
@@ -17,6 +17,7 @@ from controllers.web.error import (
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from controllers.web.wraps import WebApiResource
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -28,7 +29,6 @@ from libs import helper
from libs.helper import uuid_value
from models.model import AppMode
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.errors.llm import InvokeRateLimitError
logger = logging.getLogger(__name__)
@@ -64,7 +64,7 @@ class CompletionApi(WebApiResource):
}
)
def post(self, app_model, end_user):
if app_model.mode != AppMode.COMPLETION:
if app_model.mode != "completion":
raise NotCompletionAppError()
parser = (
@@ -125,15 +125,10 @@ class CompletionStopApi(WebApiResource):
}
)
def post(self, app_model, end_user, task_id):
if app_model.mode != AppMode.COMPLETION:
if app_model.mode != "completion":
raise NotCompletionAppError()
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.WEB_APP,
user_id=end_user.id,
app_mode=AppMode.value_of(app_model.mode),
)
AppQueueManager.set_stop_flag(task_id, InvokeFrom.WEB_APP, end_user.id)
return {"result": "success"}, 200
@@ -239,11 +234,6 @@ class ChatStopApi(WebApiResource):
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.WEB_APP,
user_id=end_user.id,
app_mode=app_mode,
)
AppQueueManager.set_stop_flag(task_id, InvokeFrom.WEB_APP, end_user.id)
return {"result": "success"}, 200
+102 -1
View File
@@ -1,10 +1,11 @@
import logging
import time
from collections.abc import Mapping, Sequence
from collections.abc import Mapping, MutableMapping, Sequence
from typing import Any, cast
from sqlalchemy import select
from sqlalchemy.orm import Session
from typing_extensions import override
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfig
from core.app.apps.base_app_queue_manager import AppQueueManager
@@ -20,6 +21,8 @@ from core.app.entities.queue_entities import (
QueueTextChunkEvent,
)
from core.app.features.annotation_reply.annotation_reply import AnnotationReplyFeature
from core.memory.entities import MemoryCreatedBy, MemoryScope
from core.model_runtime.entities import AssistantPromptMessage, UserPromptMessage
from core.moderation.base import ModerationError
from core.moderation.input_moderation import InputModeration
from core.variables.variables import VariableUnion
@@ -27,6 +30,7 @@ from core.workflow.enums import WorkflowType
from core.workflow.graph_engine.command_channels.redis_channel import RedisChannel
from core.workflow.graph_engine.layers.base import GraphEngineLayer
from core.workflow.graph_engine.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
from core.workflow.graph_events import GraphRunSucceededEvent
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
from core.workflow.runtime import GraphRuntimeState, VariablePool
@@ -39,6 +43,8 @@ from models import Workflow
from models.enums import UserFrom
from models.model import App, Conversation, Message, MessageAnnotation
from models.workflow import ConversationVariable
from services.chatflow_history_service import ChatflowHistoryService
from services.chatflow_memory_service import ChatflowMemoryService
logger = logging.getLogger(__name__)
@@ -81,6 +87,11 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
self._workflow_node_execution_repository = workflow_node_execution_repository
def run(self):
ChatflowMemoryService.wait_for_sync_memory_completion(
workflow=self._workflow,
conversation_id=self.conversation.id
)
app_config = self.application_generate_entity.app_config
app_config = cast(AdvancedChatAppConfig, app_config)
@@ -143,6 +154,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
# Based on the definition of `VariableUnion`,
# `list[Variable]` can be safely used as `list[VariableUnion]` since they are compatible.
conversation_variables=conversation_variables,
memory_blocks=self._fetch_memory_blocks(),
)
# init graph
@@ -206,6 +218,31 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
for event in generator:
self._handle_event(workflow_entry, event)
try:
self._check_app_memory_updates(variable_pool)
except Exception as e:
logger.exception("Failed to check app memory updates", exc_info=e)
@override
def _handle_event(self, workflow_entry: WorkflowEntry, event: Any) -> None:
super()._handle_event(workflow_entry, event)
if isinstance(event, GraphRunSucceededEvent):
workflow_outputs = event.outputs
if not workflow_outputs:
logger.warning("Chatflow output is empty.")
return
assistant_message = workflow_outputs.get('answer')
if not assistant_message:
logger.warning("Chatflow output does not contain 'answer'.")
return
if not isinstance(assistant_message, str):
logger.warning("Chatflow output 'answer' is not a string.")
return
try:
self._sync_conversation_to_chatflow_tables(assistant_message)
except Exception as e:
logger.exception("Failed to sync conversation to memory tables", exc_info=e)
def handle_input_moderation(
self,
app_record: App,
@@ -403,3 +440,67 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
# Return combined list
return existing_variables + new_variables
def _fetch_memory_blocks(self) -> Mapping[str, str]:
"""fetch all memory blocks for current app"""
memory_blocks_dict: MutableMapping[str, str] = {}
is_draft = (self.application_generate_entity.invoke_from == InvokeFrom.DEBUGGER)
conversation_id = self.conversation.id
memory_block_specs = self._workflow.memory_blocks
# Get runtime memory values
memories = ChatflowMemoryService.get_memories_by_specs(
memory_block_specs=memory_block_specs,
tenant_id=self._workflow.tenant_id,
app_id=self._workflow.app_id,
node_id=None,
conversation_id=conversation_id,
is_draft=is_draft,
created_by=self._get_created_by(),
)
# Build memory_id -> value mapping
for memory in memories:
if memory.spec.scope == MemoryScope.APP:
# App level: use memory_id directly
memory_blocks_dict[memory.spec.id] = memory.value
else: # NODE scope
node_id = memory.node_id
if not node_id:
logger.warning("Memory block %s has no node_id, skip.", memory.spec.id)
continue
key = f"{node_id}_{memory.spec.id}"
memory_blocks_dict[key] = memory.value
return memory_blocks_dict
def _sync_conversation_to_chatflow_tables(self, assistant_message: str):
ChatflowHistoryService.save_app_message(
prompt_message=UserPromptMessage(content=(self.application_generate_entity.query)),
conversation_id=self.conversation.id,
app_id=self._workflow.app_id,
tenant_id=self._workflow.tenant_id
)
ChatflowHistoryService.save_app_message(
prompt_message=AssistantPromptMessage(content=assistant_message),
conversation_id=self.conversation.id,
app_id=self._workflow.app_id,
tenant_id=self._workflow.tenant_id
)
def _check_app_memory_updates(self, variable_pool: VariablePool):
is_draft = (self.application_generate_entity.invoke_from == InvokeFrom.DEBUGGER)
ChatflowMemoryService.update_app_memory_if_needed(
workflow=self._workflow,
conversation_id=self.conversation.id,
variable_pool=variable_pool,
is_draft=is_draft,
created_by=self._get_created_by()
)
def _get_created_by(self) -> MemoryCreatedBy:
if self.application_generate_entity.invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.EXPLORE}:
return MemoryCreatedBy(account_id=self.application_generate_entity.user_id)
else:
return MemoryCreatedBy(end_user_id=self.application_generate_entity.user_id)
+2 -11
View File
@@ -155,17 +155,8 @@ class BaseAppGenerator:
f"{variable_entity.variable} in input form must be less than {variable_entity.max_length} files"
)
case VariableEntityType.CHECKBOX:
if isinstance(value, str):
normalized_value = value.strip().lower()
if normalized_value in {"true", "1", "yes", "on"}:
value = True
elif normalized_value in {"false", "0", "no", "off"}:
value = False
elif isinstance(value, (int, float)):
if value == 1:
value = True
elif value == 0:
value = False
if not isinstance(value, bool):
raise ValueError(f"{variable_entity.variable} in input form must be a valid boolean value")
case _:
raise AssertionError("this statement should be unreachable.")
+441 -12
View File
@@ -1,8 +1,8 @@
import json
import logging
import re
from collections.abc import Sequence
from typing import Protocol, cast
from collections.abc import Callable, Mapping, Sequence
from typing import Any, Protocol, cast
import json_repair
@@ -14,10 +14,16 @@ from core.llm_generator.prompts import (
JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE,
LLM_MODIFY_CODE_SYSTEM,
LLM_MODIFY_PROMPT_SYSTEM,
MEMORY_INSTRUCTION_EDIT_SYSTEM_PROMPT,
MEMORY_INSTRUCTION_GENERATION_SYSTEM_PROMPT,
MEMORY_TEMPLATE_EDIT_SYSTEM_PROMPT,
MEMORY_TEMPLATE_GENERATION_SYSTEM_PROMPT,
MEMORY_UPDATE_PROMPT,
PYTHON_CODE_GENERATOR_PROMPT_TEMPLATE,
SYSTEM_STRUCTURED_OUTPUT_GENERATE,
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
)
from core.memory.entities import MemoryBlock, MemoryBlockSpec
from core.model_manager import ModelManager
from core.model_runtime.entities.llm_entities import LLMResult
from core.model_runtime.entities.message_entities import PromptMessage, SystemPromptMessage, UserPromptMessage
@@ -28,6 +34,7 @@ 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.runtime import VariablePool
from extensions.ext_database import db
from extensions.ext_storage import storage
from models import App, Message, WorkflowNodeExecutionModel
@@ -506,16 +513,17 @@ class LLMGenerator:
node_type: str,
ideal_output: str | None,
):
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")
# Use unified variable injector
variable_providers = {
"last_run": lambda: json.dumps(last_run) if last_run else "null",
"current": lambda: current or "null",
"error_message": lambda: error_message or "null",
}
injected_instruction = LLMGenerator.__inject_variables(
instruction=instruction,
variable_providers=variable_providers
)
model_instance = ModelManager().get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
@@ -562,3 +570,424 @@ class LLMGenerator:
"Failed to invoke LLM model, model: %s", json.dumps(model_config.get("name")), exc_info=True
)
return {"error": f"An unexpected error occurred: {str(e)}"}
@staticmethod
def update_memory_block(
tenant_id: str,
visible_history: Sequence[tuple[str, str]],
variable_pool: VariablePool,
memory_block: MemoryBlock,
memory_spec: MemoryBlockSpec
) -> str:
model_instance = ModelManager().get_model_instance(
tenant_id=tenant_id,
provider=memory_spec.model.provider,
model=memory_spec.model.name,
model_type=ModelType.LLM,
)
formatted_history = ""
for sender, message in visible_history:
formatted_history += f"{sender}: {message}\n"
filled_instruction = variable_pool.convert_template(memory_spec.instruction).text
formatted_prompt = PromptTemplateParser(MEMORY_UPDATE_PROMPT).format(
inputs={
"formatted_history": formatted_history,
"current_value": memory_block.value,
"instruction": filled_instruction,
}
)
llm_result = model_instance.invoke_llm(
prompt_messages=[UserPromptMessage(content=formatted_prompt)],
model_parameters=memory_spec.model.completion_params,
stream=False,
)
return llm_result.message.get_text_content()
@staticmethod
def generate_memory_template(
tenant_id: str,
instruction: str,
model_config: dict,
) -> dict:
"""
Generate Memory Template
Uses MEMORY_TEMPLATE_GENERATION_SYSTEM_PROMPT
"""
model_instance = ModelManager().get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
prompt_messages: list[PromptMessage] = [
SystemPromptMessage(content=MEMORY_TEMPLATE_GENERATION_SYSTEM_PROMPT),
UserPromptMessage(content=instruction),
]
try:
response = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters={"temperature": 0.7},
stream=False,
)
generated_template = response.message.get_text_content()
return {"template": generated_template}
except Exception as e:
logger.exception("Failed to generate memory template")
return {"error": f"Failed to generate memory template: {str(e)}"}
@staticmethod
def generate_memory_instruction(
tenant_id: str,
instruction: str,
model_config: dict,
) -> dict:
"""
Generate Memory Instruction
Uses MEMORY_INSTRUCTION_GENERATION_SYSTEM_PROMPT
"""
model_instance = ModelManager().get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
prompt_messages: list[PromptMessage] = [
SystemPromptMessage(content=MEMORY_INSTRUCTION_GENERATION_SYSTEM_PROMPT),
UserPromptMessage(content=instruction),
]
try:
response = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters={"temperature": 0.7},
stream=False,
)
generated_instruction = response.message.get_text_content()
return {"instruction": generated_instruction}
except Exception as e:
logger.exception("Failed to generate memory instruction")
return {"error": f"Failed to generate memory instruction: {str(e)}"}
@staticmethod
def edit_memory_template(
tenant_id: str,
flow_id: str,
node_id: str | None,
current: str,
instruction: str,
model_config: dict,
ideal_output: str | None = None,
) -> dict:
"""
Edit Memory Template
Supports variable references: {{#history#}}, {{#system_prompt#}}
"""
# Use unified variable injector
variable_providers = {
"history": lambda: LLMGenerator.__get_history_json(flow_id, node_id, tenant_id),
"system_prompt": lambda: json.dumps(
LLMGenerator.__get_system_prompt(flow_id, node_id, tenant_id),
ensure_ascii=False
),
}
injected_instruction = LLMGenerator.__inject_variables(
instruction=instruction,
variable_providers=variable_providers
)
model_instance = ModelManager().get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
system_prompt = MEMORY_TEMPLATE_EDIT_SYSTEM_PROMPT
user_content = json.dumps({
"current_template": current,
"instruction": injected_instruction,
"ideal_output": ideal_output,
})
prompt_messages: list[PromptMessage] = [
SystemPromptMessage(content=system_prompt),
UserPromptMessage(content=user_content),
]
try:
response = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters={"temperature": 0.4},
stream=False,
)
generated_raw = response.message.get_text_content()
# Extract JSON
first_brace = generated_raw.find("{")
last_brace = generated_raw.rfind("}")
result = json.loads(generated_raw[first_brace : last_brace + 1])
return {
"modified": result.get("modified", ""),
"message": result.get("message", "Template updated successfully"),
}
except Exception as e:
logger.exception("Failed to edit memory template")
return {"error": f"Failed to edit memory template: {str(e)}"}
@staticmethod
def edit_memory_instruction(
tenant_id: str,
flow_id: str,
node_id: str | None,
current: str,
instruction: str,
model_config: dict,
ideal_output: str | None = None,
) -> dict:
"""
Edit Memory Instruction
Supports variable references: {{#history#}}, {{#system_prompt#}}
"""
# Use unified variable injector
variable_providers = {
"history": lambda: LLMGenerator.__get_history_json(flow_id, node_id, tenant_id),
"system_prompt": lambda: json.dumps(
LLMGenerator.__get_system_prompt(flow_id, node_id, tenant_id),
ensure_ascii=False
),
}
injected_instruction = LLMGenerator.__inject_variables(
instruction=instruction,
variable_providers=variable_providers
)
model_instance = ModelManager().get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=model_config.get("provider", ""),
model=model_config.get("name", ""),
)
system_prompt = MEMORY_INSTRUCTION_EDIT_SYSTEM_PROMPT
user_content = json.dumps({
"current_instruction": current,
"instruction": injected_instruction,
"ideal_output": ideal_output,
})
prompt_messages: list[PromptMessage] = [
SystemPromptMessage(content=system_prompt),
UserPromptMessage(content=user_content),
]
try:
response = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters={"temperature": 0.4},
stream=False,
)
generated_raw = response.message.get_text_content()
# Extract JSON
first_brace = generated_raw.find("{")
last_brace = generated_raw.rfind("}")
result = json.loads(generated_raw[first_brace : last_brace + 1])
return {
"modified": result.get("modified", ""),
"message": result.get("message", "Instruction updated successfully"),
}
except Exception as e:
logger.exception("Failed to edit memory instruction")
return {"error": f"Failed to edit memory instruction: {str(e)}"}
# ==================== Unified variable injector (private method) ====================
@staticmethod
def __inject_variables(
instruction: str,
variable_providers: Mapping[str, Callable[[], str]]
) -> str:
"""
Unified variable injector (private method)
Replaces variable placeholders {{#variable_name#}} in instruction with actual values
Args:
instruction: User's original instruction
variable_providers: Mapping of variable name -> getter function
Example: {"last_run": lambda: json.dumps(data), "history": lambda: get_history()}
Returns:
Instruction with injected variables
Features:
1. Lazy loading: Only calls getter function when placeholder is present
2. Fault tolerance: Failure of one variable doesn't affect others
3. Extensible: New variables can be added through variable_providers parameter
"""
injected = instruction
for var_name, provider_func in variable_providers.items():
placeholder = f"{{{{#{var_name}#}}}}"
if placeholder in injected:
try:
# Lazy loading: only call when needed
value = provider_func()
injected = injected.replace(placeholder, value)
except Exception as e:
logger.warning("Failed to inject variable '%s': %s", var_name, e)
# Use default value on failure, don't block the request
default_value = "[]" if var_name == "history" else '""'
injected = injected.replace(placeholder, default_value)
return injected
@staticmethod
def __get_history_json(
flow_id: str,
node_id: str | None,
tenant_id: str
) -> str:
"""
Get conversation history as JSON string (private method)
Args:
flow_id: Application ID
node_id: Node ID (optional, None indicates APP level)
tenant_id: Tenant ID
Returns:
JSON array string in format: [{"role": "user", "content": "..."}, ...]
Returns "[]" if no history exists
"""
from services.chatflow_history_service import ChatflowHistoryService
app = db.session.query(App).filter_by(id=flow_id).first()
if not app:
return "[]"
visible_messages = ChatflowHistoryService.get_latest_chat_history_for_app(
app_id=app.id,
tenant_id=tenant_id,
node_id=node_id or None
)
history_json = [
{"role": msg.role.value, "content": msg.content}
for msg in visible_messages
]
return json.dumps(history_json, ensure_ascii=False)
@staticmethod
def __get_system_prompt(
flow_id: str,
node_id: str | None,
tenant_id: str
) -> str:
"""
Get system prompt (private method)
Args:
flow_id: Application ID
node_id: Node ID (optional)
tenant_id: Tenant ID
Returns:
System prompt string, returns "" if none exists
"""
from services.workflow_service import WorkflowService
app = db.session.query(App).filter_by(id=flow_id).first()
if not app:
return ""
# Legacy app
if app.mode in {"chat", "completion"}:
try:
app_model_config = app.app_model_config_dict
return app_model_config.get("pre_prompt", "")
except Exception:
return ""
# Workflow app
try:
workflow = WorkflowService().get_draft_workflow(app_model=app)
if not workflow:
return ""
nodes = workflow.graph_dict.get("nodes", [])
if node_id:
# Get system prompt for specified node
node = next((n for n in nodes if n["id"] == node_id), None)
if not node or node["data"]["type"] not in ["llm", "agent"]:
return ""
prompt_template = node["data"].get("prompt_template")
return LLMGenerator.__extract_system_prompt_from_template(prompt_template)
else:
# APP level: find the main LLM node (connected to END node)
edges = workflow.graph_dict.get("edges", [])
llm_nodes = [n for n in nodes if n["data"]["type"] in ["llm", "agent"]]
for edge in edges:
if edge.get("target") == "end":
source_node = next((n for n in llm_nodes if n["id"] == edge.get("source")), None)
if source_node:
prompt_template = source_node["data"].get("prompt_template")
system_prompt = LLMGenerator.__extract_system_prompt_from_template(prompt_template)
if system_prompt:
return system_prompt
# Fallback: return system prompt from first LLM node
if llm_nodes:
prompt_template = llm_nodes[0]["data"].get("prompt_template")
return LLMGenerator.__extract_system_prompt_from_template(prompt_template)
return ""
except Exception as e:
logger.warning("Failed to get system prompt: %s", e)
return ""
@staticmethod
def __extract_system_prompt_from_template(prompt_template: Any) -> str:
"""
Extract system prompt from prompt_template (private method)
Args:
prompt_template: LLM node's prompt_template (may be list or dict)
Returns:
System prompt string
"""
if not prompt_template:
return ""
if isinstance(prompt_template, list):
# Chat model: [{"role": "system", "text": "..."}, ...]
system_msg = next((m for m in prompt_template if m.get("role") == "system"), None)
return system_msg.get("text", "") if system_msg else ""
elif isinstance(prompt_template, dict):
# Completion model: {"text": "..."}
return prompt_template.get("text", "")
else:
return ""
+109
View File
@@ -422,3 +422,112 @@ INSTRUCTION_GENERATE_TEMPLATE_PROMPT = """The output of this prompt is not as ex
You should edit the prompt according to the IDEAL OUTPUT."""
INSTRUCTION_GENERATE_TEMPLATE_CODE = """Please fix the errors in the {{#error_message#}}."""
MEMORY_UPDATE_PROMPT = """
Based on the following conversation history, update the memory content:
Conversation history:
{{formatted_history}}
Current memory:
{{current_value}}
Update instruction:
{{instruction}}
Please output only the updated memory content, no other text like greeting:
"""
MEMORY_TEMPLATE_GENERATION_SYSTEM_PROMPT = """
You are a helpful assistant designed to extract structured template information from a long-term conversation. Your task is to generate a concise and complete MemoryBlock template based on the underlying purpose of the conversation.
Each MemoryBlock represents a reusable schema that captures the key elements relevant to a specific task or goal (e.g., planning a trip, conducting a job interview, writing a blog post, etc.).
When generating a template:
1. Analyze the overall goal or purpose of the conversation described in the user's instruction.
2. Identify essential information categories that would be relevant to track.
3. Structure the template using Markdown format with clear sections and fields.
4. Do not fill in actual user data — only describe the structure and purpose of each field.
5. Be general enough to be reusable, but specific enough to serve the user's intent.
Respond with only the template in Markdown format, with no additional explanation.
Example format:
# [Template Name]
## Section 1
- **Field 1:** Description of what should be captured here
- **Field 2:** Description of what should be captured here
## Section 2
- **Field 3:** Description of what should be captured here
""" # noqa: E501
MEMORY_INSTRUCTION_GENERATION_SYSTEM_PROMPT = """
You are a prompt generation model.
Your task is to generate an instruction for a downstream language model tasked with extracting structured memory blocks (MemoryBlock) from long, multi-turn conversations between a user and an assistant.
The downstream model will receive:
- A template describing the structure and fields of the memory block it should extract.
- The historical conversation, serialized as plain text with message tags.
- Optional context including the assistant's system prompt.
You must generate a clear, specific, and instructional prompt that:
1. Explains what a MemoryBlock is and its purpose.
2. Instructs the model to extract only information relevant to the template fields.
3. Emphasizes handling implicit information and scattered mentions across multiple turns.
4. Instructs to ignore irrelevant or casual dialogue.
5. Describes the expected output format (structured object matching the template).
6. Uses placeholders {{#history#}} and {{#system_prompt#}} for runtime variable injection.
The tone should be concise, instructional, and focused on task precision.
Based on the user's description of the conversation context, generate the ideal extraction instruction.
""" # noqa: E501
MEMORY_TEMPLATE_EDIT_SYSTEM_PROMPT = """
You are an expert at refining memory templates for conversation tracking systems.
You will receive:
- current_template: The existing memory template
- instruction: User's instruction for how to modify the template (may include {{#history#}} and {{#system_prompt#}} references)
- ideal_output: Optional description of the desired result
Your task:
1. Analyze the current template structure
2. Apply the requested modifications based on the instruction
3. Ensure the modified template maintains proper Markdown structure
4. Keep field descriptions clear and actionable
Output format (JSON):
{
"modified": "<the updated template in Markdown format>",
"message": "<brief explanation of changes made>"
}
Only output the JSON, no additional text.
""" # noqa: E501
MEMORY_INSTRUCTION_EDIT_SYSTEM_PROMPT = """
You are an expert at refining extraction instructions for memory systems.
You will receive:
- current_instruction: The existing extraction instruction
- instruction: User's instruction for how to improve it (may include {{#history#}} and {{#system_prompt#}} references)
- ideal_output: Optional description of the desired result
Your task:
1. Analyze the current instruction's effectiveness
2. Apply the requested improvements based on the user's instruction
3. Ensure the modified instruction is clear, specific, and actionable
4. Maintain focus on structured extraction matching the template
Output format (JSON):
{
"modified": "<the improved instruction>",
"message": "<brief explanation of improvements>"
}
Only output the JSON, no additional text.
"""
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from enum import StrEnum
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, Field, field_validator
from core.app.app_config.entities import ModelConfig
class MemoryScope(StrEnum):
"""Memory scope determined by node_id field"""
APP = "app" # node_id is None
NODE = "node" # node_id is not None
class MemoryTerm(StrEnum):
"""Memory term determined by conversation_id field"""
SESSION = "session" # conversation_id is not None
PERSISTENT = "persistent" # conversation_id is None
class MemoryStrategy(StrEnum):
ON_TURNS = "on_turns"
class MemoryScheduleMode(StrEnum):
SYNC = "sync"
ASYNC = "async"
class MemoryBlockSpec(BaseModel):
"""Memory block specification for workflow configuration"""
id: str = Field(
default_factory=lambda: str(uuid4()),
description="Unique identifier for the memory block",
)
name: str = Field(description="Display name of the memory block")
description: str = Field(default="", description="Description of the memory block")
template: str = Field(description="Initial template content for the memory")
instruction: str = Field(description="Instructions for updating the memory")
scope: MemoryScope = Field(description="Scope of the memory (app or node level)")
term: MemoryTerm = Field(description="Term of the memory (session or persistent)")
strategy: MemoryStrategy = Field(description="Update strategy for the memory")
update_turns: int = Field(gt=0, description="Number of turns between updates")
preserved_turns: int = Field(gt=0, description="Number of conversation turns to preserve")
schedule_mode: MemoryScheduleMode = Field(description="Synchronous or asynchronous update mode")
model: ModelConfig = Field(description="Model configuration for memory updates")
end_user_visible: bool = Field(default=False, description="Whether memory is visible to end users")
end_user_editable: bool = Field(default=False, description="Whether memory is editable by end users")
node_id: str | None = Field(
default=None,
description="Node ID when scope is NODE. Must be None when scope is APP."
)
@field_validator('node_id')
@classmethod
def validate_node_id_with_scope(cls, v: str | None, info) -> str | None:
"""Validate node_id consistency with scope"""
scope = info.data.get('scope')
if scope == MemoryScope.NODE and v is None:
raise ValueError("node_id is required when scope is NODE")
if scope == MemoryScope.APP and v is not None:
raise ValueError("node_id must be None when scope is APP")
return v
class MemoryCreatedBy(BaseModel):
end_user_id: str | None = None
account_id: str | None = None
class MemoryBlock(BaseModel):
"""Runtime memory block instance
Design Rules:
- app_id = None: Global memory (future feature, not implemented yet)
- app_id = str: App-specific memory
- conversation_id = None: Persistent memory (cross-conversation)
- conversation_id = str: Session memory (conversation-specific)
- node_id = None: App-level scope
- node_id = str: Node-level scope
These rules implicitly determine scope and term without redundant storage.
"""
spec: MemoryBlockSpec
tenant_id: str
value: str
app_id: str
conversation_id: Optional[str] = None
node_id: Optional[str] = None
edited_by_user: bool = False
created_by: MemoryCreatedBy
version: int = Field(description="Memory block version number")
class MemoryValueData(BaseModel):
value: str
edited_by_user: bool = False
class ChatflowConversationMetadata(BaseModel):
"""Metadata for chatflow conversation with visible message count"""
type: str = "mutable_visible_window"
visible_count: int = Field(gt=0, description="Number of visible messages to keep")
class MemoryBlockWithConversation(MemoryBlock):
"""MemoryBlock with optional conversation metadata for session memories"""
conversation_metadata: ChatflowConversationMetadata = Field(
description="Conversation metadata, only present for session memories"
)
@classmethod
def from_memory_block(
cls,
memory_block: MemoryBlock,
conversation_metadata: ChatflowConversationMetadata
) -> MemoryBlockWithConversation:
"""Create MemoryBlockWithConversation from MemoryBlock"""
return cls(
spec=memory_block.spec,
tenant_id=memory_block.tenant_id,
value=memory_block.value,
app_id=memory_block.app_id,
conversation_id=memory_block.conversation_id,
node_id=memory_block.node_id,
edited_by_user=memory_block.edited_by_user,
created_by=memory_block.created_by,
version=memory_block.version,
conversation_metadata=conversation_metadata
)
+6
View File
@@ -0,0 +1,6 @@
class MemorySyncTimeoutError(Exception):
def __init__(self, app_id: str, conversation_id: str):
self.app_id = app_id
self.conversation_id = conversation_id
self.message = "Memory synchronization timeout after 50 seconds"
super().__init__(self.message)
@@ -44,7 +44,7 @@ class MemoryConfig(BaseModel):
enabled: bool
size: int | None = None
mode: Literal["linear", "block"] | None = "linear"
role_prefix: RolePrefix | None = None
window: WindowConfig
query_prompt_template: str | None = None
@@ -1,110 +1,20 @@
import re
from operator import itemgetter
from typing import cast
class JiebaKeywordTableHandler:
def __init__(self):
from core.rag.datasource.keyword.jieba.stopwords import STOPWORDS
tfidf = self._load_tfidf_extractor()
tfidf.stop_words = STOPWORDS # type: ignore[attr-defined]
self._tfidf = tfidf
def _load_tfidf_extractor(self):
"""
Load jieba TFIDF extractor with fallback strategy.
Loading Flow:
┌─────────────────────────────────────────────────────────────────────┐
│ jieba.analyse.default_tfidf │
│ exists? │
└─────────────────────────────────────────────────────────────────────┘
│ │
YES NO
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────────────────┐
│ Return default │ │ jieba.analyse.TFIDF exists? │
│ TFIDF │ └──────────────────────────────────┘
└──────────────────┘ │ │
YES NO
│ │
│ ▼
│ ┌────────────────────────────┐
│ │ Try import from │
│ │ jieba.analyse.tfidf.TFIDF │
│ └────────────────────────────┘
│ │ │
│ SUCCESS FAILED
│ │ │
▼ ▼ ▼
┌────────────────────────┐ ┌─────────────────┐
│ Instantiate TFIDF() │ │ Build fallback │
│ & cache to default │ │ _SimpleTFIDF │
└────────────────────────┘ └─────────────────┘
"""
import jieba.analyse # type: ignore
tfidf = getattr(jieba.analyse, "default_tfidf", None)
if tfidf is not None:
return tfidf
tfidf_class = getattr(jieba.analyse, "TFIDF", None)
if tfidf_class is None:
try:
from jieba.analyse.tfidf import TFIDF # type: ignore
tfidf_class = TFIDF
except Exception:
tfidf_class = None
if tfidf_class is not None:
tfidf = tfidf_class()
jieba.analyse.default_tfidf = tfidf # type: ignore[attr-defined]
return tfidf
return self._build_fallback_tfidf()
@staticmethod
def _build_fallback_tfidf():
"""Fallback lightweight TFIDF for environments missing jieba's TFIDF."""
import jieba # type: ignore
from core.rag.datasource.keyword.jieba.stopwords import STOPWORDS
class _SimpleTFIDF:
def __init__(self):
self.stop_words = STOPWORDS
self._lcut = getattr(jieba, "lcut", None)
def extract_tags(self, sentence: str, top_k: int | None = 20, **kwargs):
# Basic frequency-based keyword extraction as a fallback when TF-IDF is unavailable.
top_k = kwargs.pop("topK", top_k)
cut = getattr(jieba, "cut", None)
if self._lcut:
tokens = self._lcut(sentence)
elif callable(cut):
tokens = list(cut(sentence))
else:
tokens = re.findall(r"\w+", sentence)
words = [w for w in tokens if w and w not in self.stop_words]
freq: dict[str, int] = {}
for w in words:
freq[w] = freq.get(w, 0) + 1
sorted_words = sorted(freq.items(), key=itemgetter(1), reverse=True)
if top_k is not None:
sorted_words = sorted_words[:top_k]
return [item[0] for item in sorted_words]
return _SimpleTFIDF()
jieba.analyse.default_tfidf.stop_words = STOPWORDS # type: ignore
def extract_keywords(self, text: str, max_keywords_per_chunk: int | None = 10) -> set[str]:
"""Extract keywords with JIEBA tfidf."""
keywords = self._tfidf.extract_tags(
import jieba.analyse # type: ignore
keywords = jieba.analyse.extract_tags(
sentence=text,
topK=max_keywords_per_chunk,
)
@@ -302,7 +302,8 @@ class OracleVector(BaseVector):
nltk.data.find("tokenizers/punkt")
nltk.data.find("corpora/stopwords")
except LookupError:
raise LookupError("Unable to find the required NLTK data package: punkt and stopwords")
nltk.download("punkt")
nltk.download("stopwords")
e_str = re.sub(r"[^\w ]", "", query)
all_tokens = nltk.word_tokenize(e_str)
stop_words = stopwords.words("english")
@@ -167,18 +167,13 @@ class WeaviateVector(BaseVector):
try:
if not self._client.collections.exists(self._collection_name):
tokenization = (
wc.Tokenization(dify_config.WEAVIATE_TOKENIZATION)
if dify_config.WEAVIATE_TOKENIZATION
else wc.Tokenization.WORD
)
self._client.collections.create(
name=self._collection_name,
properties=[
wc.Property(
name=Field.TEXT_KEY.value,
data_type=wc.DataType.TEXT,
tokenization=tokenization,
tokenization=wc.Tokenization.WORD,
),
wc.Property(name="document_id", data_type=wc.DataType.TEXT),
wc.Property(name="doc_id", data_type=wc.DataType.TEXT),
+1 -2
View File
@@ -1,6 +1,5 @@
import base64
import logging
import pickle
from typing import Any, cast
import numpy as np
@@ -90,8 +89,8 @@ class CacheEmbedding(Embeddings):
model_name=self._model_instance.model,
hash=hash,
provider_name=self._model_instance.provider,
embedding=pickle.dumps(n_embedding, protocol=pickle.HIGHEST_PROTOCOL),
)
embedding_cache.set_embedding(n_embedding)
db.session.add(embedding_cache)
cache_embeddings.append(hash)
db.session.commit()
@@ -141,7 +141,6 @@ class WorkflowToolProviderController(ToolProviderController):
form=parameter.form,
llm_description=parameter.description,
required=variable.required,
default=variable.default,
options=options,
placeholder=I18nObject(en_US="", zh_Hans=""),
)
-5
View File
@@ -71,11 +71,6 @@ class TriggerProviderIdentity(BaseModel):
icon_dark: str | None = Field(default=None, description="The dark icon of the trigger provider")
tags: list[str] = Field(default_factory=list, description="The tags of the trigger provider")
@field_validator("tags", mode="before")
@classmethod
def validate_tags(cls, v: list[str] | None) -> list[str]:
return v or []
class EventIdentity(BaseModel):
"""
+43
View File
@@ -203,6 +203,48 @@ class ArrayFileSegment(ArraySegment):
return ""
class VersionedMemoryValue(BaseModel):
current_value: str = None # type: ignore
versions: Mapping[str, str] = {}
model_config = ConfigDict(frozen=True)
def add_version(
self,
new_value: str,
version_name: str | None = None
) -> "VersionedMemoryValue":
if version_name is None:
version_name = str(len(self.versions) + 1)
if version_name in self.versions:
raise ValueError(f"Version '{version_name}' already exists.")
self.current_value = new_value
return VersionedMemoryValue(
current_value=new_value,
versions={
version_name: new_value,
**self.versions,
}
)
class VersionedMemorySegment(Segment):
value_type: SegmentType = SegmentType.VERSIONED_MEMORY
value: VersionedMemoryValue = None # type: ignore
@property
def text(self) -> str:
return self.value.current_value
@property
def log(self) -> str:
return self.value.current_value
@property
def markdown(self) -> str:
return self.value.current_value
class ArrayBooleanSegment(ArraySegment):
value_type: SegmentType = SegmentType.ARRAY_BOOLEAN
value: Sequence[bool]
@@ -248,6 +290,7 @@ SegmentUnion: TypeAlias = Annotated[
| Annotated[ArrayObjectSegment, Tag(SegmentType.ARRAY_OBJECT)]
| Annotated[ArrayFileSegment, Tag(SegmentType.ARRAY_FILE)]
| Annotated[ArrayBooleanSegment, Tag(SegmentType.ARRAY_BOOLEAN)]
| Annotated[VersionedMemorySegment, Tag(SegmentType.VERSIONED_MEMORY)]
),
Discriminator(get_segment_discriminator),
]
+2
View File
@@ -44,6 +44,8 @@ class SegmentType(StrEnum):
ARRAY_FILE = "array[file]"
ARRAY_BOOLEAN = "array[boolean]"
VERSIONED_MEMORY = "versioned_memory"
NONE = "none"
GROUP = "group"
+6
View File
@@ -22,6 +22,7 @@ from .segments import (
ObjectSegment,
Segment,
StringSegment,
VersionedMemorySegment,
get_segment_discriminator,
)
from .types import SegmentType
@@ -106,6 +107,10 @@ class ArrayFileVariable(ArrayFileSegment, ArrayVariable):
pass
class VersionedMemoryVariable(VersionedMemorySegment, Variable):
pass
class ArrayBooleanVariable(ArrayBooleanSegment, ArrayVariable):
pass
@@ -161,6 +166,7 @@ VariableUnion: TypeAlias = Annotated[
| Annotated[ArrayFileVariable, Tag(SegmentType.ARRAY_FILE)]
| Annotated[ArrayBooleanVariable, Tag(SegmentType.ARRAY_BOOLEAN)]
| Annotated[SecretVariable, Tag(SegmentType.SECRET)]
| Annotated[VersionedMemoryVariable, Tag(SegmentType.VERSIONED_MEMORY)]
),
Discriminator(get_segment_discriminator),
]
+1
View File
@@ -1,4 +1,5 @@
SYSTEM_VARIABLE_NODE_ID = "sys"
ENVIRONMENT_VARIABLE_NODE_ID = "env"
CONVERSATION_VARIABLE_NODE_ID = "conversation"
MEMORY_BLOCK_VARIABLE_NODE_ID = "memory_block"
RAG_PIPELINE_VARIABLE_NODE_ID = "rag"
@@ -237,7 +237,8 @@ class IterationNode(LLMUsageTrackingMixin, Node):
)
)
# Accumulate usage from this iteration
# Update the total tokens from this iteration
self.graph_runtime_state.total_tokens += graph_engine.graph_runtime_state.total_tokens
usage_accumulator[0] = self._merge_usage(
usage_accumulator[0], graph_engine.graph_runtime_state.llm_usage
)
@@ -264,6 +265,7 @@ class IterationNode(LLMUsageTrackingMixin, Node):
datetime,
list[GraphNodeEventBase],
object | None,
int,
dict[str, VariableUnion],
LLMUsage,
]
@@ -290,6 +292,7 @@ class IterationNode(LLMUsageTrackingMixin, Node):
iter_start_at,
events,
output_value,
tokens_used,
conversation_snapshot,
iteration_usage,
) = result
@@ -301,6 +304,7 @@ class IterationNode(LLMUsageTrackingMixin, Node):
yield from events
# Update tokens and timing
self.graph_runtime_state.total_tokens += tokens_used
iter_run_map[str(index)] = (datetime.now(UTC).replace(tzinfo=None) - iter_start_at).total_seconds()
usage_accumulator[0] = self._merge_usage(usage_accumulator[0], iteration_usage)
@@ -332,7 +336,7 @@ class IterationNode(LLMUsageTrackingMixin, Node):
item: object,
flask_app: Flask,
context_vars: contextvars.Context,
) -> tuple[datetime, list[GraphNodeEventBase], object | None, dict[str, VariableUnion], LLMUsage]:
) -> tuple[datetime, list[GraphNodeEventBase], object | None, int, dict[str, VariableUnion], LLMUsage]:
"""Execute a single iteration in parallel mode and return results."""
with preserve_flask_contexts(flask_app=flask_app, context_vars=context_vars):
iter_start_at = datetime.now(UTC).replace(tzinfo=None)
@@ -359,6 +363,7 @@ class IterationNode(LLMUsageTrackingMixin, Node):
iter_start_at,
events,
output_value,
graph_engine.graph_runtime_state.total_tokens,
conversation_snapshot,
graph_engine.graph_runtime_state.llm_usage,
)
@@ -229,8 +229,6 @@ def _get_file_extract_string_func(*, key: str) -> Callable[[File], str]:
return lambda x: x.transfer_method
case "url":
return lambda x: x.remote_url or ""
case "related_id":
return lambda x: x.related_id or ""
case _:
raise InvalidKeyError(f"Invalid key: {key}")
@@ -301,7 +299,7 @@ def _get_boolean_filter_func(*, condition: FilterOperator, value: bool) -> Calla
def _get_file_filter_func(*, key: str, condition: str, value: str | Sequence[str]) -> Callable[[File], bool]:
extract_func: Callable[[File], Any]
if key in {"name", "extension", "mime_type", "url", "related_id"} and isinstance(value, str):
if key in {"name", "extension", "mime_type", "url"} and isinstance(value, str):
extract_func = _get_file_extract_string_func(key=key)
return lambda x: _get_string_filter_func(condition=condition, value=value)(extract_func(x))
if key in {"type", "transfer_method"}:
@@ -360,7 +358,7 @@ def _ge(value: int | float) -> Callable[[int | float], bool]:
def _order_file(*, order: Order, order_by: str = "", array: Sequence[File]):
extract_func: Callable[[File], Any]
if order_by in {"name", "type", "extension", "mime_type", "transfer_method", "url", "related_id"}:
if order_by in {"name", "type", "extension", "mime_type", "transfer_method", "url"}:
extract_func = _get_file_extract_string_func(key=order_by)
return sorted(array, key=lambda x: extract_func(x), reverse=order == Order.DESC)
elif order_by == "size":
+85 -1
View File
@@ -7,11 +7,15 @@ import time
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal
from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.app.entities.app_invoke_entities import InvokeFrom, 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
from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
from core.memory.entities import MemoryCreatedBy, MemoryScope
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance, ModelManager
from core.model_runtime.entities import (
@@ -73,6 +77,8 @@ from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig, Variabl
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.base.variable_template_parser import VariableTemplateParser
from core.workflow.runtime import VariablePool
from models import UserFrom, Workflow
from models.engine import db
from . import llm_utils
from .entities import (
@@ -317,6 +323,11 @@ class LLMNode(Node):
if self._file_outputs:
outputs["files"] = ArrayFileSegment(value=self._file_outputs)
try:
self._handle_chatflow_memory(result_text, variable_pool)
except Exception as e:
logger.warning("Memory orchestration failed for node %s: %s", self.node_id, str(e))
# Send final chunk event to indicate streaming is complete
yield StreamChunkEvent(
selector=[self._node_id, "text"],
@@ -1228,6 +1239,79 @@ class LLMNode(Node):
def retry(self) -> bool:
return self._node_data.retry_config.retry_enabled
def _handle_chatflow_memory(self, llm_output: str, variable_pool: VariablePool):
if not self._node_data.memory or self._node_data.memory.mode != "block":
return
conversation_id_segment = variable_pool.get((SYSTEM_VARIABLE_NODE_ID, SystemVariableKey.CONVERSATION_ID))
if not conversation_id_segment:
raise ValueError("Conversation ID not found in variable pool.")
conversation_id = conversation_id_segment.text
user_query_segment = variable_pool.get((SYSTEM_VARIABLE_NODE_ID, SystemVariableKey.QUERY))
if not user_query_segment:
raise ValueError("User query not found in variable pool.")
user_query = user_query_segment.text
from core.model_runtime.entities.message_entities import AssistantPromptMessage, UserPromptMessage
from services.chatflow_history_service import ChatflowHistoryService
ChatflowHistoryService.save_node_message(
prompt_message=(UserPromptMessage(content=user_query)),
node_id=self.node_id,
conversation_id=conversation_id,
app_id=self.app_id,
tenant_id=self.tenant_id
)
ChatflowHistoryService.save_node_message(
prompt_message=(AssistantPromptMessage(content=llm_output)),
node_id=self.node_id,
conversation_id=conversation_id,
app_id=self.app_id,
tenant_id=self.tenant_id
)
# FIXME: This is dirty workaround and may cause incorrect resolution for workflow version
with Session(db.engine) as session:
stmt = select(Workflow).where(
Workflow.tenant_id == self.tenant_id,
Workflow.app_id == self.app_id
)
workflow = session.scalars(stmt).first()
if not workflow:
raise ValueError("Workflow not found.")
# Filter memory blocks that belong to this node
node_memory_blocks = [
block for block in workflow.memory_blocks
if block.scope == MemoryScope.NODE and block.node_id == self.id
]
if not node_memory_blocks:
return
# Update each memory block that belongs to this node
is_draft = (self.invoke_from == InvokeFrom.DEBUGGER)
from services.chatflow_memory_service import ChatflowMemoryService
for memory_block_spec in node_memory_blocks:
ChatflowMemoryService.update_node_memory_if_needed(
tenant_id=self.tenant_id,
app_id=self.app_id,
node_id=self.id,
conversation_id=conversation_id,
memory_block_spec=memory_block_spec,
variable_pool=variable_pool,
is_draft=is_draft,
created_by=self._get_user_from_context()
)
def _get_user_from_context(self) -> MemoryCreatedBy:
if self.user_from == UserFrom.ACCOUNT:
return MemoryCreatedBy(account_id=self.user_id)
else:
return MemoryCreatedBy(end_user_id=self.user_id)
def _combine_message_content_with_role(
*, contents: str | list[PromptMessageContentUnionTypes] | None = None, role: PromptMessageRole
@@ -140,6 +140,7 @@ class LoopNode(LLMUsageTrackingMixin, Node):
if reach_break_condition:
loop_count = 0
cost_tokens = 0
for i in range(loop_count):
graph_engine = self._create_graph_engine(start_at=start_at, root_node_id=root_node_id)
@@ -162,6 +163,9 @@ class LoopNode(LLMUsageTrackingMixin, Node):
# For other outputs, just update
self.graph_runtime_state.set_output(key, value)
# Update the total tokens from this iteration
cost_tokens += graph_engine.graph_runtime_state.total_tokens
# Accumulate usage from the sub-graph execution
loop_usage = self._merge_usage(loop_usage, graph_engine.graph_runtime_state.llm_usage)
@@ -190,6 +194,7 @@ class LoopNode(LLMUsageTrackingMixin, Node):
pre_loop_output=self._node_data.outputs,
)
self.graph_runtime_state.total_tokens += cost_tokens
self._accumulate_usage(loop_usage)
# Loop completed successfully
yield LoopSucceededEvent(
+1 -9
View File
@@ -329,15 +329,7 @@ class ToolNode(Node):
json.append(message.message.json_object)
elif message.type == ToolInvokeMessage.MessageType.LINK:
assert isinstance(message.message, ToolInvokeMessage.TextMessage)
# Check if this LINK message is a file link
file_obj = (message.meta or {}).get("file")
if isinstance(file_obj, File):
files.append(file_obj)
stream_text = f"File: {message.message.text}\n"
else:
stream_text = f"Link: {message.message.text}\n"
stream_text = f"Link: {message.message.text}\n"
text += stream_text
yield StreamChunkEvent(
selector=[node_id, "text"],
+19 -2
View File
@@ -9,11 +9,12 @@ from pydantic import BaseModel, Field
from core.file import File, FileAttribute, file_manager
from core.variables import Segment, SegmentGroup, Variable
from core.variables.consts import SELECTORS_LENGTH
from core.variables.segments import FileSegment, ObjectSegment
from core.variables.variables import RAGPipelineVariableInput, VariableUnion
from core.variables.segments import FileSegment, ObjectSegment, VersionedMemoryValue
from core.variables.variables import RAGPipelineVariableInput, VariableUnion, VersionedMemoryVariable
from core.workflow.constants import (
CONVERSATION_VARIABLE_NODE_ID,
ENVIRONMENT_VARIABLE_NODE_ID,
MEMORY_BLOCK_VARIABLE_NODE_ID,
RAG_PIPELINE_VARIABLE_NODE_ID,
SYSTEM_VARIABLE_NODE_ID,
)
@@ -56,6 +57,10 @@ class VariablePool(BaseModel):
description="RAG pipeline variables.",
default_factory=list,
)
memory_blocks: Mapping[str, str] = Field(
description="Memory blocks.",
default_factory=dict,
)
def model_post_init(self, context: Any, /):
# Create a mapping from field names to SystemVariableKey enum values
@@ -76,6 +81,18 @@ class VariablePool(BaseModel):
rag_pipeline_variables_map[node_id][key] = value
for key, value in rag_pipeline_variables_map.items():
self.add((RAG_PIPELINE_VARIABLE_NODE_ID, key), value)
# Add memory blocks to the variable pool
for memory_id, memory_value in self.memory_blocks.items():
self.add(
[MEMORY_BLOCK_VARIABLE_NODE_ID, memory_id],
VersionedMemoryVariable(
value=VersionedMemoryValue(
current_value=memory_value,
versions={"1": memory_value},
),
name=memory_id,
)
)
def add(self, selector: Sequence[str], value: Any, /):
"""
+1 -1
View File
@@ -112,7 +112,7 @@ class Storage:
def exists(self, filename):
return self.storage_runner.exists(filename)
def delete(self, filename: str):
def delete(self, filename):
return self.storage_runner.delete(filename)
def scan(self, path: str, files: bool = True, directories: bool = False) -> list[str]:
+11
View File
@@ -21,6 +21,8 @@ from core.variables.segments import (
ObjectSegment,
Segment,
StringSegment,
VersionedMemorySegment,
VersionedMemoryValue,
)
from core.variables.types import SegmentType
from core.variables.variables import (
@@ -39,6 +41,7 @@ from core.variables.variables import (
SecretVariable,
StringVariable,
Variable,
VersionedMemoryVariable,
)
from core.workflow.constants import (
CONVERSATION_VARIABLE_NODE_ID,
@@ -69,6 +72,7 @@ SEGMENT_TO_VARIABLE_MAP = {
NoneSegment: NoneVariable,
ObjectSegment: ObjectVariable,
StringSegment: StringVariable,
VersionedMemorySegment: VersionedMemoryVariable
}
@@ -193,6 +197,7 @@ _segment_factory: Mapping[SegmentType, type[Segment]] = {
SegmentType.FILE: FileSegment,
SegmentType.BOOLEAN: BooleanSegment,
SegmentType.OBJECT: ObjectSegment,
SegmentType.VERSIONED_MEMORY: VersionedMemorySegment,
# Array types
SegmentType.ARRAY_ANY: ArrayAnySegment,
SegmentType.ARRAY_STRING: ArrayStringSegment,
@@ -259,6 +264,12 @@ def build_segment_with_type(segment_type: SegmentType, value: Any) -> Segment:
else:
raise TypeMismatchError(f"Type mismatch: expected {segment_type}, but got empty list")
if segment_type == SegmentType.VERSIONED_MEMORY:
return VersionedMemorySegment(
value_type=segment_type,
value=VersionedMemoryValue.model_validate(value)
)
inferred_type = SegmentType.infer_segment_type(value)
# Type compatibility checking
if inferred_type is None:
+26
View File
@@ -49,6 +49,31 @@ conversation_variable_fields = {
"description": fields.String,
}
model_config_fields = {
"provider": fields.String,
"name": fields.String,
"mode": fields.String,
"completion_params": fields.Raw,
}
memory_block_fields = {
"id": fields.String,
"name": fields.String,
"description": fields.String,
"template": fields.String,
"instruction": fields.String,
"scope": fields.String,
"term": fields.String,
"strategy": fields.String,
"update_turns": fields.Integer,
"preserved_turns": fields.Integer,
"schedule_mode": fields.String,
"model": fields.Nested(model_config_fields),
"end_user_visible": fields.Boolean,
"end_user_editable": fields.Boolean,
"node_id": fields.String,
}
pipeline_variable_fields = {
"label": fields.String,
"variable": fields.String,
@@ -81,6 +106,7 @@ workflow_fields = {
"tool_published": fields.Boolean,
"environment_variables": fields.List(EnvironmentVariableField()),
"conversation_variables": fields.List(fields.Nested(conversation_variable_fields)),
"memory_blocks": fields.Nested(memory_block_fields),
"rag_pipeline_variables": fields.List(fields.Nested(pipeline_variable_fields)),
}
+4
View File
@@ -9,6 +9,7 @@ from .account import (
TenantStatus,
)
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
from .chatflow_memory import ChatflowConversation, ChatflowMemoryVariable, ChatflowMessage
from .dataset import (
AppDatasetJoin,
Dataset,
@@ -129,6 +130,9 @@ __all__ = [
"BuiltinToolProvider",
"CeleryTask",
"CeleryTaskSet",
"ChatflowConversation",
"ChatflowMemoryVariable",
"ChatflowMessage",
"Conversation",
"ConversationVariable",
"CreatorUserRole",
+76
View File
@@ -0,0 +1,76 @@
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .types import StringUUID
class ChatflowMemoryVariable(Base):
__tablename__ = "chatflow_memory_variables"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="chatflow_memory_variables_pkey"),
sa.Index("chatflow_memory_variables_memory_id_idx", "tenant_id", "app_id", "node_id", "memory_id"),
)
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, server_default=sa.text("uuid_generate_v4()"))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
node_id: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
memory_id: Mapped[str] = mapped_column(sa.Text, nullable=False)
value: Mapped[str] = mapped_column(sa.Text, nullable=False)
name: Mapped[str] = mapped_column(sa.Text, nullable=False)
scope: Mapped[str] = mapped_column(sa.String(10), nullable=False) # 'app' or 'node'
term: Mapped[str] = mapped_column(sa.String(20), nullable=False) # 'session' or 'persistent'
version: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=1)
created_by_role: Mapped[str] = mapped_column(sa.String(20)) # 'end_user' or 'account`
created_by: Mapped[str] = mapped_column(StringUUID)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
)
class ChatflowConversation(Base):
__tablename__ = "chatflow_conversations"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="chatflow_conversations_pkey"),
sa.Index(
"chatflow_conversations_original_conversation_id_idx",
"tenant_id", "app_id", "node_id", "original_conversation_id"
),
)
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, server_default=sa.text("uuid_generate_v4()"))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
node_id: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
original_conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
conversation_metadata: Mapped[str] = mapped_column(sa.Text, nullable=False) # JSON
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
)
class ChatflowMessage(Base):
__tablename__ = "chatflow_messages"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="chatflow_messages_pkey"),
sa.Index("chatflow_messages_version_idx", "conversation_id", "index", "version"),
)
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, server_default=sa.text("uuid_generate_v4()"))
conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
index: Mapped[int] = mapped_column(sa.Integer, nullable=False) # This index starts from 0
version: Mapped[int] = mapped_column(sa.Integer, nullable=False)
data: Mapped[str] = mapped_column(sa.Text, nullable=False) # Serialized PromptMessage JSON
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
)
+36 -56
View File
@@ -307,7 +307,7 @@ class Dataset(Base):
return f"{dify_config.VECTOR_INDEX_NAME_PREFIX}_{normalized_dataset_id}_Node"
class DatasetProcessRule(Base): # bug
class DatasetProcessRule(Base):
__tablename__ = "dataset_process_rules"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="dataset_process_rule_pkey"),
@@ -1004,7 +1004,7 @@ class DatasetKeywordTable(TypeBase):
return None
class Embedding(TypeBase):
class Embedding(Base):
__tablename__ = "embeddings"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="embedding_pkey"),
@@ -1012,16 +1012,12 @@ class Embedding(TypeBase):
sa.Index("created_at_idx", "created_at"),
)
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuid4()), init=False)
model_name: Mapped[str] = mapped_column(
String(255), nullable=False, server_default=sa.text("'text-embedding-ada-002'")
)
hash: Mapped[str] = mapped_column(String(64), nullable=False)
embedding: Mapped[bytes] = mapped_column(BinaryData, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
provider_name: Mapped[str] = mapped_column(String(255), nullable=False, server_default=sa.text("''"))
id = mapped_column(StringUUID, primary_key=True, default=lambda: str(uuid4()))
model_name = mapped_column(String(255), nullable=False, server_default=sa.text("'text-embedding-ada-002'"))
hash = mapped_column(String(64), nullable=False)
embedding = mapped_column(BinaryData, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
provider_name = mapped_column(String(255), nullable=False, server_default=sa.text("''"))
def set_embedding(self, embedding_data: list[float]):
self.embedding = pickle.dumps(embedding_data, protocol=pickle.HIGHEST_PROTOCOL)
@@ -1218,7 +1214,7 @@ class RateLimitLog(TypeBase):
)
class DatasetMetadata(TypeBase):
class DatasetMetadata(Base):
__tablename__ = "dataset_metadatas"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="dataset_metadata_pkey"),
@@ -1226,26 +1222,20 @@ class DatasetMetadata(TypeBase):
sa.Index("dataset_metadata_dataset_idx", "dataset_id"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()), init=False)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
id = mapped_column(StringUUID, default=lambda: str(uuid4()))
tenant_id = mapped_column(StringUUID, nullable=False)
dataset_id = mapped_column(StringUUID, nullable=False)
type: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=sa.func.current_timestamp(), init=False
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=sa.func.current_timestamp())
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=sa.func.current_timestamp(),
onupdate=func.current_timestamp(),
init=False,
DateTime, nullable=False, server_default=sa.func.current_timestamp(), onupdate=func.current_timestamp()
)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
updated_by: Mapped[str] = mapped_column(StringUUID, nullable=True, default=None)
created_by = mapped_column(StringUUID, nullable=False)
updated_by = mapped_column(StringUUID, nullable=True)
class DatasetMetadataBinding(TypeBase):
class DatasetMetadataBinding(Base):
__tablename__ = "dataset_metadata_bindings"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="dataset_metadata_binding_pkey"),
@@ -1255,15 +1245,13 @@ class DatasetMetadataBinding(TypeBase):
sa.Index("dataset_metadata_binding_document_idx", "document_id"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()), init=False)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
metadata_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
document_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
id = mapped_column(StringUUID, default=lambda: str(uuid4()))
tenant_id = mapped_column(StringUUID, nullable=False)
dataset_id = mapped_column(StringUUID, nullable=False)
metadata_id = mapped_column(StringUUID, nullable=False)
document_id = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
created_by = mapped_column(StringUUID, nullable=False)
class PipelineBuiltInTemplate(TypeBase):
@@ -1331,30 +1319,22 @@ class PipelineCustomizedTemplate(TypeBase):
return ""
class Pipeline(TypeBase):
class Pipeline(Base): # type: ignore[name-defined]
__tablename__ = "pipelines"
__table_args__ = (sa.PrimaryKeyConstraint("id", name="pipeline_pkey"),)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7()), init=False)
id = mapped_column(StringUUID, default=lambda: str(uuidv7()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
name: Mapped[str] = mapped_column(sa.String(255), nullable=False)
description: Mapped[str] = mapped_column(LongText, nullable=False, default=sa.text("''"))
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
is_public: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False)
is_published: Mapped[bool] = mapped_column(
sa.Boolean, nullable=False, server_default=sa.text("false"), default=False
)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime,
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
init=False,
name = mapped_column(sa.String(255), nullable=False)
description = mapped_column(LongText, nullable=False, default=sa.text("''"))
workflow_id = mapped_column(StringUUID, nullable=True)
is_public = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
is_published = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
created_by = mapped_column(StringUUID, nullable=True)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
updated_by = mapped_column(StringUUID, nullable=True)
updated_at = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
)
def retrieve_dataset(self, session: Session):
+1
View File
@@ -28,6 +28,7 @@ class DraftVariableType(StrEnum):
NODE = "node"
SYS = "sys"
CONVERSATION = "conversation"
MEMORY_BLOCK = "memory_block"
class MessageStatus(StrEnum):
+37 -53
View File
@@ -533,7 +533,7 @@ class AppModelConfig(Base):
return self
class RecommendedApp(Base): # bug
class RecommendedApp(Base):
__tablename__ = "recommended_apps"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="recommended_app_pkey"),
@@ -1294,7 +1294,7 @@ class Message(Base):
)
class MessageFeedback(TypeBase):
class MessageFeedback(Base):
__tablename__ = "message_feedbacks"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="message_feedback_pkey"),
@@ -1303,24 +1303,18 @@ class MessageFeedback(TypeBase):
sa.Index("message_feedback_conversation_idx", "conversation_id", "from_source", "rating"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()), init=False)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
message_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
rating: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str | None] = mapped_column(LongText)
from_source: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
from_end_user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
from_account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
from_end_user_id: Mapped[str | None] = mapped_column(StringUUID)
from_account_id: Mapped[str | None] = mapped_column(StringUUID)
created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime,
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
init=False,
sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
)
@property
@@ -1473,28 +1467,22 @@ class AppAnnotationSetting(TypeBase):
return collection_binding_detail
class OperationLog(TypeBase):
class OperationLog(Base):
__tablename__ = "operation_logs"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="operation_log_pkey"),
sa.Index("operation_log_account_action_idx", "tenant_id", "account_id", "action"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()), init=False)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
id = mapped_column(StringUUID, default=lambda: str(uuid4()))
tenant_id = mapped_column(StringUUID, nullable=False)
account_id = mapped_column(StringUUID, nullable=False)
action: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[Any] = mapped_column(sa.JSON)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
content = mapped_column(sa.JSON)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
created_ip: Mapped[str] = mapped_column(String(255), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime,
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
init=False,
updated_at = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
)
@@ -1639,7 +1627,7 @@ class Site(Base):
return dify_config.APP_WEB_URL or request.url_root.rstrip("/")
class ApiToken(Base): # bug: this uses setattr so idk the field.
class ApiToken(Base):
__tablename__ = "api_tokens"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="api_token_pkey"),
@@ -1899,36 +1887,34 @@ class MessageAgentThought(Base):
return {}
class DatasetRetrieverResource(TypeBase):
class DatasetRetrieverResource(Base):
__tablename__ = "dataset_retriever_resources"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="dataset_retriever_resource_pkey"),
sa.Index("dataset_retriever_resource_message_id_idx", "message_id"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()), init=False)
message_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
id = mapped_column(StringUUID, default=lambda: str(uuid4()))
message_id = mapped_column(StringUUID, nullable=False)
position: Mapped[int] = mapped_column(sa.Integer, nullable=False)
dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
dataset_name: Mapped[str] = mapped_column(LongText, nullable=False)
document_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
document_name: Mapped[str] = mapped_column(LongText, nullable=False)
data_source_type: Mapped[str | None] = mapped_column(LongText, nullable=True)
segment_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
dataset_id = mapped_column(StringUUID, nullable=False)
dataset_name = mapped_column(LongText, nullable=False)
document_id = mapped_column(StringUUID, nullable=True)
document_name = mapped_column(LongText, nullable=False)
data_source_type = mapped_column(LongText, nullable=True)
segment_id = mapped_column(StringUUID, nullable=True)
score: Mapped[float | None] = mapped_column(sa.Float, nullable=True)
content: Mapped[str] = mapped_column(LongText, nullable=False)
content = mapped_column(LongText, nullable=False)
hit_count: Mapped[int | None] = mapped_column(sa.Integer, nullable=True)
word_count: Mapped[int | None] = mapped_column(sa.Integer, nullable=True)
segment_position: Mapped[int | None] = mapped_column(sa.Integer, nullable=True)
index_node_hash: Mapped[str | None] = mapped_column(LongText, nullable=True)
retriever_from: Mapped[str] = mapped_column(LongText, nullable=False)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.current_timestamp(), init=False
)
index_node_hash = mapped_column(LongText, nullable=True)
retriever_from = mapped_column(LongText, nullable=False)
created_by = mapped_column(StringUUID, nullable=False)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=sa.func.current_timestamp())
class Tag(TypeBase):
class Tag(Base):
__tablename__ = "tags"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="tag_pkey"),
@@ -1938,14 +1924,12 @@ class Tag(TypeBase):
TAG_TYPE_LIST = ["knowledge", "app"]
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()), init=False)
tenant_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
type: Mapped[str] = mapped_column(String(16), nullable=False)
id = mapped_column(StringUUID, default=lambda: str(uuid4()))
tenant_id = mapped_column(StringUUID, nullable=True)
type = mapped_column(String(16), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
created_by = mapped_column(StringUUID, nullable=False)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
class TagBinding(TypeBase):
+15 -12
View File
@@ -17,7 +17,7 @@ from core.trigger.utils.endpoint import generate_plugin_trigger_endpoint_url, ge
from libs.datetime_utils import naive_utc_now
from libs.uuid_utils import uuidv7
from .base import TypeBase
from .base import Base, TypeBase
from .engine import db
from .enums import AppTriggerStatus, AppTriggerType, CreatorUserRole, WorkflowTriggerStatus
from .model import Account
@@ -160,7 +160,7 @@ class TriggerOAuthTenantClient(TypeBase):
return cast(Mapping[str, Any], json.loads(self.encrypted_oauth_params or "{}"))
class WorkflowTriggerLog(TypeBase):
class WorkflowTriggerLog(Base):
"""
Workflow Trigger Log
@@ -202,7 +202,7 @@ class WorkflowTriggerLog(TypeBase):
sa.Index("workflow_trigger_log_workflow_id_idx", "workflow_id"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7()), init=False)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
@@ -214,21 +214,24 @@ class WorkflowTriggerLog(TypeBase):
inputs: Mapped[str] = mapped_column(LongText, nullable=False) # Just inputs for easy viewing
outputs: Mapped[str | None] = mapped_column(LongText, nullable=True)
status: Mapped[str] = mapped_column(EnumText(WorkflowTriggerStatus, length=50), nullable=False)
status: Mapped[str] = mapped_column(
EnumText(WorkflowTriggerStatus, length=50), nullable=False, default=WorkflowTriggerStatus.PENDING
)
error: Mapped[str | None] = mapped_column(LongText, nullable=True)
queue_name: Mapped[str] = mapped_column(String(100), nullable=False)
celery_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
retry_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
elapsed_time: Mapped[float | None] = mapped_column(sa.Float, nullable=True)
total_tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
created_by_role: Mapped[str] = mapped_column(String(255), nullable=False)
created_by: Mapped[str] = mapped_column(String(255), nullable=False)
retry_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
elapsed_time: Mapped[float | None] = mapped_column(sa.Float, nullable=True, default=None)
total_tokens: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
triggered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
triggered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
@property
def created_by_account(self):
+57 -2
View File
@@ -1,5 +1,6 @@
import json
import logging
import uuid
from collections.abc import Generator, Mapping, Sequence
from datetime import datetime
from enum import StrEnum
@@ -23,10 +24,13 @@ from sqlalchemy.orm import Mapped, declared_attr, mapped_column
from core.file.constants import maybe_file_object
from core.file.models import File
from core.memory.entities import MemoryBlockSpec
from core.variables import utils as variable_utils
from core.variables.segments import VersionedMemoryValue
from core.variables.variables import FloatVariable, IntegerVariable, StringVariable
from core.workflow.constants import (
CONVERSATION_VARIABLE_NODE_ID,
MEMORY_BLOCK_VARIABLE_NODE_ID,
SYSTEM_VARIABLE_NODE_ID,
)
from core.workflow.enums import NodeType
@@ -96,7 +100,7 @@ class _InvalidGraphDefinitionError(Exception):
pass
class Workflow(Base): # bug
class Workflow(Base):
"""
Workflow, for `Workflow App` and `Chat App workflow mode`.
@@ -161,6 +165,9 @@ class Workflow(Base): # bug
_rag_pipeline_variables: Mapped[str] = mapped_column(
"rag_pipeline_variables", LongText, nullable=False, default="{}"
)
_memory_blocks: Mapped[str] = mapped_column(
"memory_blocks", sa.Text, nullable=False, server_default="[]"
)
VERSION_DRAFT = "draft"
@@ -178,6 +185,7 @@ class Workflow(Base): # bug
environment_variables: Sequence[Variable],
conversation_variables: Sequence[Variable],
rag_pipeline_variables: list[dict],
memory_blocks: Sequence[MemoryBlockSpec] | None = None,
marked_name: str = "",
marked_comment: str = "",
) -> "Workflow":
@@ -193,6 +201,7 @@ class Workflow(Base): # bug
workflow.environment_variables = environment_variables or []
workflow.conversation_variables = conversation_variables or []
workflow.rag_pipeline_variables = rag_pipeline_variables or []
workflow.memory_blocks = memory_blocks or []
workflow.marked_name = marked_name
workflow.marked_comment = marked_comment
workflow.created_at = naive_utc_now()
@@ -509,7 +518,7 @@ class Workflow(Base): # bug
"features": self.features_dict,
"environment_variables": [var.model_dump(mode="json") for var in environment_variables],
"conversation_variables": [var.model_dump(mode="json") for var in self.conversation_variables],
"rag_pipeline_variables": self.rag_pipeline_variables,
"memory_blocks": [block.model_dump(mode="json") for block in self.memory_blocks],
}
return result
@@ -547,6 +556,27 @@ class Workflow(Base): # bug
ensure_ascii=False,
)
@property
def memory_blocks(self) -> Sequence[MemoryBlockSpec]:
"""Memory blocks configuration stored in database"""
if self._memory_blocks is None or self._memory_blocks == "":
self._memory_blocks = "[]"
memory_blocks_list: list[dict[str, Any]] = json.loads(self._memory_blocks)
results = [MemoryBlockSpec.model_validate(config) for config in memory_blocks_list]
return results
@memory_blocks.setter
def memory_blocks(self, value: Sequence[MemoryBlockSpec]):
if not value:
self._memory_blocks = "[]"
return
self._memory_blocks = json.dumps(
[block.model_dump() for block in value],
ensure_ascii=False,
)
@staticmethod
def version_from_datetime(d: datetime) -> str:
return str(d)
@@ -1570,6 +1600,31 @@ class WorkflowDraftVariable(Base):
variable.editable = editable
return variable
@staticmethod
def new_memory_block_variable(
*,
app_id: str,
node_id: str | None = None,
memory_id: str,
name: str,
value: VersionedMemoryValue,
description: str = "",
) -> "WorkflowDraftVariable":
"""Create a new memory block draft variable."""
return WorkflowDraftVariable(
id=str(uuid.uuid4()),
app_id=app_id,
node_id=MEMORY_BLOCK_VARIABLE_NODE_ID,
name=name,
value=value.model_dump_json(),
description=description,
selector=[MEMORY_BLOCK_VARIABLE_NODE_ID, memory_id] if node_id is None else
[MEMORY_BLOCK_VARIABLE_NODE_ID, memory_id, node_id],
value_type=SegmentType.VERSIONED_MEMORY,
visible=True,
editable=True,
)
@property
def edited(self):
return self.last_edited_at is not None
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.10.1"
version = "1.10.0"
requires-python = ">=3.11,<3.13"
dependencies = [
+1 -1
View File
@@ -550,7 +550,7 @@ class AppDslService:
"app": {
"name": app_model.name,
"mode": app_model.mode,
"icon": app_model.icon if app_model.icon_type == "image" else "🤖",
"icon": "🤖" if app_model.icon_type == "image" else app_model.icon,
"icon_background": "#FFEAD5" if app_model.icon_type == "image" else app_model.icon_background,
"description": app_model.description,
"use_icon_as_answer_icon": app_model.use_icon_as_answer_icon,
-45
View File
@@ -1,45 +0,0 @@
"""Service for managing application task operations.
This service provides centralized logic for task control operations
like stopping tasks, handling both legacy Redis flag mechanism and
new GraphEngine command channel mechanism.
"""
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.workflow.graph_engine.manager import GraphEngineManager
from models.model import AppMode
class AppTaskService:
"""Service for managing application task operations."""
@staticmethod
def stop_task(
task_id: str,
invoke_from: InvokeFrom,
user_id: str,
app_mode: AppMode,
) -> None:
"""Stop a running task.
This method handles stopping tasks using both mechanisms:
1. Legacy Redis flag mechanism (for backward compatibility)
2. New GraphEngine command channel (for workflow-based apps)
Args:
task_id: The task ID to stop
invoke_from: The source of the invoke (e.g., DEBUGGER, WEB_APP, SERVICE_API)
user_id: The user ID requesting the stop
app_mode: The application mode (CHAT, AGENT_CHAT, ADVANCED_CHAT, WORKFLOW, etc.)
Returns:
None
"""
# Legacy mechanism: Set stop flag in Redis
AppQueueManager.set_stop_flag(task_id, invoke_from, user_id)
# New mechanism: Send stop command via GraphEngine for workflow-based apps
# This ensures proper workflow status recording in the persistence layer
if app_mode in (AppMode.ADVANCED_CHAT, AppMode.WORKFLOW):
GraphEngineManager.send_stop_command(task_id)
-6
View File
@@ -113,8 +113,6 @@ class AsyncWorkflowService:
trigger_data.trigger_metadata.model_dump_json() if trigger_data.trigger_metadata else "{}"
),
trigger_type=trigger_data.trigger_type,
workflow_run_id=None,
outputs=None,
trigger_data=trigger_data.model_dump_json(),
inputs=json.dumps(dict(trigger_data.inputs)),
status=WorkflowTriggerStatus.PENDING,
@@ -122,10 +120,6 @@ class AsyncWorkflowService:
retry_count=0,
created_by_role=created_by_role,
created_by=created_by,
celery_task_id=None,
error=None,
elapsed_time=None,
total_tokens=None,
)
trigger_log = trigger_log_repo.create(trigger_log)
+289
View File
@@ -0,0 +1,289 @@
import json
from collections.abc import MutableMapping, Sequence
from typing import Literal, Optional, overload
from sqlalchemy import Row, Select, and_, desc, func, select
from sqlalchemy.orm import Session
from core.memory.entities import ChatflowConversationMetadata
from core.model_runtime.entities.message_entities import (
PromptMessage,
)
from extensions.ext_database import db
from models.chatflow_memory import ChatflowConversation, ChatflowMessage
class ChatflowHistoryService:
@staticmethod
def get_visible_chat_history(
conversation_id: str,
app_id: str,
tenant_id: str,
node_id: Optional[str] = None,
max_visible_count: Optional[int] = None
) -> Sequence[PromptMessage]:
with Session(db.engine) as session:
chatflow_conv = ChatflowHistoryService._get_or_create_chatflow_conversation(
session, conversation_id, app_id, tenant_id, node_id, create_if_missing=False
)
if not chatflow_conv:
return []
metadata = ChatflowConversationMetadata.model_validate_json(chatflow_conv.conversation_metadata)
visible_count: int = max_visible_count or metadata.visible_count
stmt = select(ChatflowMessage).where(
ChatflowMessage.conversation_id == chatflow_conv.id
).order_by(ChatflowMessage.index.asc(), ChatflowMessage.version.desc())
raw_messages: Sequence[Row[tuple[ChatflowMessage]]] = session.execute(stmt).all()
sorted_messages = ChatflowHistoryService._filter_latest_messages(
[it[0] for it in raw_messages]
)
visible_count = min(visible_count, len(sorted_messages))
visible_messages = sorted_messages[-visible_count:]
return [PromptMessage.model_validate_json(it.data) for it in visible_messages]
@staticmethod
def get_latest_chat_history_for_app(
app_id: str,
tenant_id: str,
node_id: Optional[str] = None,
max_visible_count: Optional[int] = None
) -> Sequence[PromptMessage]:
"""
Get the latest chat history for an app
Args:
app_id: Application ID
tenant_id: Tenant ID
node_id: Node ID (None for APP level)
max_visible_count: Maximum number of visible messages (optional)
Returns:
PromptMessage sequence, empty list if no history exists
"""
with Session(db.engine) as session:
# Query the most recently updated chatflow conversation
stmt = select(ChatflowConversation).where(
ChatflowConversation.tenant_id == tenant_id,
ChatflowConversation.app_id == app_id,
ChatflowConversation.node_id == (node_id or None)
).order_by(desc(ChatflowConversation.updated_at)).limit(1)
chatflow_conv_row = session.execute(stmt).first()
if not chatflow_conv_row:
return []
chatflow_conv = chatflow_conv_row[0]
# Get visible messages for this conversation
metadata = ChatflowConversationMetadata.model_validate_json(
chatflow_conv.conversation_metadata
)
visible_count: int = max_visible_count or metadata.visible_count
stmt = select(ChatflowMessage).where(
ChatflowMessage.conversation_id == chatflow_conv.id
).order_by(ChatflowMessage.index.asc(), ChatflowMessage.version.desc())
raw_messages: Sequence[Row[tuple[ChatflowMessage]]] = session.execute(stmt).all()
sorted_messages = ChatflowHistoryService._filter_latest_messages(
[it[0] for it in raw_messages]
)
visible_count = min(visible_count, len(sorted_messages))
visible_messages = sorted_messages[-visible_count:]
return [PromptMessage.model_validate_json(it.data) for it in visible_messages]
@staticmethod
def save_message(
prompt_message: PromptMessage,
conversation_id: str,
app_id: str,
tenant_id: str,
node_id: Optional[str] = None
) -> None:
with Session(db.engine) as session:
chatflow_conv = ChatflowHistoryService._get_or_create_chatflow_conversation(
session, conversation_id, app_id, tenant_id, node_id, create_if_missing=True
)
# Get next index
max_index = session.execute(
select(func.max(ChatflowMessage.index)).where(
ChatflowMessage.conversation_id == chatflow_conv.id
)
).scalar() or -1
next_index = max_index + 1
# Save new message to append-only table
new_message = ChatflowMessage(
conversation_id=chatflow_conv.id,
index=next_index,
version=1,
data=json.dumps(prompt_message)
)
session.add(new_message)
session.commit()
# Increment visible_count after each message save
current_metadata = ChatflowConversationMetadata.model_validate_json(chatflow_conv.conversation_metadata)
new_visible_count = current_metadata.visible_count + 1
new_metadata = ChatflowConversationMetadata(visible_count=new_visible_count)
chatflow_conv.conversation_metadata = new_metadata.model_dump_json()
@staticmethod
def save_app_message(
prompt_message: PromptMessage,
conversation_id: str,
app_id: str,
tenant_id: str
) -> None:
"""Save PromptMessage to app-level chatflow conversation."""
ChatflowHistoryService.save_message(
prompt_message=prompt_message,
conversation_id=conversation_id,
app_id=app_id,
tenant_id=tenant_id,
node_id=None
)
@staticmethod
def save_node_message(
prompt_message: PromptMessage,
node_id: str,
conversation_id: str,
app_id: str,
tenant_id: str
) -> None:
ChatflowHistoryService.save_message(
prompt_message=prompt_message,
conversation_id=conversation_id,
app_id=app_id,
tenant_id=tenant_id,
node_id=node_id
)
@staticmethod
def update_visible_count(
conversation_id: str,
node_id: Optional[str],
new_visible_count: int,
app_id: str,
tenant_id: str
) -> None:
with Session(db.engine) as session:
chatflow_conv = ChatflowHistoryService._get_or_create_chatflow_conversation(
session, conversation_id, app_id, tenant_id, node_id, create_if_missing=True
)
# Only update visible_count in metadata, do not delete any data
new_metadata = ChatflowConversationMetadata(visible_count=new_visible_count)
chatflow_conv.conversation_metadata = new_metadata.model_dump_json()
session.commit()
@staticmethod
def get_conversation_metadata(
tenant_id: str,
app_id: str,
conversation_id: str,
node_id: Optional[str]
) -> ChatflowConversationMetadata:
with Session(db.engine) as session:
chatflow_conv = ChatflowHistoryService._get_or_create_chatflow_conversation(
session, conversation_id, app_id, tenant_id, node_id, create_if_missing=False
)
if not chatflow_conv:
raise ValueError(f"Conversation not found: {conversation_id}")
return ChatflowConversationMetadata.model_validate_json(chatflow_conv.conversation_metadata)
@staticmethod
def _filter_latest_messages(raw_messages: Sequence[ChatflowMessage]) -> Sequence[ChatflowMessage]:
index_to_message: MutableMapping[int, ChatflowMessage] = {}
for msg in raw_messages:
index = msg.index
if index not in index_to_message or msg.version > index_to_message[index].version:
index_to_message[index] = msg
sorted_messages = sorted(index_to_message.values(), key=lambda m: m.index)
return sorted_messages
@overload
@staticmethod
def _get_or_create_chatflow_conversation(
session: Session,
conversation_id: str,
app_id: str,
tenant_id: str,
node_id: Optional[str] = None,
create_if_missing: Literal[True] = True
) -> ChatflowConversation: ...
@overload
@staticmethod
def _get_or_create_chatflow_conversation(
session: Session,
conversation_id: str,
app_id: str,
tenant_id: str,
node_id: Optional[str] = None,
create_if_missing: Literal[False] = False
) -> Optional[ChatflowConversation]: ...
@overload
@staticmethod
def _get_or_create_chatflow_conversation(
session: Session,
conversation_id: str,
app_id: str,
tenant_id: str,
node_id: Optional[str] = None,
create_if_missing: bool = False
) -> Optional[ChatflowConversation]: ...
@staticmethod
def _get_or_create_chatflow_conversation(
session: Session,
conversation_id: str,
app_id: str,
tenant_id: str,
node_id: Optional[str] = None,
create_if_missing: bool = False
) -> Optional[ChatflowConversation]:
"""Get existing chatflow conversation or optionally create new one"""
stmt: Select[tuple[ChatflowConversation]] = select(ChatflowConversation).where(
and_(
ChatflowConversation.original_conversation_id == conversation_id,
ChatflowConversation.tenant_id == tenant_id,
ChatflowConversation.app_id == app_id
)
)
if node_id:
stmt = stmt.where(ChatflowConversation.node_id == node_id)
else:
stmt = stmt.where(ChatflowConversation.node_id.is_(None))
chatflow_conv: Row[tuple[ChatflowConversation]] | None = session.execute(stmt).first()
if chatflow_conv:
result: ChatflowConversation = chatflow_conv[0] # Extract the ChatflowConversation object
return result
else:
if create_if_missing:
# Create a new chatflow conversation
default_metadata = ChatflowConversationMetadata(visible_count=0)
new_chatflow_conv = ChatflowConversation(
tenant_id=tenant_id,
app_id=app_id,
node_id=node_id,
original_conversation_id=conversation_id,
conversation_metadata=default_metadata.model_dump_json(),
)
session.add(new_chatflow_conv)
session.flush() # Obtain ID
return new_chatflow_conv
return None
+680
View File
@@ -0,0 +1,680 @@
import logging
import threading
import time
from collections.abc import Sequence
from typing import Optional
from sqlalchemy import and_, delete, select
from sqlalchemy.orm import Session
from core.llm_generator.llm_generator import LLMGenerator
from core.memory.entities import (
MemoryBlock,
MemoryBlockSpec,
MemoryBlockWithConversation,
MemoryCreatedBy,
MemoryScheduleMode,
MemoryScope,
MemoryTerm,
MemoryValueData,
)
from core.memory.errors import MemorySyncTimeoutError
from core.model_runtime.entities.message_entities import PromptMessage
from core.variables.segments import VersionedMemoryValue
from core.workflow.constants import MEMORY_BLOCK_VARIABLE_NODE_ID
from core.workflow.runtime import VariablePool
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models import App, CreatorUserRole
from models.chatflow_memory import ChatflowMemoryVariable
from models.workflow import Workflow, WorkflowDraftVariable
from services.chatflow_history_service import ChatflowHistoryService
from services.workflow_draft_variable_service import WorkflowDraftVariableService
from services.workflow_service import WorkflowService
logger = logging.getLogger(__name__)
class ChatflowMemoryService:
@staticmethod
def get_persistent_memories(
app: App,
created_by: MemoryCreatedBy,
version: int | None = None
) -> Sequence[MemoryBlock]:
if created_by.account_id:
created_by_role = CreatorUserRole.ACCOUNT
created_by_id = created_by.account_id
else:
created_by_role = CreatorUserRole.END_USER
created_by_id = created_by.id
if version is None:
# If version not specified, get the latest version
stmt = select(ChatflowMemoryVariable).distinct(ChatflowMemoryVariable.memory_id).where(
and_(
ChatflowMemoryVariable.tenant_id == app.tenant_id,
ChatflowMemoryVariable.app_id == app.id,
ChatflowMemoryVariable.conversation_id == None,
ChatflowMemoryVariable.created_by_role == created_by_role,
ChatflowMemoryVariable.created_by == created_by_id,
)
).order_by(ChatflowMemoryVariable.version.desc())
else:
stmt = select(ChatflowMemoryVariable).where(
and_(
ChatflowMemoryVariable.tenant_id == app.tenant_id,
ChatflowMemoryVariable.app_id == app.id,
ChatflowMemoryVariable.conversation_id == None,
ChatflowMemoryVariable.created_by_role == created_by_role,
ChatflowMemoryVariable.created_by == created_by_id,
ChatflowMemoryVariable.version == version
)
)
with Session(db.engine) as session:
db_results = session.execute(stmt).all()
return ChatflowMemoryService._convert_to_memory_blocks(app, created_by, [result[0] for result in db_results])
@staticmethod
def get_session_memories(
app: App,
created_by: MemoryCreatedBy,
conversation_id: str,
version: int | None = None
) -> Sequence[MemoryBlock]:
if version is None:
# If version not specified, get the latest version
stmt = select(ChatflowMemoryVariable).distinct(ChatflowMemoryVariable.memory_id).where(
and_(
ChatflowMemoryVariable.tenant_id == app.tenant_id,
ChatflowMemoryVariable.app_id == app.id,
ChatflowMemoryVariable.conversation_id == conversation_id
)
).order_by(ChatflowMemoryVariable.version.desc())
else:
stmt = select(ChatflowMemoryVariable).where(
and_(
ChatflowMemoryVariable.tenant_id == app.tenant_id,
ChatflowMemoryVariable.app_id == app.id,
ChatflowMemoryVariable.conversation_id == conversation_id,
ChatflowMemoryVariable.version == version
)
)
with Session(db.engine) as session:
db_results = session.execute(stmt).all()
return ChatflowMemoryService._convert_to_memory_blocks(app, created_by, [result[0] for result in db_results])
@staticmethod
def save_memory(memory: MemoryBlock, variable_pool: VariablePool, is_draft: bool) -> None:
key = f"{memory.node_id}_{memory.spec.id}" if memory.node_id else memory.spec.id
variable_pool.add([MEMORY_BLOCK_VARIABLE_NODE_ID, key], memory.value)
if memory.created_by.account_id:
created_by_role = CreatorUserRole.ACCOUNT
created_by = memory.created_by.account_id
else:
created_by_role = CreatorUserRole.END_USER
created_by = memory.created_by.id
with Session(db.engine) as session:
session.add(
ChatflowMemoryVariable(
memory_id=memory.spec.id,
tenant_id=memory.tenant_id,
app_id=memory.app_id,
node_id=memory.node_id,
conversation_id=memory.conversation_id,
name=memory.spec.name,
value=MemoryValueData(
value=memory.value,
edited_by_user=memory.edited_by_user
).model_dump_json(),
term=memory.spec.term,
scope=memory.spec.scope,
version=memory.version, # Use version from MemoryBlock directly
created_by_role=created_by_role,
created_by=created_by,
)
)
session.commit()
if is_draft:
with Session(bind=db.engine) as session:
draft_var_service = WorkflowDraftVariableService(session)
memory_selector = memory.spec.id if not memory.node_id else f"{memory.node_id}_{memory.spec.id}"
existing_vars = draft_var_service.get_draft_variables_by_selectors(
app_id=memory.app_id,
selectors=[[MEMORY_BLOCK_VARIABLE_NODE_ID, memory_selector]]
)
if existing_vars:
draft_var = existing_vars[0]
draft_var.value = VersionedMemoryValue.model_validate_json(draft_var.value)\
.add_version(memory.value)\
.model_dump_json()
else:
draft_var = WorkflowDraftVariable.new_memory_block_variable(
app_id=memory.app_id,
memory_id=memory.spec.id,
name=memory.spec.name,
value=VersionedMemoryValue().add_version(memory.value),
description=memory.spec.description
)
session.add(draft_var)
session.commit()
@staticmethod
def get_memories_by_specs(
memory_block_specs: Sequence[MemoryBlockSpec],
tenant_id: str, app_id: str,
created_by: MemoryCreatedBy,
conversation_id: Optional[str],
node_id: Optional[str],
is_draft: bool
) -> Sequence[MemoryBlock]:
return [ChatflowMemoryService.get_memory_by_spec(
spec, tenant_id, app_id, created_by, conversation_id, node_id, is_draft
) for spec in memory_block_specs]
@staticmethod
def get_memory_by_spec(
spec: MemoryBlockSpec,
tenant_id: str,
app_id: str,
created_by: MemoryCreatedBy,
conversation_id: Optional[str],
node_id: Optional[str],
is_draft: bool
) -> MemoryBlock:
with Session(db.engine) as session:
if is_draft:
draft_var_service = WorkflowDraftVariableService(session)
selector = [MEMORY_BLOCK_VARIABLE_NODE_ID, f"{spec.id}.{node_id}"] \
if node_id else [MEMORY_BLOCK_VARIABLE_NODE_ID, spec.id]
draft_vars = draft_var_service.get_draft_variables_by_selectors(
app_id=app_id,
selectors=[selector]
)
if draft_vars:
draft_var = draft_vars[0]
return MemoryBlock(
value=draft_var.get_value().text,
tenant_id=tenant_id,
app_id=app_id,
conversation_id=conversation_id,
node_id=node_id,
spec=spec,
created_by=created_by,
version=1,
)
stmt = select(ChatflowMemoryVariable).where(
and_(
ChatflowMemoryVariable.memory_id == spec.id,
ChatflowMemoryVariable.tenant_id == tenant_id,
ChatflowMemoryVariable.app_id == app_id,
ChatflowMemoryVariable.node_id ==
(node_id if spec.scope == MemoryScope.NODE else None),
ChatflowMemoryVariable.conversation_id ==
(conversation_id if spec.term == MemoryTerm.SESSION else None),
)
).order_by(ChatflowMemoryVariable.version.desc()).limit(1)
result = session.execute(stmt).scalar()
if result:
memory_value_data = MemoryValueData.model_validate_json(result.value)
return MemoryBlock(
value=memory_value_data.value,
tenant_id=tenant_id,
app_id=app_id,
conversation_id=conversation_id,
node_id=node_id,
spec=spec,
edited_by_user=memory_value_data.edited_by_user,
created_by=created_by,
version=result.version,
)
return MemoryBlock(
tenant_id=tenant_id,
value=spec.template,
app_id=app_id,
conversation_id=conversation_id,
node_id=node_id,
spec=spec,
created_by=created_by,
version=1,
)
@staticmethod
def update_app_memory_if_needed(
workflow: Workflow,
conversation_id: str,
variable_pool: VariablePool,
created_by: MemoryCreatedBy,
is_draft: bool
):
visible_messages = ChatflowHistoryService.get_visible_chat_history(
conversation_id=conversation_id,
app_id=workflow.app_id,
tenant_id=workflow.tenant_id,
node_id=None,
)
sync_blocks: list[MemoryBlock] = []
async_blocks: list[MemoryBlock] = []
for memory_spec in workflow.memory_blocks:
if memory_spec.scope == MemoryScope.APP:
memory = ChatflowMemoryService.get_memory_by_spec(
spec=memory_spec,
tenant_id=workflow.tenant_id,
app_id=workflow.app_id,
conversation_id=conversation_id,
node_id=None,
is_draft=is_draft,
created_by=created_by,
)
if ChatflowMemoryService._should_update_memory(memory, visible_messages):
if memory.spec.schedule_mode == MemoryScheduleMode.SYNC:
sync_blocks.append(memory)
else:
async_blocks.append(memory)
if not sync_blocks and not async_blocks:
return
# async mode: submit individual async tasks directly
for memory_block in async_blocks:
ChatflowMemoryService._app_submit_async_memory_update(
block=memory_block,
is_draft=is_draft,
variable_pool=variable_pool,
visible_messages=visible_messages,
conversation_id=conversation_id,
)
# sync mode: submit a batch update task
if sync_blocks:
ChatflowMemoryService._app_submit_sync_memory_batch_update(
sync_blocks=sync_blocks,
is_draft=is_draft,
conversation_id=conversation_id,
app_id=workflow.app_id,
visible_messages=visible_messages,
variable_pool=variable_pool
)
@staticmethod
def update_node_memory_if_needed(
tenant_id: str,
app_id: str,
node_id: str,
created_by: MemoryCreatedBy,
conversation_id: str,
memory_block_spec: MemoryBlockSpec,
variable_pool: VariablePool,
is_draft: bool
) -> bool:
visible_messages = ChatflowHistoryService.get_visible_chat_history(
conversation_id=conversation_id,
app_id=app_id,
tenant_id=tenant_id,
node_id=node_id,
)
memory_block = ChatflowMemoryService.get_memory_by_spec(
spec=memory_block_spec,
tenant_id=tenant_id,
app_id=app_id,
conversation_id=conversation_id,
node_id=node_id,
is_draft=is_draft,
created_by=created_by,
)
if not ChatflowMemoryService._should_update_memory(
memory_block=memory_block,
visible_history=visible_messages
):
return False
if memory_block_spec.schedule_mode == MemoryScheduleMode.SYNC:
# Node-level sync: blocking execution
ChatflowMemoryService._update_node_memory_sync(
visible_messages=visible_messages,
memory_block=memory_block,
variable_pool=variable_pool,
is_draft=is_draft,
conversation_id=conversation_id
)
else:
# Node-level async: execute asynchronously
ChatflowMemoryService._update_node_memory_async(
memory_block=memory_block,
visible_messages=visible_messages,
variable_pool=variable_pool,
is_draft=is_draft,
conversation_id=conversation_id
)
return True
@staticmethod
def wait_for_sync_memory_completion(workflow: Workflow, conversation_id: str):
"""Wait for sync memory update to complete, maximum 50 seconds"""
memory_blocks = workflow.memory_blocks
sync_memory_blocks = [
block for block in memory_blocks
if block.scope == MemoryScope.APP and block.schedule_mode == MemoryScheduleMode.SYNC
]
if not sync_memory_blocks:
return
lock_key = _get_memory_sync_lock_key(workflow.app_id, conversation_id)
# Retry up to 10 times, wait 5 seconds each time, total 50 seconds
max_retries = 10
retry_interval = 5
for i in range(max_retries):
if not redis_client.exists(lock_key):
# Lock doesn't exist, can continue
return
if i < max_retries - 1:
# Still have retry attempts, wait
time.sleep(retry_interval)
else:
# Maximum retry attempts reached, raise exception
raise MemorySyncTimeoutError(
app_id=workflow.app_id,
conversation_id=conversation_id
)
@staticmethod
def _convert_to_memory_blocks(
app: App,
created_by: MemoryCreatedBy,
raw_results: Sequence[ChatflowMemoryVariable]
) -> Sequence[MemoryBlock]:
workflow = WorkflowService().get_published_workflow(app)
if not workflow:
return []
results = []
for chatflow_memory_variable in raw_results:
spec = next(
(spec for spec in workflow.memory_blocks if spec.id == chatflow_memory_variable.memory_id),
None
)
if spec and chatflow_memory_variable.app_id:
memory_value_data = MemoryValueData.model_validate_json(chatflow_memory_variable.value)
results.append(
MemoryBlock(
spec=spec,
tenant_id=chatflow_memory_variable.tenant_id,
value=memory_value_data.value,
app_id=chatflow_memory_variable.app_id,
conversation_id=chatflow_memory_variable.conversation_id,
node_id=chatflow_memory_variable.node_id,
edited_by_user=memory_value_data.edited_by_user,
created_by=created_by,
version=chatflow_memory_variable.version,
)
)
return results
@staticmethod
def _should_update_memory(
memory_block: MemoryBlock,
visible_history: Sequence[PromptMessage]
) -> bool:
return len(visible_history) >= memory_block.spec.update_turns
@staticmethod
def _app_submit_async_memory_update(
block: MemoryBlock,
visible_messages: Sequence[PromptMessage],
variable_pool: VariablePool,
conversation_id: str,
is_draft: bool
):
thread = threading.Thread(
target=ChatflowMemoryService._perform_memory_update,
kwargs={
'memory_block': block,
'visible_messages': visible_messages,
'variable_pool': variable_pool,
'is_draft': is_draft,
'conversation_id': conversation_id
},
)
thread.start()
@staticmethod
def _app_submit_sync_memory_batch_update(
sync_blocks: Sequence[MemoryBlock],
app_id: str,
conversation_id: str,
visible_messages: Sequence[PromptMessage],
variable_pool: VariablePool,
is_draft: bool
):
"""Submit sync memory batch update task"""
thread = threading.Thread(
target=ChatflowMemoryService._batch_update_sync_memory,
kwargs={
'sync_blocks': sync_blocks,
'app_id': app_id,
'conversation_id': conversation_id,
'visible_messages': visible_messages,
'variable_pool': variable_pool,
'is_draft': is_draft
},
)
thread.start()
@staticmethod
def _batch_update_sync_memory(
sync_blocks: Sequence[MemoryBlock],
app_id: str,
conversation_id: str,
visible_messages: Sequence[PromptMessage],
variable_pool: VariablePool,
is_draft: bool
):
try:
lock_key = _get_memory_sync_lock_key(app_id, conversation_id)
with redis_client.lock(lock_key, timeout=120):
threads = []
for block in sync_blocks:
thread = threading.Thread(
target=ChatflowMemoryService._perform_memory_update,
kwargs={
'memory_block': block,
'visible_messages': visible_messages,
'variable_pool': variable_pool,
'is_draft': is_draft,
'conversation_id': conversation_id,
},
)
threads.append(thread)
for thread in threads:
thread.start()
for thread in threads:
thread.join()
except Exception as e:
logger.exception("Error batch updating memory", exc_info=e)
@staticmethod
def _update_node_memory_sync(
memory_block: MemoryBlock,
visible_messages: Sequence[PromptMessage],
variable_pool: VariablePool,
conversation_id: str,
is_draft: bool
):
ChatflowMemoryService._perform_memory_update(
memory_block=memory_block,
visible_messages=visible_messages,
variable_pool=variable_pool,
is_draft=is_draft,
conversation_id=conversation_id
)
@staticmethod
def _update_node_memory_async(
memory_block: MemoryBlock,
visible_messages: Sequence[PromptMessage],
variable_pool: VariablePool,
conversation_id: str,
is_draft: bool = False
):
thread = threading.Thread(
target=ChatflowMemoryService._perform_memory_update,
kwargs={
'memory_block': memory_block,
'visible_messages': visible_messages,
'variable_pool': variable_pool,
'is_draft': is_draft,
'conversation_id': conversation_id,
},
daemon=True
)
thread.start()
@staticmethod
def _perform_memory_update(
memory_block: MemoryBlock,
variable_pool: VariablePool,
conversation_id: str,
visible_messages: Sequence[PromptMessage],
is_draft: bool
):
updated_value = LLMGenerator.update_memory_block(
tenant_id=memory_block.tenant_id,
visible_history=ChatflowMemoryService._format_chat_history(visible_messages),
variable_pool=variable_pool,
memory_block=memory_block,
memory_spec=memory_block.spec,
)
updated_memory = MemoryBlock(
tenant_id=memory_block.tenant_id,
value=updated_value,
spec=memory_block.spec,
app_id=memory_block.app_id,
conversation_id=conversation_id,
node_id=memory_block.node_id,
edited_by_user=False,
created_by=memory_block.created_by,
version=memory_block.version + 1, # Increment version for business logic update
)
ChatflowMemoryService.save_memory(updated_memory, variable_pool, is_draft)
ChatflowHistoryService.update_visible_count(
conversation_id=conversation_id,
node_id=memory_block.node_id,
new_visible_count=memory_block.spec.preserved_turns,
app_id=memory_block.app_id,
tenant_id=memory_block.tenant_id
)
@staticmethod
def delete_memory(app: App, memory_id: str, created_by: MemoryCreatedBy):
workflow = WorkflowService().get_published_workflow(app)
if not workflow:
raise ValueError("Workflow not found")
memory_spec = next((it for it in workflow.memory_blocks if it.id == memory_id), None)
if not memory_spec or not memory_spec.end_user_editable:
raise ValueError("Memory not found or not deletable")
if created_by.account_id:
created_by_role = CreatorUserRole.ACCOUNT
created_by_id = created_by.account_id
else:
created_by_role = CreatorUserRole.END_USER
created_by_id = created_by.id
with Session(db.engine) as session:
stmt = delete(ChatflowMemoryVariable).where(
and_(
ChatflowMemoryVariable.tenant_id == app.tenant_id,
ChatflowMemoryVariable.app_id == app.id,
ChatflowMemoryVariable.memory_id == memory_id,
ChatflowMemoryVariable.created_by_role == created_by_role,
ChatflowMemoryVariable.created_by == created_by_id
)
)
session.execute(stmt)
session.commit()
@staticmethod
def delete_all_user_memories(app: App, created_by: MemoryCreatedBy):
if created_by.account_id:
created_by_role = CreatorUserRole.ACCOUNT
created_by_id = created_by.account_id
else:
created_by_role = CreatorUserRole.END_USER
created_by_id = created_by.id
with Session(db.engine) as session:
stmt = delete(ChatflowMemoryVariable).where(
and_(
ChatflowMemoryVariable.tenant_id == app.tenant_id,
ChatflowMemoryVariable.app_id == app.id,
ChatflowMemoryVariable.created_by_role == created_by_role,
ChatflowMemoryVariable.created_by == created_by_id
)
)
session.execute(stmt)
session.commit()
@staticmethod
def get_persistent_memories_with_conversation(
app: App,
created_by: MemoryCreatedBy,
conversation_id: str,
version: int | None = None
) -> Sequence[MemoryBlockWithConversation]:
"""Get persistent memories with conversation metadata (always None for persistent)"""
memory_blocks = ChatflowMemoryService.get_persistent_memories(app, created_by, version)
return [
MemoryBlockWithConversation.from_memory_block(
block,
ChatflowHistoryService.get_conversation_metadata(
app.tenant_id, app.id, conversation_id, block.node_id
)
)
for block in memory_blocks
]
@staticmethod
def get_session_memories_with_conversation(
app: App,
created_by: MemoryCreatedBy,
conversation_id: str,
version: int | None = None
) -> Sequence[MemoryBlockWithConversation]:
"""Get session memories with conversation metadata"""
memory_blocks = ChatflowMemoryService.get_session_memories(app, created_by, conversation_id, version)
return [
MemoryBlockWithConversation.from_memory_block(
block,
ChatflowHistoryService.get_conversation_metadata(
app.tenant_id, app.id, conversation_id, block.node_id
)
)
for block in memory_blocks
]
@staticmethod
def _format_chat_history(messages: Sequence[PromptMessage]) -> Sequence[tuple[str, str]]:
result = []
for message in messages:
result.append((str(message.role.value), message.get_text_content()))
return result
def _get_memory_sync_lock_key(app_id: str, conversation_id: str) -> str:
"""Generate Redis lock key for memory sync updates
Args:
app_id: Application ID
conversation_id: Conversation ID
Returns:
Formatted lock key
"""
return f"memory_sync_update:{app_id}:{conversation_id}"
-5
View File
@@ -1375,11 +1375,6 @@ class DocumentService:
document.name = name
db.session.add(document)
if document.data_source_info_dict:
db.session.query(UploadFile).where(
UploadFile.id == document.data_source_info_dict["upload_file_id"]
).update({UploadFile.name: name})
db.session.commit()
return document
-185
View File
@@ -1,185 +0,0 @@
import csv
import io
import json
from datetime import datetime
from flask import Response
from sqlalchemy import or_
from extensions.ext_database import db
from models.model import Account, App, Conversation, Message, MessageFeedback
class FeedbackService:
@staticmethod
def export_feedbacks(
app_id: str,
from_source: str | None = None,
rating: str | None = None,
has_comment: bool | None = None,
start_date: str | None = None,
end_date: str | None = None,
format_type: str = "csv",
):
"""
Export feedback data with message details for analysis
Args:
app_id: Application ID
from_source: Filter by feedback source ('user' or 'admin')
rating: Filter by rating ('like' or 'dislike')
has_comment: Only include feedback with comments
start_date: Start date filter (YYYY-MM-DD)
end_date: End date filter (YYYY-MM-DD)
format_type: Export format ('csv' or 'json')
"""
# Validate format early to avoid hitting DB when unnecessary
fmt = (format_type or "csv").lower()
if fmt not in {"csv", "json"}:
raise ValueError(f"Unsupported format: {format_type}")
# Build base query
query = (
db.session.query(MessageFeedback, Message, Conversation, App, Account)
.join(Message, MessageFeedback.message_id == Message.id)
.join(Conversation, MessageFeedback.conversation_id == Conversation.id)
.join(App, MessageFeedback.app_id == App.id)
.outerjoin(Account, MessageFeedback.from_account_id == Account.id)
.where(MessageFeedback.app_id == app_id)
)
# Apply filters
if from_source:
query = query.filter(MessageFeedback.from_source == from_source)
if rating:
query = query.filter(MessageFeedback.rating == rating)
if has_comment is not None:
if has_comment:
query = query.filter(MessageFeedback.content.isnot(None), MessageFeedback.content != "")
else:
query = query.filter(or_(MessageFeedback.content.is_(None), MessageFeedback.content == ""))
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(MessageFeedback.created_at >= start_dt)
except ValueError:
raise ValueError(f"Invalid start_date format: {start_date}. Use YYYY-MM-DD")
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
query = query.filter(MessageFeedback.created_at <= end_dt)
except ValueError:
raise ValueError(f"Invalid end_date format: {end_date}. Use YYYY-MM-DD")
# Order by creation date (newest first)
query = query.order_by(MessageFeedback.created_at.desc())
# Execute query
results = query.all()
# Prepare data for export
export_data = []
for feedback, message, conversation, app, account in results:
# Get the user query from the message
user_query = message.query or message.inputs.get("query", "") if message.inputs else ""
# Format the feedback data
feedback_record = {
"feedback_id": str(feedback.id),
"app_name": app.name,
"app_id": str(app.id),
"conversation_id": str(conversation.id),
"conversation_name": conversation.name or "",
"message_id": str(message.id),
"user_query": user_query,
"ai_response": message.answer[:500] + "..."
if len(message.answer) > 500
else message.answer, # Truncate long responses
"feedback_rating": "👍" if feedback.rating == "like" else "👎",
"feedback_rating_raw": feedback.rating,
"feedback_comment": feedback.content or "",
"feedback_source": feedback.from_source,
"feedback_date": feedback.created_at.strftime("%Y-%m-%d %H:%M:%S"),
"message_date": message.created_at.strftime("%Y-%m-%d %H:%M:%S"),
"from_account_name": account.name if account else "",
"from_end_user_id": str(feedback.from_end_user_id) if feedback.from_end_user_id else "",
"has_comment": "Yes" if feedback.content and feedback.content.strip() else "No",
}
export_data.append(feedback_record)
# Export based on format
if fmt == "csv":
return FeedbackService._export_csv(export_data, app_id)
else: # fmt == "json"
return FeedbackService._export_json(export_data, app_id)
@staticmethod
def _export_csv(data, app_id):
"""Export data as CSV"""
if not data:
pass # allow empty CSV with headers only
# Create CSV in memory
output = io.StringIO()
# Define headers
headers = [
"feedback_id",
"app_name",
"app_id",
"conversation_id",
"conversation_name",
"message_id",
"user_query",
"ai_response",
"feedback_rating",
"feedback_rating_raw",
"feedback_comment",
"feedback_source",
"feedback_date",
"message_date",
"from_account_name",
"from_end_user_id",
"has_comment",
]
writer = csv.DictWriter(output, fieldnames=headers)
writer.writeheader()
writer.writerows(data)
# Create response without requiring app context
response = Response(output.getvalue(), mimetype="text/csv; charset=utf-8-sig")
response.headers["Content-Disposition"] = (
f"attachment; filename=dify_feedback_export_{app_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
)
return response
@staticmethod
def _export_json(data, app_id):
"""Export data as JSON"""
response_data = {
"export_info": {
"app_id": app_id,
"export_date": datetime.now().isoformat(),
"total_records": len(data),
"data_source": "dify_feedback_export",
},
"feedback_data": data,
}
# Create response without requiring app context
response = Response(
json.dumps(response_data, ensure_ascii=False, indent=2),
mimetype="application/json; charset=utf-8",
)
response.headers["Content-Disposition"] = (
f"attachment; filename=dify_feedback_export_{app_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
)
return response
+10 -9
View File
@@ -3,8 +3,8 @@ import os
import uuid
from typing import Literal, Union
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy import Engine
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import NotFound
from configs import dify_config
@@ -29,7 +29,7 @@ PREVIEW_WORDS_LIMIT = 3000
class FileService:
_session_maker: sessionmaker[Session]
_session_maker: sessionmaker
def __init__(self, session_factory: sessionmaker | Engine | None = None):
if isinstance(session_factory, Engine):
@@ -236,10 +236,11 @@ class FileService:
return content.decode("utf-8")
def delete_file(self, file_id: str):
with self._session_maker() as session, session.begin():
upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id))
with self._session_maker(expire_on_commit=False) as session:
upload_file: UploadFile | None = session.query(UploadFile).where(UploadFile.id == file_id).first()
if not upload_file:
return
storage.delete(upload_file.key)
session.delete(upload_file)
if not upload_file:
return
storage.delete(upload_file.key)
session.delete(upload_file)
session.commit()
-1
View File
@@ -164,7 +164,6 @@ class MessageService:
elif not rating and not feedback:
raise ValueError("rating cannot be None when feedback not exists")
else:
assert rating is not None
feedback = MessageFeedback(
app_id=app_model.id,
conversation_id=message.conversation_id,
@@ -580,14 +580,13 @@ class RagPipelineDslService:
raise ValueError("Current tenant is not set")
# Create new app
pipeline = Pipeline(
tenant_id=account.current_tenant_id,
name=pipeline_data.get("name", ""),
description=pipeline_data.get("description", ""),
created_by=account.id,
updated_by=account.id,
)
pipeline = Pipeline()
pipeline.id = str(uuid4())
pipeline.tenant_id = account.current_tenant_id
pipeline.name = pipeline_data.get("name", "")
pipeline.description = pipeline_data.get("description", "")
pipeline.created_by = account.id
pipeline.updated_by = account.id
self._session.add(pipeline)
self._session.commit()
@@ -198,16 +198,15 @@ class RagPipelineTransformService:
graph = workflow_data.get("graph", {})
# Create new app
pipeline = Pipeline(
tenant_id=current_user.current_tenant_id,
name=pipeline_data.get("name", ""),
description=pipeline_data.get("description", ""),
created_by=current_user.id,
updated_by=current_user.id,
is_published=True,
is_public=True,
)
pipeline = Pipeline()
pipeline.id = str(uuid4())
pipeline.tenant_id = current_user.current_tenant_id
pipeline.name = pipeline_data.get("name", "")
pipeline.description = pipeline_data.get("description", "")
pipeline.created_by = current_user.id
pipeline.updated_by = current_user.id
pipeline.is_published = True
pipeline.is_public = True
db.session.add(pipeline)
db.session.flush()
+1 -1
View File
@@ -79,12 +79,12 @@ class TagService:
if TagService.get_tag_by_tag_name(args["type"], current_user.current_tenant_id, args["name"]):
raise ValueError("Tag name already exists")
tag = Tag(
id=str(uuid.uuid4()),
name=args["name"],
type=args["type"],
created_by=current_user.id,
tenant_id=current_user.current_tenant_id,
)
tag.id = str(uuid.uuid4())
db.session.add(tag)
db.session.commit()
return tag
@@ -475,7 +475,7 @@ class TriggerProviderService:
oauth_params = encrypter.decrypt(dict(tenant_client.oauth_params))
return oauth_params
is_verified = PluginService.is_plugin_verified(tenant_id, provider_controller.plugin_unique_identifier)
is_verified = PluginService.is_plugin_verified(tenant_id, provider_id.plugin_id)
if not is_verified:
return None
@@ -499,8 +499,7 @@ class TriggerProviderService:
"""
Check if system OAuth client exists for a trigger provider.
"""
provider_controller = TriggerManager.get_trigger_provider(tenant_id=tenant_id, provider_id=provider_id)
is_verified = PluginService.is_plugin_verified(tenant_id, provider_controller.plugin_unique_identifier)
is_verified = PluginService.is_plugin_verified(tenant_id, provider_id.plugin_id)
if not is_verified:
return False
with Session(db.engine, expire_on_commit=False) as session:
+6 -14
View File
@@ -5,7 +5,6 @@ import secrets
from collections.abc import Mapping
from typing import Any
import orjson
from flask import request
from pydantic import BaseModel
from sqlalchemy import select
@@ -170,7 +169,7 @@ class WebhookService:
- method: HTTP method
- headers: Request headers
- query_params: Query parameters as strings
- body: Request body (varies by content type; JSON parsing errors raise ValueError)
- body: Request body (varies by content type)
- files: Uploaded files (if any)
"""
cls._validate_content_length()
@@ -256,21 +255,14 @@ class WebhookService:
Returns:
tuple: (body_data, files_data) where:
- body_data: Parsed JSON content
- body_data: Parsed JSON content or empty dict if parsing fails
- files_data: Empty dict (JSON requests don't contain files)
Raises:
ValueError: If JSON parsing fails
"""
raw_body = request.get_data(cache=True)
if not raw_body or raw_body.strip() == b"":
return {}, {}
try:
body = orjson.loads(raw_body)
except orjson.JSONDecodeError as exc:
logger.warning("Failed to parse JSON body: %s", exc)
raise ValueError(f"Invalid JSON body: {exc}") from exc
body = request.get_json() or {}
except Exception:
logger.warning("Failed to parse JSON body")
body = {}
return body, {}
@classmethod
+43 -12
View File
@@ -12,6 +12,7 @@ from core.app.app_config.entities import VariableEntityType
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
from core.file import File
from core.memory.entities import MemoryBlockSpec, MemoryCreatedBy, MemoryScope
from core.repositories import DifyCoreRepositoryFactory
from core.variables import Variable
from core.variables.variables import VariableUnion
@@ -199,6 +200,7 @@ class WorkflowService:
account: Account,
environment_variables: Sequence[Variable],
conversation_variables: Sequence[Variable],
memory_blocks: Sequence[MemoryBlockSpec] | None = None,
) -> Workflow:
"""
Sync draft workflow
@@ -229,6 +231,7 @@ class WorkflowService:
environment_variables=environment_variables,
conversation_variables=conversation_variables,
)
workflow.memory_blocks = memory_blocks or []
db.session.add(workflow)
# update draft workflow if found
else:
@@ -238,6 +241,7 @@ class WorkflowService:
workflow.updated_at = naive_utc_now()
workflow.environment_variables = environment_variables
workflow.conversation_variables = conversation_variables
workflow.memory_blocks = memory_blocks or []
# commit db session changes
db.session.commit()
@@ -303,6 +307,7 @@ class WorkflowService:
marked_name=marked_name,
marked_comment=marked_comment,
rag_pipeline_variables=draft_workflow.rag_pipeline_variables,
memory_blocks=draft_workflow.memory_blocks,
features=draft_workflow.features,
)
@@ -660,17 +665,10 @@ class WorkflowService:
tenant_id=draft_workflow.tenant_id, start_node_data=start_data, user_inputs=user_inputs
)
# init variable pool
variable_pool = _setup_variable_pool(
query=query,
files=files or [],
user_id=account.id,
user_inputs=user_inputs,
workflow=draft_workflow,
# NOTE(QuantumGhost): We rely on `DraftVarLoader` to load conversation variables.
conversation_variables=[],
node_type=node_type,
conversation_id=conversation_id,
)
variable_pool = _setup_variable_pool(query=query, files=files or [], user_id=account.id,
user_inputs=user_inputs, workflow=draft_workflow,
node_type=node_type, conversation_id=conversation_id,
conversation_variables=[], is_draft=True)
else:
variable_pool = VariablePool(
@@ -1044,6 +1042,7 @@ def _setup_variable_pool(
node_type: NodeType,
conversation_id: str,
conversation_variables: list[Variable],
is_draft: bool
):
# Only inject system variables for START node type.
if node_type == NodeType.START or node_type.is_trigger_node:
@@ -1063,7 +1062,6 @@ def _setup_variable_pool(
system_variable.dialogue_count = 1
else:
system_variable = SystemVariable.empty()
# init variable pool
variable_pool = VariablePool(
system_variables=system_variable,
@@ -1072,6 +1070,12 @@ def _setup_variable_pool(
# Based on the definition of `VariableUnion`,
# `list[Variable]` can be safely used as `list[VariableUnion]` since they are compatible.
conversation_variables=cast(list[VariableUnion], conversation_variables), #
memory_blocks=_fetch_memory_blocks(
workflow,
MemoryCreatedBy(account_id=user_id),
conversation_id,
is_draft=is_draft
),
)
return variable_pool
@@ -1108,3 +1112,30 @@ def _rebuild_single_file(tenant_id: str, value: Any, variable_entity_type: Varia
return build_from_mappings(mappings=value, tenant_id=tenant_id)
else:
raise Exception("unreachable")
def _fetch_memory_blocks(
workflow: Workflow,
created_by: MemoryCreatedBy,
conversation_id: str,
is_draft: bool
) -> Mapping[str, str]:
memory_blocks = {}
memory_block_specs = workflow.memory_blocks
from services.chatflow_memory_service import ChatflowMemoryService
memories = ChatflowMemoryService.get_memories_by_specs(
memory_block_specs=memory_block_specs,
tenant_id=workflow.tenant_id,
app_id=workflow.app_id,
node_id=None,
conversation_id=conversation_id,
is_draft=is_draft,
created_by=created_by,
)
for memory in memories:
if memory.spec.scope == MemoryScope.APP:
memory_blocks[memory.spec.id] = memory.value
else: # NODE scope
memory_blocks[f"{memory.node_id}_{memory.spec.id}"] = memory.value
return memory_blocks
-2
View File
@@ -218,8 +218,6 @@ def _record_trigger_failure_log(
finished_at=now,
elapsed_time=0.0,
total_tokens=0,
outputs=None,
celery_task_id=None,
)
session.add(trigger_log)
session.commit()
-1
View File
@@ -62,7 +62,6 @@ WEAVIATE_ENDPOINT=http://localhost:8080
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_GRPC_ENABLED=false
WEAVIATE_BATCH_SIZE=100
WEAVIATE_TOKENIZATION=word
# Upload configuration
@@ -1,106 +0,0 @@
"""Basic integration tests for Feedback API endpoints."""
import uuid
from flask.testing import FlaskClient
class TestFeedbackApiBasic:
"""Basic tests for feedback API endpoints."""
def test_feedback_export_endpoint_exists(self, test_client: FlaskClient, auth_header):
"""Test that feedback export endpoint exists and handles basic requests."""
app_id = str(uuid.uuid4())
# Test endpoint exists (even if it fails, it should return 500 or 403, not 404)
response = test_client.get(
f"/console/api/apps/{app_id}/feedbacks/export", headers=auth_header, query_string={"format": "csv"}
)
# Should not return 404 (endpoint exists)
assert response.status_code != 404
# Should return authentication or permission error
assert response.status_code in [401, 403, 500] # 500 if app doesn't exist, 403 if no permission
def test_feedback_summary_endpoint_exists(self, test_client: FlaskClient, auth_header):
"""Test that feedback summary endpoint exists and handles basic requests."""
app_id = str(uuid.uuid4())
# Test endpoint exists
response = test_client.get(f"/console/api/apps/{app_id}/feedbacks/summary", headers=auth_header)
# Should not return 404 (endpoint exists)
assert response.status_code != 404
# Should return authentication or permission error
assert response.status_code in [401, 403, 500]
def test_feedback_export_invalid_format(self, test_client: FlaskClient, auth_header):
"""Test feedback export endpoint with invalid format parameter."""
app_id = str(uuid.uuid4())
# Test with invalid format
response = test_client.get(
f"/console/api/apps/{app_id}/feedbacks/export",
headers=auth_header,
query_string={"format": "invalid_format"},
)
# Should not return 404
assert response.status_code != 404
def test_feedback_export_with_filters(self, test_client: FlaskClient, auth_header):
"""Test feedback export endpoint with various filter parameters."""
app_id = str(uuid.uuid4())
# Test with various filter combinations
filter_params = [
{"from_source": "user"},
{"rating": "like"},
{"has_comment": True},
{"start_date": "2024-01-01"},
{"end_date": "2024-12-31"},
{"format": "json"},
{
"from_source": "admin",
"rating": "dislike",
"has_comment": True,
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"format": "csv",
},
]
for params in filter_params:
response = test_client.get(
f"/console/api/apps/{app_id}/feedbacks/export", headers=auth_header, query_string=params
)
# Should not return 404
assert response.status_code != 404
def test_feedback_export_invalid_dates(self, test_client: FlaskClient, auth_header):
"""Test feedback export endpoint with invalid date formats."""
app_id = str(uuid.uuid4())
# Test with invalid date formats
invalid_dates = [
{"start_date": "invalid-date"},
{"end_date": "not-a-date"},
{"start_date": "2024-13-01"}, # Invalid month
{"end_date": "2024-12-32"}, # Invalid day
]
for params in invalid_dates:
response = test_client.get(
f"/console/api/apps/{app_id}/feedbacks/export", headers=auth_header, query_string=params
)
# Should not return 404
assert response.status_code != 404
@@ -1,334 +0,0 @@
"""Integration tests for Feedback Export API endpoints."""
import json
import uuid
from datetime import datetime
from types import SimpleNamespace
from unittest import mock
import pytest
from flask.testing import FlaskClient
from controllers.console.app import message as message_api
from controllers.console.app import wraps
from libs.datetime_utils import naive_utc_now
from models import App, Tenant
from models.account import Account, TenantAccountJoin, TenantAccountRole
from models.model import AppMode, MessageFeedback
from services.feedback_service import FeedbackService
class TestFeedbackExportApi:
"""Test feedback export API endpoints."""
@pytest.fixture
def mock_app_model(self):
"""Create a mock App model for testing."""
app = App()
app.id = str(uuid.uuid4())
app.mode = AppMode.CHAT
app.tenant_id = str(uuid.uuid4())
app.status = "normal"
app.name = "Test App"
return app
@pytest.fixture
def mock_account(self, monkeypatch: pytest.MonkeyPatch):
"""Create a mock Account for testing."""
account = Account(
name="Test User",
email="test@example.com",
)
account.last_active_at = naive_utc_now()
account.created_at = naive_utc_now()
account.updated_at = naive_utc_now()
account.id = str(uuid.uuid4())
# Create mock tenant
tenant = Tenant(name="Test Tenant")
tenant.id = str(uuid.uuid4())
mock_session_instance = mock.Mock()
mock_tenant_join = TenantAccountJoin(role=TenantAccountRole.OWNER)
monkeypatch.setattr(mock_session_instance, "scalar", mock.Mock(return_value=mock_tenant_join))
mock_scalars_result = mock.Mock()
mock_scalars_result.one.return_value = tenant
monkeypatch.setattr(mock_session_instance, "scalars", mock.Mock(return_value=mock_scalars_result))
mock_session_context = mock.Mock()
mock_session_context.__enter__.return_value = mock_session_instance
monkeypatch.setattr("models.account.Session", lambda _, expire_on_commit: mock_session_context)
account.current_tenant = tenant
return account
@pytest.fixture
def sample_feedback_data(self):
"""Create sample feedback data for testing."""
app_id = str(uuid.uuid4())
conversation_id = str(uuid.uuid4())
message_id = str(uuid.uuid4())
# Mock feedback data
user_feedback = MessageFeedback(
id=str(uuid.uuid4()),
app_id=app_id,
conversation_id=conversation_id,
message_id=message_id,
rating="like",
from_source="user",
content=None,
from_end_user_id=str(uuid.uuid4()),
from_account_id=None,
created_at=naive_utc_now(),
)
admin_feedback = MessageFeedback(
id=str(uuid.uuid4()),
app_id=app_id,
conversation_id=conversation_id,
message_id=message_id,
rating="dislike",
from_source="admin",
content="The response was not helpful",
from_end_user_id=None,
from_account_id=str(uuid.uuid4()),
created_at=naive_utc_now(),
)
# Mock message and conversation
mock_message = SimpleNamespace(
id=message_id,
conversation_id=conversation_id,
query="What is the weather today?",
answer="It's sunny and 25 degrees outside.",
inputs={"query": "What is the weather today?"},
created_at=naive_utc_now(),
)
mock_conversation = SimpleNamespace(id=conversation_id, name="Weather Conversation", app_id=app_id)
mock_app = SimpleNamespace(id=app_id, name="Weather App")
return {
"user_feedback": user_feedback,
"admin_feedback": admin_feedback,
"message": mock_message,
"conversation": mock_conversation,
"app": mock_app,
}
@pytest.mark.parametrize(
("role", "status"),
[
(TenantAccountRole.OWNER, 200),
(TenantAccountRole.ADMIN, 200),
(TenantAccountRole.EDITOR, 200),
(TenantAccountRole.NORMAL, 403),
(TenantAccountRole.DATASET_OPERATOR, 403),
],
)
def test_feedback_export_permissions(
self,
test_client: FlaskClient,
auth_header,
monkeypatch,
mock_app_model,
mock_account,
role: TenantAccountRole,
status: int,
):
"""Test feedback export endpoint permissions."""
# Setup mocks
mock_load_app_model = mock.Mock(return_value=mock_app_model)
monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model)
mock_export_feedbacks = mock.Mock(return_value="mock csv response")
monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks)
monkeypatch.setattr(message_api, "current_user", mock_account)
# Set user role
mock_account.role = role
response = test_client.get(
f"/console/api/apps/{mock_app_model.id}/feedbacks/export",
headers=auth_header,
query_string={"format": "csv"},
)
assert response.status_code == status
if status == 200:
mock_export_feedbacks.assert_called_once()
def test_feedback_export_csv_format(
self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account, sample_feedback_data
):
"""Test feedback export in CSV format."""
# Setup mocks
mock_load_app_model = mock.Mock(return_value=mock_app_model)
monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model)
# Create mock CSV response
mock_csv_content = (
"feedback_id,app_name,conversation_id,user_query,ai_response,feedback_rating,feedback_comment\n"
)
mock_csv_content += f"{sample_feedback_data['user_feedback'].id},{sample_feedback_data['app'].name},"
mock_csv_content += f"{sample_feedback_data['conversation'].id},{sample_feedback_data['message'].query},"
mock_csv_content += f"{sample_feedback_data['message'].answer},👍,\n"
mock_response = mock.Mock()
mock_response.headers = {"Content-Type": "text/csv; charset=utf-8-sig"}
mock_response.data = mock_csv_content.encode("utf-8")
mock_export_feedbacks = mock.Mock(return_value=mock_response)
monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks)
monkeypatch.setattr(message_api, "current_user", mock_account)
response = test_client.get(
f"/console/api/apps/{mock_app_model.id}/feedbacks/export",
headers=auth_header,
query_string={"format": "csv", "from_source": "user"},
)
assert response.status_code == 200
assert "text/csv" in response.content_type
def test_feedback_export_json_format(
self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account, sample_feedback_data
):
"""Test feedback export in JSON format."""
# Setup mocks
mock_load_app_model = mock.Mock(return_value=mock_app_model)
monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model)
mock_json_response = {
"export_info": {
"app_id": mock_app_model.id,
"export_date": datetime.now().isoformat(),
"total_records": 2,
"data_source": "dify_feedback_export",
},
"feedback_data": [
{
"feedback_id": sample_feedback_data["user_feedback"].id,
"feedback_rating": "👍",
"feedback_rating_raw": "like",
"feedback_comment": "",
}
],
}
mock_response = mock.Mock()
mock_response.headers = {"Content-Type": "application/json; charset=utf-8"}
mock_response.data = json.dumps(mock_json_response).encode("utf-8")
mock_export_feedbacks = mock.Mock(return_value=mock_response)
monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks)
monkeypatch.setattr(message_api, "current_user", mock_account)
response = test_client.get(
f"/console/api/apps/{mock_app_model.id}/feedbacks/export",
headers=auth_header,
query_string={"format": "json"},
)
assert response.status_code == 200
assert "application/json" in response.content_type
def test_feedback_export_with_filters(
self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account
):
"""Test feedback export with various filters."""
# Setup mocks
mock_load_app_model = mock.Mock(return_value=mock_app_model)
monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model)
mock_export_feedbacks = mock.Mock(return_value="mock filtered response")
monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks)
monkeypatch.setattr(message_api, "current_user", mock_account)
# Test with multiple filters
response = test_client.get(
f"/console/api/apps/{mock_app_model.id}/feedbacks/export",
headers=auth_header,
query_string={
"from_source": "user",
"rating": "dislike",
"has_comment": True,
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"format": "csv",
},
)
assert response.status_code == 200
# Verify service was called with correct parameters
mock_export_feedbacks.assert_called_once_with(
app_id=mock_app_model.id,
from_source="user",
rating="dislike",
has_comment=True,
start_date="2024-01-01",
end_date="2024-12-31",
format_type="csv",
)
def test_feedback_export_invalid_date_format(
self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account
):
"""Test feedback export with invalid date format."""
# Setup mocks
mock_load_app_model = mock.Mock(return_value=mock_app_model)
monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model)
# Mock the service to raise ValueError for invalid date
mock_export_feedbacks = mock.Mock(side_effect=ValueError("Invalid date format"))
monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks)
monkeypatch.setattr(message_api, "current_user", mock_account)
response = test_client.get(
f"/console/api/apps/{mock_app_model.id}/feedbacks/export",
headers=auth_header,
query_string={"start_date": "invalid-date", "format": "csv"},
)
assert response.status_code == 400
response_json = response.get_json()
assert "Parameter validation error" in response_json["error"]
def test_feedback_export_server_error(
self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account
):
"""Test feedback export with server error."""
# Setup mocks
mock_load_app_model = mock.Mock(return_value=mock_app_model)
monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model)
# Mock the service to raise an exception
mock_export_feedbacks = mock.Mock(side_effect=Exception("Database connection failed"))
monkeypatch.setattr(FeedbackService, "export_feedbacks", mock_export_feedbacks)
monkeypatch.setattr(message_api, "current_user", mock_account)
response = test_client.get(
f"/console/api/apps/{mock_app_model.id}/feedbacks/export",
headers=auth_header,
query_string={"format": "csv"},
)
assert response.status_code == 500
@@ -295,13 +295,9 @@ class TestAPIBasedExtensionService:
original_name = created_extension.name
original_endpoint = created_extension.api_endpoint
# Update the extension with guaranteed different values
# Update the extension
new_name = fake.company()
# Ensure new endpoint is different from original
new_endpoint = f"https://{fake.domain_name()}/api"
# If by chance they're the same, generate a new one
while new_endpoint == original_endpoint:
new_endpoint = f"https://{fake.domain_name()}/api"
new_api_key = fake.password(length=20)
created_extension.name = new_name
@@ -1,386 +0,0 @@
"""Unit tests for FeedbackService."""
import json
from datetime import datetime
from types import SimpleNamespace
from unittest import mock
import pytest
from extensions.ext_database import db
from models.model import App, Conversation, Message
from services.feedback_service import FeedbackService
class TestFeedbackService:
"""Test FeedbackService methods."""
@pytest.fixture
def mock_db_session(self, monkeypatch):
"""Mock database session."""
mock_session = mock.Mock()
monkeypatch.setattr(db, "session", mock_session)
return mock_session
@pytest.fixture
def sample_data(self):
"""Create sample data for testing."""
app_id = "test-app-id"
# Create mock models
app = App(id=app_id, name="Test App")
conversation = Conversation(id="test-conversation-id", app_id=app_id, name="Test Conversation")
message = Message(
id="test-message-id",
conversation_id="test-conversation-id",
query="What is AI?",
answer="AI is artificial intelligence.",
inputs={"query": "What is AI?"},
created_at=datetime(2024, 1, 1, 10, 0, 0),
)
# Use SimpleNamespace to avoid ORM model constructor issues
user_feedback = SimpleNamespace(
id="user-feedback-id",
app_id=app_id,
conversation_id="test-conversation-id",
message_id="test-message-id",
rating="like",
from_source="user",
content="Great answer!",
from_end_user_id="user-123",
from_account_id=None,
from_account=None, # Mock account object
created_at=datetime(2024, 1, 1, 10, 5, 0),
)
admin_feedback = SimpleNamespace(
id="admin-feedback-id",
app_id=app_id,
conversation_id="test-conversation-id",
message_id="test-message-id",
rating="dislike",
from_source="admin",
content="Could be more detailed",
from_end_user_id=None,
from_account_id="admin-456",
from_account=SimpleNamespace(name="Admin User"), # Mock account object
created_at=datetime(2024, 1, 1, 10, 10, 0),
)
return {
"app": app,
"conversation": conversation,
"message": message,
"user_feedback": user_feedback,
"admin_feedback": admin_feedback,
}
def test_export_feedbacks_csv_format(self, mock_db_session, sample_data):
"""Test exporting feedback data in CSV format."""
# Setup mock query result
mock_query = mock.Mock()
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [
(
sample_data["user_feedback"],
sample_data["message"],
sample_data["conversation"],
sample_data["app"],
sample_data["user_feedback"].from_account,
)
]
mock_db_session.query.return_value = mock_query
# Test CSV export
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv")
# Verify response structure
assert hasattr(result, "headers")
assert "text/csv" in result.headers["Content-Type"]
assert "attachment" in result.headers["Content-Disposition"]
# Check CSV content
csv_content = result.get_data(as_text=True)
# Verify essential headers exist (order may include additional columns)
assert "feedback_id" in csv_content
assert "app_name" in csv_content
assert "conversation_id" in csv_content
assert sample_data["app"].name in csv_content
assert sample_data["message"].query in csv_content
def test_export_feedbacks_json_format(self, mock_db_session, sample_data):
"""Test exporting feedback data in JSON format."""
# Setup mock query result
mock_query = mock.Mock()
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [
(
sample_data["admin_feedback"],
sample_data["message"],
sample_data["conversation"],
sample_data["app"],
sample_data["admin_feedback"].from_account,
)
]
mock_db_session.query.return_value = mock_query
# Test JSON export
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json")
# Verify response structure
assert hasattr(result, "headers")
assert "application/json" in result.headers["Content-Type"]
assert "attachment" in result.headers["Content-Disposition"]
# Check JSON content
json_content = json.loads(result.get_data(as_text=True))
assert "export_info" in json_content
assert "feedback_data" in json_content
assert json_content["export_info"]["app_id"] == sample_data["app"].id
assert json_content["export_info"]["total_records"] == 1
def test_export_feedbacks_with_filters(self, mock_db_session, sample_data):
"""Test exporting feedback with various filters."""
# Setup mock query result
mock_query = mock.Mock()
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [
(
sample_data["admin_feedback"],
sample_data["message"],
sample_data["conversation"],
sample_data["app"],
sample_data["admin_feedback"].from_account,
)
]
mock_db_session.query.return_value = mock_query
# Test with filters
result = FeedbackService.export_feedbacks(
app_id=sample_data["app"].id,
from_source="admin",
rating="dislike",
has_comment=True,
start_date="2024-01-01",
end_date="2024-12-31",
format_type="csv",
)
# Verify filters were applied
assert mock_query.filter.called
filter_calls = mock_query.filter.call_args_list
# At least three filter invocations are expected (source, rating, comment)
assert len(filter_calls) >= 3
def test_export_feedbacks_no_data(self, mock_db_session, sample_data):
"""Test exporting feedback when no data exists."""
# Setup mock query result with no data
mock_query = mock.Mock()
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = []
mock_db_session.query.return_value = mock_query
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv")
# Should return an empty CSV with headers only
assert hasattr(result, "headers")
assert "text/csv" in result.headers["Content-Type"]
csv_content = result.get_data(as_text=True)
# Headers should exist (order can include additional columns)
assert "feedback_id" in csv_content
assert "app_name" in csv_content
assert "conversation_id" in csv_content
# No data rows expected
assert len([line for line in csv_content.strip().splitlines() if line.strip()]) == 1
def test_export_feedbacks_invalid_date_format(self, mock_db_session, sample_data):
"""Test exporting feedback with invalid date format."""
# Test with invalid start_date
with pytest.raises(ValueError, match="Invalid start_date format"):
FeedbackService.export_feedbacks(app_id=sample_data["app"].id, start_date="invalid-date-format")
# Test with invalid end_date
with pytest.raises(ValueError, match="Invalid end_date format"):
FeedbackService.export_feedbacks(app_id=sample_data["app"].id, end_date="invalid-date-format")
def test_export_feedbacks_invalid_format(self, mock_db_session, sample_data):
"""Test exporting feedback with unsupported format."""
with pytest.raises(ValueError, match="Unsupported format"):
FeedbackService.export_feedbacks(
app_id=sample_data["app"].id,
format_type="xml", # Unsupported format
)
def test_export_feedbacks_long_response_truncation(self, mock_db_session, sample_data):
"""Test that long AI responses are truncated in export."""
# Create message with long response
long_message = Message(
id="long-message-id",
conversation_id="test-conversation-id",
query="What is AI?",
answer="A" * 600, # 600 character response
inputs={"query": "What is AI?"},
created_at=datetime(2024, 1, 1, 10, 0, 0),
)
# Setup mock query result
mock_query = mock.Mock()
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [
(
sample_data["user_feedback"],
long_message,
sample_data["conversation"],
sample_data["app"],
sample_data["user_feedback"].from_account,
)
]
mock_db_session.query.return_value = mock_query
# Test export
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json")
# Check JSON content
json_content = json.loads(result.get_data(as_text=True))
exported_answer = json_content["feedback_data"][0]["ai_response"]
# Should be truncated with ellipsis
assert len(exported_answer) <= 503 # 500 + "..."
assert exported_answer.endswith("...")
assert len(exported_answer) > 500 # Should be close to limit
def test_export_feedbacks_unicode_content(self, mock_db_session, sample_data):
"""Test exporting feedback with unicode content (Chinese characters)."""
# Create feedback with Chinese content (use SimpleNamespace to avoid ORM constructor constraints)
chinese_feedback = SimpleNamespace(
id="chinese-feedback-id",
app_id=sample_data["app"].id,
conversation_id="test-conversation-id",
message_id="test-message-id",
rating="dislike",
from_source="user",
content="回答不够详细,需要更多信息",
from_end_user_id="user-123",
from_account_id=None,
created_at=datetime(2024, 1, 1, 10, 5, 0),
)
# Create Chinese message
chinese_message = Message(
id="chinese-message-id",
conversation_id="test-conversation-id",
query="什么是人工智能?",
answer="人工智能是模拟人类智能的技术。",
inputs={"query": "什么是人工智能?"},
created_at=datetime(2024, 1, 1, 10, 0, 0),
)
# Setup mock query result
mock_query = mock.Mock()
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [
(
chinese_feedback,
chinese_message,
sample_data["conversation"],
sample_data["app"],
None, # No account for user feedback
)
]
mock_db_session.query.return_value = mock_query
# Test export
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv")
# Check that unicode content is preserved
csv_content = result.get_data(as_text=True)
assert "什么是人工智能?" in csv_content
assert "回答不够详细,需要更多信息" in csv_content
assert "人工智能是模拟人类智能的技术" in csv_content
def test_export_feedbacks_emoji_ratings(self, mock_db_session, sample_data):
"""Test that rating emojis are properly formatted in export."""
# Setup mock query result with both like and dislike feedback
mock_query = mock.Mock()
mock_query.join.return_value = mock_query
mock_query.outerjoin.return_value = mock_query
mock_query.where.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [
(
sample_data["user_feedback"],
sample_data["message"],
sample_data["conversation"],
sample_data["app"],
sample_data["user_feedback"].from_account,
),
(
sample_data["admin_feedback"],
sample_data["message"],
sample_data["conversation"],
sample_data["app"],
sample_data["admin_feedback"].from_account,
),
]
mock_db_session.query.return_value = mock_query
# Test export
result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json")
# Check JSON content for emoji ratings
json_content = json.loads(result.get_data(as_text=True))
feedback_data = json_content["feedback_data"]
# Should have both feedback records
assert len(feedback_data) == 2
# Check that emojis are properly set
like_feedback = next(f for f in feedback_data if f["feedback_rating_raw"] == "like")
dislike_feedback = next(f for f in feedback_data if f["feedback_rating_raw"] == "dislike")
assert like_feedback["feedback_rating"] == "👍"
assert dislike_feedback["feedback_rating"] == "👎"
@@ -384,24 +384,24 @@ class TestCleanDatasetTask:
# Create dataset metadata and bindings
metadata = DatasetMetadata(
id=str(uuid.uuid4()),
dataset_id=dataset.id,
tenant_id=tenant.id,
name="test_metadata",
type="string",
created_by=account.id,
created_at=datetime.now(),
)
metadata.id = str(uuid.uuid4())
metadata.created_at = datetime.now()
binding = DatasetMetadataBinding(
id=str(uuid.uuid4()),
tenant_id=tenant.id,
dataset_id=dataset.id,
metadata_id=metadata.id,
document_id=documents[0].id, # Use first document as example
created_by=account.id,
created_at=datetime.now(),
)
binding.id = str(uuid.uuid4())
binding.created_at = datetime.now()
from extensions.ext_database import db
@@ -697,26 +697,26 @@ class TestCleanDatasetTask:
for i in range(10): # Create 10 metadata items
metadata = DatasetMetadata(
id=str(uuid.uuid4()),
dataset_id=dataset.id,
tenant_id=tenant.id,
name=f"test_metadata_{i}",
type="string",
created_by=account.id,
created_at=datetime.now(),
)
metadata.id = str(uuid.uuid4())
metadata.created_at = datetime.now()
metadata_items.append(metadata)
# Create binding for each metadata item
binding = DatasetMetadataBinding(
id=str(uuid.uuid4()),
tenant_id=tenant.id,
dataset_id=dataset.id,
metadata_id=metadata.id,
document_id=documents[i % len(documents)].id,
created_by=account.id,
created_at=datetime.now(),
)
binding.id = str(uuid.uuid4())
binding.created_at = datetime.now()
bindings.append(binding)
from extensions.ext_database import db
@@ -966,15 +966,14 @@ class TestCleanDatasetTask:
# Create metadata with special characters
special_metadata = DatasetMetadata(
id=str(uuid.uuid4()),
dataset_id=dataset.id,
tenant_id=tenant.id,
name=f"metadata_{special_content}",
type="string",
created_by=account.id,
created_at=datetime.now(),
)
special_metadata.id = str(uuid.uuid4())
special_metadata.created_at = datetime.now()
db.session.add(special_metadata)
db.session.commit()
@@ -112,13 +112,13 @@ class TestRagPipelineRunTasks:
# Create pipeline
pipeline = Pipeline(
id=str(uuid.uuid4()),
tenant_id=tenant.id,
workflow_id=workflow.id,
name=fake.company(),
description=fake.text(max_nb_chars=100),
created_by=account.id,
)
pipeline.id = str(uuid.uuid4())
db.session.add(pipeline)
db.session.commit()
@@ -1,160 +0,0 @@
import sys
import types
from collections.abc import Generator
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
import pytest
from core.file import File, FileTransferMethod, FileType
from core.model_runtime.entities.llm_entities import LLMUsage
from core.tools.entities.tool_entities import ToolInvokeMessage
from core.tools.utils.message_transformer import ToolFileMessageTransformer
from core.variables.segments import ArrayFileSegment
from core.workflow.entities import GraphInitParams
from core.workflow.node_events import StreamChunkEvent, StreamCompletedEvent
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
if TYPE_CHECKING: # pragma: no cover - imported for type checking only
from core.workflow.nodes.tool.tool_node import ToolNode
@pytest.fixture
def tool_node(monkeypatch) -> "ToolNode":
module_name = "core.ops.ops_trace_manager"
if module_name not in sys.modules:
ops_stub = types.ModuleType(module_name)
ops_stub.TraceQueueManager = object # pragma: no cover - stub attribute
ops_stub.TraceTask = object # pragma: no cover - stub attribute
monkeypatch.setitem(sys.modules, module_name, ops_stub)
from core.workflow.nodes.tool.tool_node import ToolNode
graph_config: dict[str, Any] = {
"nodes": [
{
"id": "tool-node",
"data": {
"type": "tool",
"title": "Tool",
"desc": "",
"provider_id": "provider",
"provider_type": "builtin",
"provider_name": "provider",
"tool_name": "tool",
"tool_label": "tool",
"tool_configurations": {},
"tool_parameters": {},
},
}
],
"edges": [],
}
init_params = GraphInitParams(
tenant_id="tenant-id",
app_id="app-id",
workflow_id="workflow-id",
graph_config=graph_config,
user_id="user-id",
user_from="account",
invoke_from="debugger",
call_depth=0,
)
variable_pool = VariablePool(system_variables=SystemVariable(user_id="user-id"))
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
config = graph_config["nodes"][0]
node = ToolNode(
id="node-instance",
config=config,
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
)
node.init_node_data(config["data"])
return node
def _collect_events(generator: Generator) -> tuple[list[Any], LLMUsage]:
events: list[Any] = []
try:
while True:
events.append(next(generator))
except StopIteration as stop:
return events, stop.value
def _run_transform(tool_node: "ToolNode", message: ToolInvokeMessage) -> tuple[list[Any], LLMUsage]:
def _identity_transform(messages, *_args, **_kwargs):
return messages
tool_runtime = MagicMock()
with patch.object(ToolFileMessageTransformer, "transform_tool_invoke_messages", side_effect=_identity_transform):
generator = tool_node._transform_message(
messages=iter([message]),
tool_info={"provider_type": "builtin", "provider_id": "provider"},
parameters_for_log={},
user_id="user-id",
tenant_id="tenant-id",
node_id=tool_node._node_id,
tool_runtime=tool_runtime,
)
return _collect_events(generator)
def test_link_messages_with_file_populate_files_output(tool_node: "ToolNode"):
file_obj = File(
tenant_id="tenant-id",
type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.TOOL_FILE,
related_id="file-id",
filename="demo.pdf",
extension=".pdf",
mime_type="application/pdf",
size=123,
storage_key="file-key",
)
message = ToolInvokeMessage(
type=ToolInvokeMessage.MessageType.LINK,
message=ToolInvokeMessage.TextMessage(text="/files/tools/file-id.pdf"),
meta={"file": file_obj},
)
events, usage = _run_transform(tool_node, message)
assert isinstance(usage, LLMUsage)
chunk_events = [event for event in events if isinstance(event, StreamChunkEvent)]
assert chunk_events
assert chunk_events[0].chunk == "File: /files/tools/file-id.pdf\n"
completed_events = [event for event in events if isinstance(event, StreamCompletedEvent)]
assert len(completed_events) == 1
outputs = completed_events[0].node_run_result.outputs
assert outputs["text"] == "File: /files/tools/file-id.pdf\n"
files_segment = outputs["files"]
assert isinstance(files_segment, ArrayFileSegment)
assert files_segment.value == [file_obj]
def test_plain_link_messages_remain_links(tool_node: "ToolNode"):
message = ToolInvokeMessage(
type=ToolInvokeMessage.MessageType.LINK,
message=ToolInvokeMessage.TextMessage(text="https://dify.ai"),
meta=None,
)
events, _ = _run_transform(tool_node, message)
chunk_events = [event for event in events if isinstance(event, StreamChunkEvent)]
assert chunk_events
assert chunk_events[0].chunk == "Link: https://dify.ai\n"
completed_events = [event for event in events if isinstance(event, StreamCompletedEvent)]
assert len(completed_events) == 1
files_segment = completed_events[0].node_run_result.outputs["files"]
assert isinstance(files_segment, ArrayFileSegment)
assert files_segment.value == []
@@ -1,966 +0,0 @@
"""
Comprehensive unit tests for Tool models.
This test suite covers:
- ToolProvider model validation (BuiltinToolProvider, ApiToolProvider)
- BuiltinToolProvider relationships and credential management
- ApiToolProvider credential storage and encryption
- Tool OAuth client models
- ToolLabelBinding relationships
"""
import json
from uuid import uuid4
from core.tools.entities.tool_entities import ApiProviderSchemaType
from models.tools import (
ApiToolProvider,
BuiltinToolProvider,
ToolLabelBinding,
ToolOAuthSystemClient,
ToolOAuthTenantClient,
)
class TestBuiltinToolProviderValidation:
"""Test suite for BuiltinToolProvider model validation and operations."""
def test_builtin_tool_provider_creation_with_required_fields(self):
"""Test creating a builtin tool provider with all required fields."""
# Arrange
tenant_id = str(uuid4())
user_id = str(uuid4())
provider_name = "google"
credentials = {"api_key": "test_key_123"}
# Act
builtin_provider = BuiltinToolProvider(
tenant_id=tenant_id,
user_id=user_id,
provider=provider_name,
encrypted_credentials=json.dumps(credentials),
name="Google API Key 1",
)
# Assert
assert builtin_provider.tenant_id == tenant_id
assert builtin_provider.user_id == user_id
assert builtin_provider.provider == provider_name
assert builtin_provider.name == "Google API Key 1"
assert builtin_provider.encrypted_credentials == json.dumps(credentials)
def test_builtin_tool_provider_credentials_property(self):
"""Test credentials property parses JSON correctly."""
# Arrange
credentials_data = {
"api_key": "sk-test123",
"auth_type": "api_key",
"endpoint": "https://api.example.com",
}
builtin_provider = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="custom_provider",
name="Custom Provider Key",
encrypted_credentials=json.dumps(credentials_data),
)
# Act
result = builtin_provider.credentials
# Assert
assert result == credentials_data
assert result["api_key"] == "sk-test123"
assert result["auth_type"] == "api_key"
def test_builtin_tool_provider_credentials_empty_when_none(self):
"""Test credentials property returns empty dict when encrypted_credentials is None."""
# Arrange
builtin_provider = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="test_provider",
name="Test Provider",
encrypted_credentials=None,
)
# Act
result = builtin_provider.credentials
# Assert
assert result == {}
def test_builtin_tool_provider_credentials_empty_when_empty_string(self):
"""Test credentials property returns empty dict when encrypted_credentials is empty."""
# Arrange
builtin_provider = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="test_provider",
name="Test Provider",
encrypted_credentials="",
)
# Act
result = builtin_provider.credentials
# Assert
assert result == {}
def test_builtin_tool_provider_default_values(self):
"""Test builtin tool provider default values."""
# Arrange & Act
builtin_provider = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="test_provider",
name="Test Provider",
)
# Assert
assert builtin_provider.is_default is False
assert builtin_provider.credential_type == "api-key"
assert builtin_provider.expires_at == -1
def test_builtin_tool_provider_with_oauth_credential_type(self):
"""Test builtin tool provider with OAuth credential type."""
# Arrange
credentials = {
"access_token": "oauth_token_123",
"refresh_token": "refresh_token_456",
"token_type": "Bearer",
}
# Act
builtin_provider = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="google",
name="Google OAuth",
encrypted_credentials=json.dumps(credentials),
credential_type="oauth2",
expires_at=1735689600,
)
# Assert
assert builtin_provider.credential_type == "oauth2"
assert builtin_provider.expires_at == 1735689600
assert builtin_provider.credentials["access_token"] == "oauth_token_123"
def test_builtin_tool_provider_is_default_flag(self):
"""Test is_default flag for builtin tool provider."""
# Arrange
provider1 = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="google",
name="Google Key 1",
is_default=True,
)
provider2 = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="google",
name="Google Key 2",
is_default=False,
)
# Assert
assert provider1.is_default is True
assert provider2.is_default is False
def test_builtin_tool_provider_unique_constraint_fields(self):
"""Test unique constraint fields (tenant_id, provider, name)."""
# Arrange
tenant_id = str(uuid4())
provider_name = "google"
credential_name = "My Google Key"
# Act
builtin_provider = BuiltinToolProvider(
tenant_id=tenant_id,
user_id=str(uuid4()),
provider=provider_name,
name=credential_name,
)
# Assert - these fields form unique constraint
assert builtin_provider.tenant_id == tenant_id
assert builtin_provider.provider == provider_name
assert builtin_provider.name == credential_name
def test_builtin_tool_provider_multiple_credentials_same_provider(self):
"""Test multiple credential sets for the same provider."""
# Arrange
tenant_id = str(uuid4())
user_id = str(uuid4())
provider = "openai"
# Act - create multiple credentials for same provider
provider1 = BuiltinToolProvider(
tenant_id=tenant_id,
user_id=user_id,
provider=provider,
name="OpenAI Key 1",
encrypted_credentials=json.dumps({"api_key": "key1"}),
)
provider2 = BuiltinToolProvider(
tenant_id=tenant_id,
user_id=user_id,
provider=provider,
name="OpenAI Key 2",
encrypted_credentials=json.dumps({"api_key": "key2"}),
)
# Assert - different names allow multiple credentials
assert provider1.provider == provider2.provider
assert provider1.name != provider2.name
assert provider1.credentials != provider2.credentials
class TestApiToolProviderValidation:
"""Test suite for ApiToolProvider model validation and operations."""
def test_api_tool_provider_creation_with_required_fields(self):
"""Test creating an API tool provider with all required fields."""
# Arrange
tenant_id = str(uuid4())
user_id = str(uuid4())
provider_name = "Custom API"
schema = '{"openapi": "3.0.0", "info": {"title": "Test API"}}'
tools = [{"name": "test_tool", "description": "A test tool"}]
credentials = {"auth_type": "api_key", "api_key_value": "test123"}
# Act
api_provider = ApiToolProvider(
tenant_id=tenant_id,
user_id=user_id,
name=provider_name,
icon='{"type": "emoji", "value": "🔧"}',
schema=schema,
schema_type_str="openapi",
description="Custom API for testing",
tools_str=json.dumps(tools),
credentials_str=json.dumps(credentials),
)
# Assert
assert api_provider.tenant_id == tenant_id
assert api_provider.user_id == user_id
assert api_provider.name == provider_name
assert api_provider.schema == schema
assert api_provider.schema_type_str == "openapi"
assert api_provider.description == "Custom API for testing"
def test_api_tool_provider_schema_type_property(self):
"""Test schema_type property converts string to enum."""
# Arrange
api_provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
name="Test API",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="Test",
tools_str="[]",
credentials_str="{}",
)
# Act
result = api_provider.schema_type
# Assert
assert result == ApiProviderSchemaType.OPENAPI
def test_api_tool_provider_tools_property(self):
"""Test tools property parses JSON and returns ApiToolBundle list."""
# Arrange
tools_data = [
{
"author": "test",
"server_url": "https://api.weather.com",
"method": "get",
"summary": "Get weather information",
"operation_id": "getWeather",
"parameters": [],
"openapi": {
"operation_id": "getWeather",
"parameters": [],
"method": "get",
"path": "/weather",
"server_url": "https://api.weather.com",
},
},
{
"author": "test",
"server_url": "https://api.location.com",
"method": "get",
"summary": "Get location data",
"operation_id": "getLocation",
"parameters": [],
"openapi": {
"operation_id": "getLocation",
"parameters": [],
"method": "get",
"path": "/location",
"server_url": "https://api.location.com",
},
},
]
api_provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
name="Weather API",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="Weather API",
tools_str=json.dumps(tools_data),
credentials_str="{}",
)
# Act
result = api_provider.tools
# Assert
assert len(result) == 2
assert result[0].operation_id == "getWeather"
assert result[1].operation_id == "getLocation"
def test_api_tool_provider_credentials_property(self):
"""Test credentials property parses JSON correctly."""
# Arrange
credentials_data = {
"auth_type": "api_key_header",
"api_key_header": "Authorization",
"api_key_value": "Bearer test_token",
"api_key_header_prefix": "bearer",
}
api_provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
name="Secure API",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="Secure API",
tools_str="[]",
credentials_str=json.dumps(credentials_data),
)
# Act
result = api_provider.credentials
# Assert
assert result["auth_type"] == "api_key_header"
assert result["api_key_header"] == "Authorization"
assert result["api_key_value"] == "Bearer test_token"
def test_api_tool_provider_with_privacy_policy(self):
"""Test API tool provider with privacy policy."""
# Arrange
privacy_policy_url = "https://example.com/privacy"
# Act
api_provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
name="Privacy API",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="API with privacy policy",
tools_str="[]",
credentials_str="{}",
privacy_policy=privacy_policy_url,
)
# Assert
assert api_provider.privacy_policy == privacy_policy_url
def test_api_tool_provider_with_custom_disclaimer(self):
"""Test API tool provider with custom disclaimer."""
# Arrange
disclaimer = "This API is provided as-is without warranty."
# Act
api_provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
name="Disclaimer API",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="API with disclaimer",
tools_str="[]",
credentials_str="{}",
custom_disclaimer=disclaimer,
)
# Assert
assert api_provider.custom_disclaimer == disclaimer
def test_api_tool_provider_default_custom_disclaimer(self):
"""Test API tool provider default custom_disclaimer is empty string."""
# Arrange & Act
api_provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
name="Default API",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="API",
tools_str="[]",
credentials_str="{}",
)
# Assert
assert api_provider.custom_disclaimer == ""
def test_api_tool_provider_unique_constraint_fields(self):
"""Test unique constraint fields (name, tenant_id)."""
# Arrange
tenant_id = str(uuid4())
provider_name = "Unique API"
# Act
api_provider = ApiToolProvider(
tenant_id=tenant_id,
user_id=str(uuid4()),
name=provider_name,
icon="{}",
schema="{}",
schema_type_str="openapi",
description="Unique API",
tools_str="[]",
credentials_str="{}",
)
# Assert - these fields form unique constraint
assert api_provider.tenant_id == tenant_id
assert api_provider.name == provider_name
def test_api_tool_provider_with_no_auth(self):
"""Test API tool provider with no authentication."""
# Arrange
credentials = {"auth_type": "none"}
# Act
api_provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
name="Public API",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="Public API with no auth",
tools_str="[]",
credentials_str=json.dumps(credentials),
)
# Assert
assert api_provider.credentials["auth_type"] == "none"
def test_api_tool_provider_with_api_key_query_auth(self):
"""Test API tool provider with API key in query parameter."""
# Arrange
credentials = {
"auth_type": "api_key_query",
"api_key_query_param": "apikey",
"api_key_value": "my_secret_key",
}
# Act
api_provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
name="Query Auth API",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="API with query auth",
tools_str="[]",
credentials_str=json.dumps(credentials),
)
# Assert
assert api_provider.credentials["auth_type"] == "api_key_query"
assert api_provider.credentials["api_key_query_param"] == "apikey"
class TestToolOAuthModels:
"""Test suite for OAuth client models (system and tenant level)."""
def test_oauth_system_client_creation(self):
"""Test creating a system-level OAuth client."""
# Arrange
plugin_id = "builtin.google"
provider = "google"
oauth_params = json.dumps(
{"client_id": "system_client_id", "client_secret": "system_secret", "scope": "email profile"}
)
# Act
oauth_client = ToolOAuthSystemClient(
plugin_id=plugin_id,
provider=provider,
encrypted_oauth_params=oauth_params,
)
# Assert
assert oauth_client.plugin_id == plugin_id
assert oauth_client.provider == provider
assert oauth_client.encrypted_oauth_params == oauth_params
def test_oauth_system_client_unique_constraint(self):
"""Test unique constraint on plugin_id and provider."""
# Arrange
plugin_id = "builtin.github"
provider = "github"
# Act
oauth_client = ToolOAuthSystemClient(
plugin_id=plugin_id,
provider=provider,
encrypted_oauth_params="{}",
)
# Assert - these fields form unique constraint
assert oauth_client.plugin_id == plugin_id
assert oauth_client.provider == provider
def test_oauth_tenant_client_creation(self):
"""Test creating a tenant-level OAuth client."""
# Arrange
tenant_id = str(uuid4())
plugin_id = "builtin.google"
provider = "google"
# Act
oauth_client = ToolOAuthTenantClient(
tenant_id=tenant_id,
plugin_id=plugin_id,
provider=provider,
)
# Set encrypted_oauth_params after creation (it has init=False)
oauth_params = json.dumps({"client_id": "tenant_client_id", "client_secret": "tenant_secret"})
oauth_client.encrypted_oauth_params = oauth_params
# Assert
assert oauth_client.tenant_id == tenant_id
assert oauth_client.plugin_id == plugin_id
assert oauth_client.provider == provider
def test_oauth_tenant_client_enabled_default(self):
"""Test OAuth tenant client enabled flag has init=False and uses server default."""
# Arrange & Act
oauth_client = ToolOAuthTenantClient(
tenant_id=str(uuid4()),
plugin_id="builtin.slack",
provider="slack",
)
# Assert - enabled has init=False, so it won't be set until saved to DB
# We can manually set it to test the field exists
oauth_client.enabled = True
assert oauth_client.enabled is True
def test_oauth_tenant_client_oauth_params_property(self):
"""Test oauth_params property parses JSON correctly."""
# Arrange
params_data = {
"client_id": "test_client_123",
"client_secret": "secret_456",
"redirect_uri": "https://app.example.com/callback",
}
oauth_client = ToolOAuthTenantClient(
tenant_id=str(uuid4()),
plugin_id="builtin.dropbox",
provider="dropbox",
)
# Set encrypted_oauth_params after creation (it has init=False)
oauth_client.encrypted_oauth_params = json.dumps(params_data)
# Act
result = oauth_client.oauth_params
# Assert
assert result == params_data
assert result["client_id"] == "test_client_123"
assert result["redirect_uri"] == "https://app.example.com/callback"
def test_oauth_tenant_client_oauth_params_empty_when_none(self):
"""Test oauth_params returns empty dict when encrypted_oauth_params is None."""
# Arrange
oauth_client = ToolOAuthTenantClient(
tenant_id=str(uuid4()),
plugin_id="builtin.test",
provider="test",
)
# encrypted_oauth_params has init=False, set it to None
oauth_client.encrypted_oauth_params = None
# Act
result = oauth_client.oauth_params
# Assert
assert result == {}
def test_oauth_tenant_client_disabled_state(self):
"""Test OAuth tenant client can be disabled."""
# Arrange
oauth_client = ToolOAuthTenantClient(
tenant_id=str(uuid4()),
plugin_id="builtin.microsoft",
provider="microsoft",
)
# Act
oauth_client.enabled = False
# Assert
assert oauth_client.enabled is False
class TestToolLabelBinding:
"""Test suite for ToolLabelBinding model."""
def test_tool_label_binding_creation(self):
"""Test creating a tool label binding."""
# Arrange
tool_id = "google.search"
tool_type = "builtin"
label_name = "search"
# Act
label_binding = ToolLabelBinding(
tool_id=tool_id,
tool_type=tool_type,
label_name=label_name,
)
# Assert
assert label_binding.tool_id == tool_id
assert label_binding.tool_type == tool_type
assert label_binding.label_name == label_name
def test_tool_label_binding_unique_constraint(self):
"""Test unique constraint on tool_id and label_name."""
# Arrange
tool_id = "openai.text_generation"
label_name = "text"
# Act
label_binding = ToolLabelBinding(
tool_id=tool_id,
tool_type="builtin",
label_name=label_name,
)
# Assert - these fields form unique constraint
assert label_binding.tool_id == tool_id
assert label_binding.label_name == label_name
def test_tool_label_binding_multiple_labels_same_tool(self):
"""Test multiple labels can be bound to the same tool."""
# Arrange
tool_id = "google.search"
tool_type = "builtin"
# Act
binding1 = ToolLabelBinding(
tool_id=tool_id,
tool_type=tool_type,
label_name="search",
)
binding2 = ToolLabelBinding(
tool_id=tool_id,
tool_type=tool_type,
label_name="productivity",
)
# Assert
assert binding1.tool_id == binding2.tool_id
assert binding1.label_name != binding2.label_name
def test_tool_label_binding_different_tool_types(self):
"""Test label bindings for different tool types."""
# Arrange
tool_types = ["builtin", "api", "workflow"]
# Act & Assert
for tool_type in tool_types:
binding = ToolLabelBinding(
tool_id=f"test_tool_{tool_type}",
tool_type=tool_type,
label_name="test",
)
assert binding.tool_type == tool_type
class TestCredentialStorage:
"""Test suite for credential storage and encryption patterns."""
def test_builtin_provider_credential_storage_format(self):
"""Test builtin provider stores credentials as JSON string."""
# Arrange
credentials = {
"api_key": "sk-test123",
"endpoint": "https://api.example.com",
"timeout": 30,
}
# Act
provider = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="test",
name="Test Provider",
encrypted_credentials=json.dumps(credentials),
)
# Assert
assert isinstance(provider.encrypted_credentials, str)
assert provider.credentials == credentials
def test_api_provider_credential_storage_format(self):
"""Test API provider stores credentials as JSON string."""
# Arrange
credentials = {
"auth_type": "api_key_header",
"api_key_header": "X-API-Key",
"api_key_value": "secret_key_789",
}
# Act
provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
name="Test API",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="Test",
tools_str="[]",
credentials_str=json.dumps(credentials),
)
# Assert
assert isinstance(provider.credentials_str, str)
assert provider.credentials == credentials
def test_builtin_provider_complex_credential_structure(self):
"""Test builtin provider with complex nested credential structure."""
# Arrange
credentials = {
"auth_type": "oauth2",
"oauth_config": {
"access_token": "token123",
"refresh_token": "refresh456",
"expires_in": 3600,
"token_type": "Bearer",
},
"additional_headers": {"X-Custom-Header": "value"},
}
# Act
provider = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="oauth_provider",
name="OAuth Provider",
encrypted_credentials=json.dumps(credentials),
)
# Assert
assert provider.credentials["oauth_config"]["access_token"] == "token123"
assert provider.credentials["additional_headers"]["X-Custom-Header"] == "value"
def test_api_provider_credential_update_pattern(self):
"""Test pattern for updating API provider credentials."""
# Arrange
original_credentials = {"auth_type": "api_key_header", "api_key_value": "old_key"}
provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
name="Update Test",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="Test",
tools_str="[]",
credentials_str=json.dumps(original_credentials),
)
# Act - simulate credential update
new_credentials = {"auth_type": "api_key_header", "api_key_value": "new_key"}
provider.credentials_str = json.dumps(new_credentials)
# Assert
assert provider.credentials["api_key_value"] == "new_key"
def test_builtin_provider_credential_expiration(self):
"""Test builtin provider credential expiration tracking."""
# Arrange
future_timestamp = 1735689600 # Future date
past_timestamp = 1609459200 # Past date
# Act
active_provider = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="active",
name="Active Provider",
expires_at=future_timestamp,
)
expired_provider = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="expired",
name="Expired Provider",
expires_at=past_timestamp,
)
never_expires_provider = BuiltinToolProvider(
tenant_id=str(uuid4()),
user_id=str(uuid4()),
provider="permanent",
name="Permanent Provider",
expires_at=-1,
)
# Assert
assert active_provider.expires_at == future_timestamp
assert expired_provider.expires_at == past_timestamp
assert never_expires_provider.expires_at == -1
def test_oauth_client_credential_storage(self):
"""Test OAuth client credential storage pattern."""
# Arrange
oauth_credentials = {
"client_id": "oauth_client_123",
"client_secret": "oauth_secret_456",
"authorization_url": "https://oauth.example.com/authorize",
"token_url": "https://oauth.example.com/token",
"scope": "read write",
}
# Act
system_client = ToolOAuthSystemClient(
plugin_id="builtin.oauth_test",
provider="oauth_test",
encrypted_oauth_params=json.dumps(oauth_credentials),
)
tenant_client = ToolOAuthTenantClient(
tenant_id=str(uuid4()),
plugin_id="builtin.oauth_test",
provider="oauth_test",
)
# Set encrypted_oauth_params after creation (it has init=False)
tenant_client.encrypted_oauth_params = json.dumps(oauth_credentials)
# Assert
assert system_client.encrypted_oauth_params == json.dumps(oauth_credentials)
assert tenant_client.oauth_params == oauth_credentials
class TestToolProviderRelationships:
"""Test suite for tool provider relationships and associations."""
def test_builtin_provider_tenant_relationship(self):
"""Test builtin provider belongs to a tenant."""
# Arrange
tenant_id = str(uuid4())
# Act
provider = BuiltinToolProvider(
tenant_id=tenant_id,
user_id=str(uuid4()),
provider="test",
name="Test Provider",
)
# Assert
assert provider.tenant_id == tenant_id
def test_api_provider_user_relationship(self):
"""Test API provider belongs to a user."""
# Arrange
user_id = str(uuid4())
# Act
provider = ApiToolProvider(
tenant_id=str(uuid4()),
user_id=user_id,
name="User API",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="Test",
tools_str="[]",
credentials_str="{}",
)
# Assert
assert provider.user_id == user_id
def test_multiple_providers_same_tenant(self):
"""Test multiple providers can belong to the same tenant."""
# Arrange
tenant_id = str(uuid4())
user_id = str(uuid4())
# Act
builtin1 = BuiltinToolProvider(
tenant_id=tenant_id,
user_id=user_id,
provider="google",
name="Google Key 1",
)
builtin2 = BuiltinToolProvider(
tenant_id=tenant_id,
user_id=user_id,
provider="openai",
name="OpenAI Key 1",
)
api1 = ApiToolProvider(
tenant_id=tenant_id,
user_id=user_id,
name="Custom API 1",
icon="{}",
schema="{}",
schema_type_str="openapi",
description="Test",
tools_str="[]",
credentials_str="{}",
)
# Assert
assert builtin1.tenant_id == tenant_id
assert builtin2.tenant_id == tenant_id
assert api1.tenant_id == tenant_id
def test_tool_label_bindings_for_provider_tools(self):
"""Test tool label bindings can be associated with provider tools."""
# Arrange
provider_name = "google"
tool_id = f"{provider_name}.search"
# Act
binding1 = ToolLabelBinding(
tool_id=tool_id,
tool_type="builtin",
label_name="search",
)
binding2 = ToolLabelBinding(
tool_id=tool_id,
tool_type="builtin",
label_name="web",
)
# Assert
assert binding1.tool_id == tool_id
assert binding2.tool_id == tool_id
assert binding1.label_name != binding2.label_name
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,106 +0,0 @@
from unittest.mock import patch
import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from models.model import AppMode
from services.app_task_service import AppTaskService
class TestAppTaskService:
"""Test suite for AppTaskService.stop_task method."""
@pytest.mark.parametrize(
("app_mode", "should_call_graph_engine"),
[
(AppMode.CHAT, False),
(AppMode.COMPLETION, False),
(AppMode.AGENT_CHAT, False),
(AppMode.CHANNEL, False),
(AppMode.RAG_PIPELINE, False),
(AppMode.ADVANCED_CHAT, True),
(AppMode.WORKFLOW, True),
],
)
@patch("services.app_task_service.AppQueueManager")
@patch("services.app_task_service.GraphEngineManager")
def test_stop_task_with_different_app_modes(
self, mock_graph_engine_manager, mock_app_queue_manager, app_mode, should_call_graph_engine
):
"""Test stop_task behavior with different app modes.
Verifies that:
- Legacy Redis flag is always set via AppQueueManager
- GraphEngine stop command is only sent for ADVANCED_CHAT and WORKFLOW modes
"""
# Arrange
task_id = "task-123"
invoke_from = InvokeFrom.WEB_APP
user_id = "user-456"
# Act
AppTaskService.stop_task(task_id, invoke_from, user_id, app_mode)
# Assert
mock_app_queue_manager.set_stop_flag.assert_called_once_with(task_id, invoke_from, user_id)
if should_call_graph_engine:
mock_graph_engine_manager.send_stop_command.assert_called_once_with(task_id)
else:
mock_graph_engine_manager.send_stop_command.assert_not_called()
@pytest.mark.parametrize(
"invoke_from",
[
InvokeFrom.WEB_APP,
InvokeFrom.SERVICE_API,
InvokeFrom.DEBUGGER,
InvokeFrom.EXPLORE,
],
)
@patch("services.app_task_service.AppQueueManager")
@patch("services.app_task_service.GraphEngineManager")
def test_stop_task_with_different_invoke_sources(
self, mock_graph_engine_manager, mock_app_queue_manager, invoke_from
):
"""Test stop_task behavior with different invoke sources.
Verifies that the method works correctly regardless of the invoke source.
"""
# Arrange
task_id = "task-789"
user_id = "user-999"
app_mode = AppMode.ADVANCED_CHAT
# Act
AppTaskService.stop_task(task_id, invoke_from, user_id, app_mode)
# Assert
mock_app_queue_manager.set_stop_flag.assert_called_once_with(task_id, invoke_from, user_id)
mock_graph_engine_manager.send_stop_command.assert_called_once_with(task_id)
@patch("services.app_task_service.GraphEngineManager")
@patch("services.app_task_service.AppQueueManager")
def test_stop_task_legacy_mechanism_called_even_if_graph_engine_fails(
self, mock_app_queue_manager, mock_graph_engine_manager
):
"""Test that legacy Redis flag is set even if GraphEngine fails.
This ensures backward compatibility: the legacy mechanism should complete
before attempting the GraphEngine command, so the stop flag is set
regardless of GraphEngine success.
"""
# Arrange
task_id = "task-123"
invoke_from = InvokeFrom.WEB_APP
user_id = "user-456"
app_mode = AppMode.ADVANCED_CHAT
# Simulate GraphEngine failure
mock_graph_engine_manager.send_stop_command.side_effect = Exception("GraphEngine error")
# Act & Assert - should raise the exception since it's not caught
with pytest.raises(Exception, match="GraphEngine error"):
AppTaskService.stop_task(task_id, invoke_from, user_id, app_mode)
# Verify legacy mechanism was still called before the exception
mock_app_queue_manager.set_stop_flag.assert_called_once_with(task_id, invoke_from, user_id)

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