From fcf04629d3609f606bbdbad80790c7b90489a06a Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:01:17 +0800 Subject: [PATCH 01/30] fix(ci): restore i18n dispatch bridge (#34331) --- .github/workflows/translate-i18n-claude.yml | 54 +++++++++----- .github/workflows/trigger-i18n-sync.yml | 81 +++++++++++++++++++++ 2 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/trigger-i18n-sync.yml diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index aaf51aa606..f3fbfe60e2 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -1,10 +1,10 @@ name: Translate i18n Files with Claude Code +# Note: claude-code-action doesn't support push events directly. +# Push events are bridged by trigger-i18n-sync.yml via repository_dispatch. on: - push: - branches: [main] - paths: - - 'web/i18n/en-US/*.json' + repository_dispatch: + types: [i18n-sync] workflow_dispatch: inputs: files: @@ -30,7 +30,7 @@ permissions: concurrency: group: translate-i18n-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'push' }} + cancel-in-progress: false jobs: translate: @@ -67,19 +67,20 @@ jobs: } " web/i18n-config/languages.ts | sed 's/[[:space:]]*$//') - if [ "${{ github.event_name }}" = "push" ]; then - BASE_SHA="${{ github.event.before }}" - if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then - BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || true) - fi - HEAD_SHA="${{ github.sha }}" - if [ -n "$BASE_SHA" ]; then - CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//') - else - CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//') - fi + if [ "${{ github.event_name }}" = "repository_dispatch" ]; then + BASE_SHA="${{ github.event.client_payload.base_sha }}" + HEAD_SHA="${{ github.event.client_payload.head_sha }}" + CHANGED_FILES="${{ github.event.client_payload.changed_files }}" TARGET_LANGS="$DEFAULT_TARGET_LANGS" - SYNC_MODE="incremental" + SYNC_MODE="${{ github.event.client_payload.sync_mode || 'incremental' }}" + + if [ -n "${{ github.event.client_payload.diff_base64 }}" ]; then + printf '%s' '${{ github.event.client_payload.diff_base64 }}' | base64 -d > /tmp/i18n-diff.txt + DIFF_AVAILABLE="true" + else + : > /tmp/i18n-diff.txt + DIFF_AVAILABLE="false" + fi else BASE_SHA="" HEAD_SHA=$(git rev-parse HEAD) @@ -104,6 +105,18 @@ jobs: else CHANGED_FILES="" fi + + if [ "$SYNC_MODE" = "incremental" ] && [ -n "$BASE_SHA" ]; then + git diff "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || : > /tmp/i18n-diff.txt + else + : > /tmp/i18n-diff.txt + fi + + if [ -s /tmp/i18n-diff.txt ]; then + DIFF_AVAILABLE="true" + else + DIFF_AVAILABLE="false" + fi fi FILE_ARGS="" @@ -123,6 +136,7 @@ jobs: echo "CHANGED_FILES=$CHANGED_FILES" echo "TARGET_LANGS=$TARGET_LANGS" echo "SYNC_MODE=$SYNC_MODE" + echo "DIFF_AVAILABLE=$DIFF_AVAILABLE" echo "FILE_ARGS=$FILE_ARGS" echo "LANG_ARGS=$LANG_ARGS" } >> "$GITHUB_OUTPUT" @@ -156,6 +170,7 @@ jobs: - Head SHA: `${{ steps.context.outputs.HEAD_SHA }}` - Scoped file args: `${{ steps.context.outputs.FILE_ARGS }}` - Scoped language args: `${{ steps.context.outputs.LANG_ARGS }}` + - Full English diff available: `${{ steps.context.outputs.DIFF_AVAILABLE }}` Tool rules: - Use Read for repository files. @@ -173,6 +188,9 @@ jobs: - Do not touch unrelated i18n files. - Do not modify `${{ github.workspace }}/web/i18n/en-US/`. 3. Detect English changes per file. + - Treat the current English JSON files under `${{ github.workspace }}/web/i18n/en-US/` plus the scoped `i18n:check` result as the primary source of truth. + - Use `/tmp/i18n-diff.txt` only as supporting context to understand what changed between `Base SHA` and `Head SHA`. + - Never rely on diff alone when deciding final keys or values. - Read the current English JSON file for each file in scope. - If sync mode is `incremental` and `Base SHA` is not empty, run: `git -C ${{ github.workspace }} show :web/i18n/en-US/.json` @@ -182,7 +200,7 @@ jobs: - ADD: key only in current - UPDATE: key exists in both and the English value changed - DELETE: key only in previous - - Do not rely on a truncated diff file. + - If `/tmp/i18n-diff.txt` is available, read it before translating so wording changes are grounded in the full English patch, but resolve any ambiguity by trusting the actual English files and scoped checks. 4. Run a scoped pre-check before editing: - `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` - Use this command as the source of truth for missing and extra keys inside the current scope. diff --git a/.github/workflows/trigger-i18n-sync.yml b/.github/workflows/trigger-i18n-sync.yml new file mode 100644 index 0000000000..ee44fbb0c0 --- /dev/null +++ b/.github/workflows/trigger-i18n-sync.yml @@ -0,0 +1,81 @@ +name: Trigger i18n Sync on Push + +on: + push: + branches: [main] + paths: + - 'web/i18n/en-US/*.json' + +permissions: + contents: write + +concurrency: + group: trigger-i18n-sync-${{ github.ref }} + cancel-in-progress: true + +jobs: + trigger: + if: github.repository == 'langgenius/dify' + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Detect changed files and generate full diff + id: detect + shell: bash + run: | + BASE_SHA="${{ github.event.before }}" + if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || true) + fi + HEAD_SHA="${{ github.sha }}" + + if [ -n "$BASE_SHA" ]; then + CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//') + git diff "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || : > /tmp/i18n-diff.txt + else + CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//') + : > /tmp/i18n-diff.txt + fi + + if [ -n "$CHANGED_FILES" ]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + echo "changed_files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" + + - name: Trigger i18n sync workflow + if: steps.detect.outputs.has_changes == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + BASE_SHA: ${{ steps.detect.outputs.base_sha }} + HEAD_SHA: ${{ steps.detect.outputs.head_sha }} + CHANGED_FILES: ${{ steps.detect.outputs.changed_files }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs') + + const diffBase64 = fs.readFileSync('/tmp/i18n-diff.txt').toString('base64') + + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'i18n-sync', + client_payload: { + changed_files: process.env.CHANGED_FILES, + diff_base64: diffBase64, + sync_mode: 'incremental', + base_sha: process.env.BASE_SHA, + head_sha: process.env.HEAD_SHA, + }, + }) From f27d669f87d554378cddfd901777081cc4f8a533 Mon Sep 17 00:00:00 2001 From: 99 Date: Tue, 31 Mar 2026 16:21:22 +0800 Subject: [PATCH 02/30] chore: normalize frozenset literals and myscale typing (#34327) --- api/constants/__init__.py | 37 +- api/controllers/common/file_response.py | 4 +- api/core/helper/csv_sanitizer.py | 2 +- .../jieba/jieba_keyword_table_handler.py | 2 +- .../rag/datasource/keyword/jieba/stopwords.py | 2742 +++++++++-------- .../datasource/vdb/myscale/myscale_vector.py | 2 +- api/core/rag/extractor/pdf_extractor.py | 4 +- api/core/trigger/constants.py | 4 +- .../nodes/trigger_webhook/entities.py | 18 +- api/libs/collection_utils.py | 9 +- api/models/workflow.py | 2 +- .../workflow_draft_variable_service.py | 4 +- .../core/datasource/test_file_upload.py | 24 +- .../keyword/jieba/test_stopwords.py | 1 + .../core/workflow/nodes/llm/test_node.py | 22 +- .../factories/test_variable_factory.py | 2 +- dev/pytest/pytest_config_tests.py | 168 +- 17 files changed, 1536 insertions(+), 1511 deletions(-) diff --git a/api/constants/__init__.py b/api/constants/__init__.py index e441395afc..8698fb855d 100644 --- a/api/constants/__init__.py +++ b/api/constants/__init__.py @@ -7,15 +7,16 @@ UUID_NIL = "00000000-0000-0000-0000-000000000000" DEFAULT_FILE_NUMBER_LIMITS = 3 -IMAGE_EXTENSIONS = convert_to_lower_and_upper_set({"jpg", "jpeg", "png", "webp", "gif", "svg"}) +_IMAGE_EXTENSION_BASE: frozenset[str] = frozenset(("jpg", "jpeg", "png", "webp", "gif", "svg")) +_VIDEO_EXTENSION_BASE: frozenset[str] = frozenset(("mp4", "mov", "mpeg", "webm")) +_AUDIO_EXTENSION_BASE: frozenset[str] = frozenset(("mp3", "m4a", "wav", "amr", "mpga")) -VIDEO_EXTENSIONS = convert_to_lower_and_upper_set({"mp4", "mov", "mpeg", "webm"}) +IMAGE_EXTENSIONS: frozenset[str] = frozenset(convert_to_lower_and_upper_set(_IMAGE_EXTENSION_BASE)) +VIDEO_EXTENSIONS: frozenset[str] = frozenset(convert_to_lower_and_upper_set(_VIDEO_EXTENSION_BASE)) +AUDIO_EXTENSIONS: frozenset[str] = frozenset(convert_to_lower_and_upper_set(_AUDIO_EXTENSION_BASE)) -AUDIO_EXTENSIONS = convert_to_lower_and_upper_set({"mp3", "m4a", "wav", "amr", "mpga"}) - -_doc_extensions: set[str] -if dify_config.ETL_TYPE == "Unstructured": - _doc_extensions = { +_UNSTRUCTURED_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset( + ( "txt", "markdown", "md", @@ -35,11 +36,10 @@ if dify_config.ETL_TYPE == "Unstructured": "pptx", "xml", "epub", - } - if dify_config.UNSTRUCTURED_API_URL: - _doc_extensions.add("ppt") -else: - _doc_extensions = { + ) +) +_DEFAULT_DOCUMENT_EXTENSION_BASE: frozenset[str] = frozenset( + ( "txt", "markdown", "md", @@ -53,8 +53,17 @@ else: "csv", "vtt", "properties", - } -DOCUMENT_EXTENSIONS: set[str] = convert_to_lower_and_upper_set(_doc_extensions) + ) +) + +_doc_extensions: set[str] +if dify_config.ETL_TYPE == "Unstructured": + _doc_extensions = set(_UNSTRUCTURED_DOCUMENT_EXTENSION_BASE) + if dify_config.UNSTRUCTURED_API_URL: + _doc_extensions.add("ppt") +else: + _doc_extensions = set(_DEFAULT_DOCUMENT_EXTENSION_BASE) +DOCUMENT_EXTENSIONS: frozenset[str] = frozenset(convert_to_lower_and_upper_set(_doc_extensions)) # console COOKIE_NAME_ACCESS_TOKEN = "access_token" diff --git a/api/controllers/common/file_response.py b/api/controllers/common/file_response.py index ca8ea3d52e..79df978012 100644 --- a/api/controllers/common/file_response.py +++ b/api/controllers/common/file_response.py @@ -4,8 +4,8 @@ from urllib.parse import quote from flask import Response -HTML_MIME_TYPES = frozenset({"text/html", "application/xhtml+xml"}) -HTML_EXTENSIONS = frozenset({"html", "htm"}) +HTML_MIME_TYPES: frozenset[str] = frozenset(("text/html", "application/xhtml+xml")) +HTML_EXTENSIONS: frozenset[str] = frozenset(("html", "htm")) def _normalize_mime_type(mime_type: str | None) -> str: diff --git a/api/core/helper/csv_sanitizer.py b/api/core/helper/csv_sanitizer.py index 0023de5a35..c4fa230b3b 100644 --- a/api/core/helper/csv_sanitizer.py +++ b/api/core/helper/csv_sanitizer.py @@ -17,7 +17,7 @@ class CSVSanitizer: """ # Characters that can start a formula in Excel/LibreOffice/Google Sheets - FORMULA_CHARS = frozenset({"=", "+", "-", "@", "\t", "\r"}) + FORMULA_CHARS = frozenset(("=", "+", "-", "@", "\t", "\r")) @classmethod def sanitize_value(cls, value: Any) -> str: diff --git a/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py b/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py index 57a60e6970..84f35c25f8 100644 --- a/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py +++ b/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py @@ -122,6 +122,6 @@ class JiebaKeywordTableHandler: results.add(token) sub_tokens = re.findall(r"\w+", token) if len(sub_tokens) > 1: - results.update({w for w in sub_tokens if w not in list(STOPWORDS)}) + results.update({w for w in sub_tokens if w not in STOPWORDS}) return results diff --git a/api/core/rag/datasource/keyword/jieba/stopwords.py b/api/core/rag/datasource/keyword/jieba/stopwords.py index 54b65d9a2d..78ed1cf594 100644 --- a/api/core/rag/datasource/keyword/jieba/stopwords.py +++ b/api/core/rag/datasource/keyword/jieba/stopwords.py @@ -1,1370 +1,1372 @@ -STOPWORDS = { - "during", - "when", - "but", - "then", - "further", - "isn", - "mustn't", - "until", - "own", - "i", - "couldn", - "y", - "only", - "you've", - "ours", - "who", - "where", - "ourselves", - "has", - "to", - "was", - "didn't", - "themselves", - "if", - "against", - "through", - "her", - "an", - "your", - "can", - "those", - "didn", - "about", - "aren't", - "shan't", - "be", - "not", - "these", - "again", - "so", - "t", - "theirs", - "weren", - "won't", - "won", - "itself", - "just", - "same", - "while", - "why", - "doesn", - "aren", - "him", - "haven", - "for", - "you'll", - "that", - "we", - "am", - "d", - "by", - "having", - "wasn't", - "than", - "weren't", - "out", - "from", - "now", - "their", - "too", - "hadn", - "o", - "needn", - "most", - "it", - "under", - "needn't", - "any", - "some", - "few", - "ll", - "hers", - "which", - "m", - "you're", - "off", - "other", - "had", - "she", - "you'd", - "do", - "you", - "does", - "s", - "will", - "each", - "wouldn't", - "hasn't", - "such", - "more", - "whom", - "she's", - "my", - "yours", - "yourself", - "of", - "on", - "very", - "hadn't", - "with", - "yourselves", - "been", - "ma", - "them", - "mightn't", - "shan", - "mustn", - "they", - "what", - "both", - "that'll", - "how", - "is", - "he", - "because", - "down", - "haven't", - "are", - "no", - "it's", - "our", - "being", - "the", - "or", - "above", - "myself", - "once", - "don't", - "doesn't", - "as", - "nor", - "here", - "herself", - "hasn", - "mightn", - "have", - "its", - "all", - "were", - "ain", - "this", - "at", - "after", - "over", - "shouldn't", - "into", - "before", - "don", - "wouldn", - "re", - "couldn't", - "wasn", - "in", - "should", - "there", - "himself", - "isn't", - "should've", - "doing", - "ve", - "shouldn", - "a", - "did", - "and", - "his", - "between", - "me", - "up", - "below", - "人民", - "末##末", - "啊", - "阿", - "哎", - "哎呀", - "哎哟", - "唉", - "俺", - "俺们", - "按", - "按照", - "吧", - "吧哒", - "把", - "罢了", - "被", - "本", - "本着", - "比", - "比方", - "比如", - "鄙人", - "彼", - "彼此", - "边", - "别", - "别的", - "别说", - "并", - "并且", - "不比", - "不成", - "不单", - "不但", - "不独", - "不管", - "不光", - "不过", - "不仅", - "不拘", - "不论", - "不怕", - "不然", - "不如", - "不特", - "不惟", - "不问", - "不只", - "朝", - "朝着", - "趁", - "趁着", - "乘", - "冲", - "除", - "除此之外", - "除非", - "除了", - "此", - "此间", - "此外", - "从", - "从而", - "打", - "待", - "但", - "但是", - "当", - "当着", - "到", - "得", - "的", - "的话", - "等", - "等等", - "地", - "第", - "叮咚", - "对", - "对于", - "多", - "多少", - "而", - "而况", - "而且", - "而是", - "而外", - "而言", - "而已", - "尔后", - "反过来", - "反过来说", - "反之", - "非但", - "非徒", - "否则", - "嘎", - "嘎登", - "该", - "赶", - "个", - "各", - "各个", - "各位", - "各种", - "各自", - "给", - "根据", - "跟", - "故", - "故此", - "固然", - "关于", - "管", - "归", - "果然", - "果真", - "过", - "哈", - "哈哈", - "呵", - "和", - "何", - "何处", - "何况", - "何时", - "嘿", - "哼", - "哼唷", - "呼哧", - "乎", - "哗", - "还是", - "还有", - "换句话说", - "换言之", - "或", - "或是", - "或者", - "极了", - "及", - "及其", - "及至", - "即", - "即便", - "即或", - "即令", - "即若", - "即使", - "几", - "几时", - "己", - "既", - "既然", - "既是", - "继而", - "加之", - "假如", - "假若", - "假使", - "鉴于", - "将", - "较", - "较之", - "叫", - "接着", - "结果", - "借", - "紧接着", - "进而", - "尽", - "尽管", - "经", - "经过", - "就", - "就是", - "就是说", - "据", - "具体地说", - "具体说来", - "开始", - "开外", - "靠", - "咳", - "可", - "可见", - "可是", - "可以", - "况且", - "啦", - "来", - "来着", - "离", - "例如", - "哩", - "连", - "连同", - "两者", - "了", - "临", - "另", - "另外", - "另一方面", - "论", - "嘛", - "吗", - "慢说", - "漫说", - "冒", - "么", - "每", - "每当", - "们", - "莫若", - "某", - "某个", - "某些", - "拿", - "哪", - "哪边", - "哪儿", - "哪个", - "哪里", - "哪年", - "哪怕", - "哪天", - "哪些", - "哪样", - "那", - "那边", - "那儿", - "那个", - "那会儿", - "那里", - "那么", - "那么些", - "那么样", - "那时", - "那些", - "那样", - "乃", - "乃至", - "呢", - "能", - "你", - "你们", - "您", - "宁", - "宁可", - "宁肯", - "宁愿", - "哦", - "呕", - "啪达", - "旁人", - "呸", - "凭", - "凭借", - "其", - "其次", - "其二", - "其他", - "其它", - "其一", - "其余", - "其中", - "起", - "起见", - "岂但", - "恰恰相反", - "前后", - "前者", - "且", - "然而", - "然后", - "然则", - "让", - "人家", - "任", - "任何", - "任凭", - "如", - "如此", - "如果", - "如何", - "如其", - "如若", - "如上所述", - "若", - "若非", - "若是", - "啥", - "上下", - "尚且", - "设若", - "设使", - "甚而", - "甚么", - "甚至", - "省得", - "时候", - "什么", - "什么样", - "使得", - "是", - "是的", - "首先", - "谁", - "谁知", - "顺", - "顺着", - "似的", - "虽", - "虽然", - "虽说", - "虽则", - "随", - "随着", - "所", - "所以", - "他", - "他们", - "他人", - "它", - "它们", - "她", - "她们", - "倘", - "倘或", - "倘然", - "倘若", - "倘使", - "腾", - "替", - "通过", - "同", - "同时", - "哇", - "万一", - "往", - "望", - "为", - "为何", - "为了", - "为什么", - "为着", - "喂", - "嗡嗡", - "我", - "我们", - "呜", - "呜呼", - "乌乎", - "无论", - "无宁", - "毋宁", - "嘻", - "吓", - "相对而言", - "像", - "向", - "向着", - "嘘", - "呀", - "焉", - "沿", - "沿着", - "要", - "要不", - "要不然", - "要不是", - "要么", - "要是", - "也", - "也罢", - "也好", - "一", - "一般", - "一旦", - "一方面", - "一来", - "一切", - "一样", - "一则", - "依", - "依照", - "矣", - "以", - "以便", - "以及", - "以免", - "以至", - "以至于", - "以致", - "抑或", - "因", - "因此", - "因而", - "因为", - "哟", - "用", - "由", - "由此可见", - "由于", - "有", - "有的", - "有关", - "有些", - "又", - "于", - "于是", - "于是乎", - "与", - "与此同时", - "与否", - "与其", - "越是", - "云云", - "哉", - "再说", - "再者", - "在", - "在下", - "咱", - "咱们", - "则", - "怎", - "怎么", - "怎么办", - "怎么样", - "怎样", - "咋", - "照", - "照着", - "者", - "这", - "这边", - "这儿", - "这个", - "这会儿", - "这就是说", - "这里", - "这么", - "这么点儿", - "这么些", - "这么样", - "这时", - "这些", - "这样", - "正如", - "吱", - "之", - "之类", - "之所以", - "之一", - "只是", - "只限", - "只要", - "只有", - "至", - "至于", - "诸位", - "着", - "着呢", - "自", - "自从", - "自个儿", - "自各儿", - "自己", - "自家", - "自身", - "综上所述", - "总的来看", - "总的来说", - "总的说来", - "总而言之", - "总之", - "纵", - "纵令", - "纵然", - "纵使", - "遵照", - "作为", - "兮", - "呃", - "呗", - "咚", - "咦", - "喏", - "啐", - "喔唷", - "嗬", - "嗯", - "嗳", - "~", - "!", - ".", - ":", - '"', - "'", - "(", - ")", - "*", - "A", - "白", - "社会主义", - "--", - "..", - ">>", - " [", - " ]", - "", - "<", - ">", - "/", - "\\", - "|", - "-", - "_", - "+", - "=", - "&", - "^", - "%", - "#", - "@", - "`", - ";", - "$", - "(", - ")", - "——", - "—", - "¥", - "·", - "...", - "‘", - "’", - "〉", - "〈", - "…", - " ", - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "二", - "三", - "四", - "五", - "六", - "七", - "八", - "九", - "零", - ">", - "<", - "@", - "#", - "$", - "%", - "︿", - "&", - "*", - "+", - "~", - "|", - "[", - "]", - "{", - "}", - "啊哈", - "啊呀", - "啊哟", - "挨次", - "挨个", - "挨家挨户", - "挨门挨户", - "挨门逐户", - "挨着", - "按理", - "按期", - "按时", - "按说", - "暗地里", - "暗中", - "暗自", - "昂然", - "八成", - "白白", - "半", - "梆", - "保管", - "保险", - "饱", - "背地里", - "背靠背", - "倍感", - "倍加", - "本人", - "本身", - "甭", - "比起", - "比如说", - "比照", - "毕竟", - "必", - "必定", - "必将", - "必须", - "便", - "别人", - "并非", - "并肩", - "并没", - "并没有", - "并排", - "并无", - "勃然", - "不", - "不必", - "不常", - "不大", - "不但...而且", - "不得", - "不得不", - "不得了", - "不得已", - "不迭", - "不定", - "不对", - "不妨", - "不管怎样", - "不会", - "不仅...而且", - "不仅仅", - "不仅仅是", - "不经意", - "不可开交", - "不可抗拒", - "不力", - "不了", - "不料", - "不满", - "不免", - "不能不", - "不起", - "不巧", - "不然的话", - "不日", - "不少", - "不胜", - "不时", - "不是", - "不同", - "不能", - "不要", - "不外", - "不外乎", - "不下", - "不限", - "不消", - "不已", - "不亦乐乎", - "不由得", - "不再", - "不择手段", - "不怎么", - "不曾", - "不知不觉", - "不止", - "不止一次", - "不至于", - "才", - "才能", - "策略地", - "差不多", - "差一点", - "常", - "常常", - "常言道", - "常言说", - "常言说得好", - "长此下去", - "长话短说", - "长期以来", - "长线", - "敞开儿", - "彻夜", - "陈年", - "趁便", - "趁机", - "趁热", - "趁势", - "趁早", - "成年", - "成年累月", - "成心", - "乘机", - "乘胜", - "乘势", - "乘隙", - "乘虚", - "诚然", - "迟早", - "充分", - "充其极", - "充其量", - "抽冷子", - "臭", - "初", - "出", - "出来", - "出去", - "除此", - "除此而外", - "除此以外", - "除开", - "除去", - "除却", - "除外", - "处处", - "川流不息", - "传", - "传说", - "传闻", - "串行", - "纯", - "纯粹", - "此后", - "此中", - "次第", - "匆匆", - "从不", - "从此", - "从此以后", - "从古到今", - "从古至今", - "从今以后", - "从宽", - "从来", - "从轻", - "从速", - "从头", - "从未", - "从无到有", - "从小", - "从新", - "从严", - "从优", - "从早到晚", - "从中", - "从重", - "凑巧", - "粗", - "存心", - "达旦", - "打从", - "打开天窗说亮话", - "大", - "大不了", - "大大", - "大抵", - "大都", - "大多", - "大凡", - "大概", - "大家", - "大举", - "大略", - "大面儿上", - "大事", - "大体", - "大体上", - "大约", - "大张旗鼓", - "大致", - "呆呆地", - "带", - "殆", - "待到", - "单", - "单纯", - "单单", - "但愿", - "弹指之间", - "当场", - "当儿", - "当即", - "当口儿", - "当然", - "当庭", - "当头", - "当下", - "当真", - "当中", - "倒不如", - "倒不如说", - "倒是", - "到处", - "到底", - "到了儿", - "到目前为止", - "到头", - "到头来", - "得起", - "得天独厚", - "的确", - "等到", - "叮当", - "顶多", - "定", - "动不动", - "动辄", - "陡然", - "都", - "独", - "独自", - "断然", - "顿时", - "多次", - "多多", - "多多少少", - "多多益善", - "多亏", - "多年来", - "多年前", - "而后", - "而论", - "而又", - "尔等", - "二话不说", - "二话没说", - "反倒", - "反倒是", - "反而", - "反手", - "反之亦然", - "反之则", - "方", - "方才", - "方能", - "放量", - "非常", - "非得", - "分期", - "分期分批", - "分头", - "奋勇", - "愤然", - "风雨无阻", - "逢", - "弗", - "甫", - "嘎嘎", - "该当", - "概", - "赶快", - "赶早不赶晚", - "敢", - "敢情", - "敢于", - "刚", - "刚才", - "刚好", - "刚巧", - "高低", - "格外", - "隔日", - "隔夜", - "个人", - "各式", - "更", - "更加", - "更进一步", - "更为", - "公然", - "共", - "共总", - "够瞧的", - "姑且", - "古来", - "故而", - "故意", - "固", - "怪", - "怪不得", - "惯常", - "光", - "光是", - "归根到底", - "归根结底", - "过于", - "毫不", - "毫无", - "毫无保留地", - "毫无例外", - "好在", - "何必", - "何尝", - "何妨", - "何苦", - "何乐而不为", - "何须", - "何止", - "很", - "很多", - "很少", - "轰然", - "后来", - "呼啦", - "忽地", - "忽然", - "互", - "互相", - "哗啦", - "话说", - "还", - "恍然", - "会", - "豁然", - "活", - "伙同", - "或多或少", - "或许", - "基本", - "基本上", - "基于", - "极", - "极大", - "极度", - "极端", - "极力", - "极其", - "极为", - "急匆匆", - "即将", - "即刻", - "即是说", - "几度", - "几番", - "几乎", - "几经", - "既...又", - "继之", - "加上", - "加以", - "间或", - "简而言之", - "简言之", - "简直", - "见", - "将才", - "将近", - "将要", - "交口", - "较比", - "较为", - "接连不断", - "接下来", - "皆可", - "截然", - "截至", - "藉以", - "借此", - "借以", - "届时", - "仅", - "仅仅", - "谨", - "进来", - "进去", - "近", - "近几年来", - "近来", - "近年来", - "尽管如此", - "尽可能", - "尽快", - "尽量", - "尽然", - "尽如人意", - "尽心竭力", - "尽心尽力", - "尽早", - "精光", - "经常", - "竟", - "竟然", - "究竟", - "就此", - "就地", - "就算", - "居然", - "局外", - "举凡", - "据称", - "据此", - "据实", - "据说", - "据我所知", - "据悉", - "具体来说", - "决不", - "决非", - "绝", - "绝不", - "绝顶", - "绝对", - "绝非", - "均", - "喀", - "看", - "看来", - "看起来", - "看上去", - "看样子", - "可好", - "可能", - "恐怕", - "快", - "快要", - "来不及", - "来得及", - "来讲", - "来看", - "拦腰", - "牢牢", - "老", - "老大", - "老老实实", - "老是", - "累次", - "累年", - "理当", - "理该", - "理应", - "历", - "立", - "立地", - "立刻", - "立马", - "立时", - "联袂", - "连连", - "连日", - "连日来", - "连声", - "连袂", - "临到", - "另方面", - "另行", - "另一个", - "路经", - "屡", - "屡次", - "屡次三番", - "屡屡", - "缕缕", - "率尔", - "率然", - "略", - "略加", - "略微", - "略为", - "论说", - "马上", - "蛮", - "满", - "没", - "没有", - "每逢", - "每每", - "每时每刻", - "猛然", - "猛然间", - "莫", - "莫不", - "莫非", - "莫如", - "默默地", - "默然", - "呐", - "那末", - "奈", - "难道", - "难得", - "难怪", - "难说", - "内", - "年复一年", - "凝神", - "偶而", - "偶尔", - "怕", - "砰", - "碰巧", - "譬如", - "偏偏", - "乒", - "平素", - "颇", - "迫于", - "扑通", - "其后", - "其实", - "奇", - "齐", - "起初", - "起来", - "起首", - "起头", - "起先", - "岂", - "岂非", - "岂止", - "迄", - "恰逢", - "恰好", - "恰恰", - "恰巧", - "恰如", - "恰似", - "千", - "千万", - "千万千万", - "切", - "切不可", - "切莫", - "切切", - "切勿", - "窃", - "亲口", - "亲身", - "亲手", - "亲眼", - "亲自", - "顷", - "顷刻", - "顷刻间", - "顷刻之间", - "请勿", - "穷年累月", - "取道", - "去", - "权时", - "全都", - "全力", - "全年", - "全然", - "全身心", - "然", - "人人", - "仍", - "仍旧", - "仍然", - "日复一日", - "日见", - "日渐", - "日益", - "日臻", - "如常", - "如此等等", - "如次", - "如今", - "如期", - "如前所述", - "如上", - "如下", - "汝", - "三番两次", - "三番五次", - "三天两头", - "瑟瑟", - "沙沙", - "上", - "上来", - "上去", - "一个", - "月", - "日", - "\n", -} +STOPWORDS: frozenset[str] = frozenset( + ( + "during", + "when", + "but", + "then", + "further", + "isn", + "mustn't", + "until", + "own", + "i", + "couldn", + "y", + "only", + "you've", + "ours", + "who", + "where", + "ourselves", + "has", + "to", + "was", + "didn't", + "themselves", + "if", + "against", + "through", + "her", + "an", + "your", + "can", + "those", + "didn", + "about", + "aren't", + "shan't", + "be", + "not", + "these", + "again", + "so", + "t", + "theirs", + "weren", + "won't", + "won", + "itself", + "just", + "same", + "while", + "why", + "doesn", + "aren", + "him", + "haven", + "for", + "you'll", + "that", + "we", + "am", + "d", + "by", + "having", + "wasn't", + "than", + "weren't", + "out", + "from", + "now", + "their", + "too", + "hadn", + "o", + "needn", + "most", + "it", + "under", + "needn't", + "any", + "some", + "few", + "ll", + "hers", + "which", + "m", + "you're", + "off", + "other", + "had", + "she", + "you'd", + "do", + "you", + "does", + "s", + "will", + "each", + "wouldn't", + "hasn't", + "such", + "more", + "whom", + "she's", + "my", + "yours", + "yourself", + "of", + "on", + "very", + "hadn't", + "with", + "yourselves", + "been", + "ma", + "them", + "mightn't", + "shan", + "mustn", + "they", + "what", + "both", + "that'll", + "how", + "is", + "he", + "because", + "down", + "haven't", + "are", + "no", + "it's", + "our", + "being", + "the", + "or", + "above", + "myself", + "once", + "don't", + "doesn't", + "as", + "nor", + "here", + "herself", + "hasn", + "mightn", + "have", + "its", + "all", + "were", + "ain", + "this", + "at", + "after", + "over", + "shouldn't", + "into", + "before", + "don", + "wouldn", + "re", + "couldn't", + "wasn", + "in", + "should", + "there", + "himself", + "isn't", + "should've", + "doing", + "ve", + "shouldn", + "a", + "did", + "and", + "his", + "between", + "me", + "up", + "below", + "人民", + "末##末", + "啊", + "阿", + "哎", + "哎呀", + "哎哟", + "唉", + "俺", + "俺们", + "按", + "按照", + "吧", + "吧哒", + "把", + "罢了", + "被", + "本", + "本着", + "比", + "比方", + "比如", + "鄙人", + "彼", + "彼此", + "边", + "别", + "别的", + "别说", + "并", + "并且", + "不比", + "不成", + "不单", + "不但", + "不独", + "不管", + "不光", + "不过", + "不仅", + "不拘", + "不论", + "不怕", + "不然", + "不如", + "不特", + "不惟", + "不问", + "不只", + "朝", + "朝着", + "趁", + "趁着", + "乘", + "冲", + "除", + "除此之外", + "除非", + "除了", + "此", + "此间", + "此外", + "从", + "从而", + "打", + "待", + "但", + "但是", + "当", + "当着", + "到", + "得", + "的", + "的话", + "等", + "等等", + "地", + "第", + "叮咚", + "对", + "对于", + "多", + "多少", + "而", + "而况", + "而且", + "而是", + "而外", + "而言", + "而已", + "尔后", + "反过来", + "反过来说", + "反之", + "非但", + "非徒", + "否则", + "嘎", + "嘎登", + "该", + "赶", + "个", + "各", + "各个", + "各位", + "各种", + "各自", + "给", + "根据", + "跟", + "故", + "故此", + "固然", + "关于", + "管", + "归", + "果然", + "果真", + "过", + "哈", + "哈哈", + "呵", + "和", + "何", + "何处", + "何况", + "何时", + "嘿", + "哼", + "哼唷", + "呼哧", + "乎", + "哗", + "还是", + "还有", + "换句话说", + "换言之", + "或", + "或是", + "或者", + "极了", + "及", + "及其", + "及至", + "即", + "即便", + "即或", + "即令", + "即若", + "即使", + "几", + "几时", + "己", + "既", + "既然", + "既是", + "继而", + "加之", + "假如", + "假若", + "假使", + "鉴于", + "将", + "较", + "较之", + "叫", + "接着", + "结果", + "借", + "紧接着", + "进而", + "尽", + "尽管", + "经", + "经过", + "就", + "就是", + "就是说", + "据", + "具体地说", + "具体说来", + "开始", + "开外", + "靠", + "咳", + "可", + "可见", + "可是", + "可以", + "况且", + "啦", + "来", + "来着", + "离", + "例如", + "哩", + "连", + "连同", + "两者", + "了", + "临", + "另", + "另外", + "另一方面", + "论", + "嘛", + "吗", + "慢说", + "漫说", + "冒", + "么", + "每", + "每当", + "们", + "莫若", + "某", + "某个", + "某些", + "拿", + "哪", + "哪边", + "哪儿", + "哪个", + "哪里", + "哪年", + "哪怕", + "哪天", + "哪些", + "哪样", + "那", + "那边", + "那儿", + "那个", + "那会儿", + "那里", + "那么", + "那么些", + "那么样", + "那时", + "那些", + "那样", + "乃", + "乃至", + "呢", + "能", + "你", + "你们", + "您", + "宁", + "宁可", + "宁肯", + "宁愿", + "哦", + "呕", + "啪达", + "旁人", + "呸", + "凭", + "凭借", + "其", + "其次", + "其二", + "其他", + "其它", + "其一", + "其余", + "其中", + "起", + "起见", + "岂但", + "恰恰相反", + "前后", + "前者", + "且", + "然而", + "然后", + "然则", + "让", + "人家", + "任", + "任何", + "任凭", + "如", + "如此", + "如果", + "如何", + "如其", + "如若", + "如上所述", + "若", + "若非", + "若是", + "啥", + "上下", + "尚且", + "设若", + "设使", + "甚而", + "甚么", + "甚至", + "省得", + "时候", + "什么", + "什么样", + "使得", + "是", + "是的", + "首先", + "谁", + "谁知", + "顺", + "顺着", + "似的", + "虽", + "虽然", + "虽说", + "虽则", + "随", + "随着", + "所", + "所以", + "他", + "他们", + "他人", + "它", + "它们", + "她", + "她们", + "倘", + "倘或", + "倘然", + "倘若", + "倘使", + "腾", + "替", + "通过", + "同", + "同时", + "哇", + "万一", + "往", + "望", + "为", + "为何", + "为了", + "为什么", + "为着", + "喂", + "嗡嗡", + "我", + "我们", + "呜", + "呜呼", + "乌乎", + "无论", + "无宁", + "毋宁", + "嘻", + "吓", + "相对而言", + "像", + "向", + "向着", + "嘘", + "呀", + "焉", + "沿", + "沿着", + "要", + "要不", + "要不然", + "要不是", + "要么", + "要是", + "也", + "也罢", + "也好", + "一", + "一般", + "一旦", + "一方面", + "一来", + "一切", + "一样", + "一则", + "依", + "依照", + "矣", + "以", + "以便", + "以及", + "以免", + "以至", + "以至于", + "以致", + "抑或", + "因", + "因此", + "因而", + "因为", + "哟", + "用", + "由", + "由此可见", + "由于", + "有", + "有的", + "有关", + "有些", + "又", + "于", + "于是", + "于是乎", + "与", + "与此同时", + "与否", + "与其", + "越是", + "云云", + "哉", + "再说", + "再者", + "在", + "在下", + "咱", + "咱们", + "则", + "怎", + "怎么", + "怎么办", + "怎么样", + "怎样", + "咋", + "照", + "照着", + "者", + "这", + "这边", + "这儿", + "这个", + "这会儿", + "这就是说", + "这里", + "这么", + "这么点儿", + "这么些", + "这么样", + "这时", + "这些", + "这样", + "正如", + "吱", + "之", + "之类", + "之所以", + "之一", + "只是", + "只限", + "只要", + "只有", + "至", + "至于", + "诸位", + "着", + "着呢", + "自", + "自从", + "自个儿", + "自各儿", + "自己", + "自家", + "自身", + "综上所述", + "总的来看", + "总的来说", + "总的说来", + "总而言之", + "总之", + "纵", + "纵令", + "纵然", + "纵使", + "遵照", + "作为", + "兮", + "呃", + "呗", + "咚", + "咦", + "喏", + "啐", + "喔唷", + "嗬", + "嗯", + "嗳", + "~", + "!", + ".", + ":", + '"', + "'", + "(", + ")", + "*", + "A", + "白", + "社会主义", + "--", + "..", + ">>", + " [", + " ]", + "", + "<", + ">", + "/", + "\\", + "|", + "-", + "_", + "+", + "=", + "&", + "^", + "%", + "#", + "@", + "`", + ";", + "$", + "(", + ")", + "——", + "—", + "¥", + "·", + "...", + "‘", + "’", + "〉", + "〈", + "…", + " ", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "二", + "三", + "四", + "五", + "六", + "七", + "八", + "九", + "零", + ">", + "<", + "@", + "#", + "$", + "%", + "︿", + "&", + "*", + "+", + "~", + "|", + "[", + "]", + "{", + "}", + "啊哈", + "啊呀", + "啊哟", + "挨次", + "挨个", + "挨家挨户", + "挨门挨户", + "挨门逐户", + "挨着", + "按理", + "按期", + "按时", + "按说", + "暗地里", + "暗中", + "暗自", + "昂然", + "八成", + "白白", + "半", + "梆", + "保管", + "保险", + "饱", + "背地里", + "背靠背", + "倍感", + "倍加", + "本人", + "本身", + "甭", + "比起", + "比如说", + "比照", + "毕竟", + "必", + "必定", + "必将", + "必须", + "便", + "别人", + "并非", + "并肩", + "并没", + "并没有", + "并排", + "并无", + "勃然", + "不", + "不必", + "不常", + "不大", + "不但...而且", + "不得", + "不得不", + "不得了", + "不得已", + "不迭", + "不定", + "不对", + "不妨", + "不管怎样", + "不会", + "不仅...而且", + "不仅仅", + "不仅仅是", + "不经意", + "不可开交", + "不可抗拒", + "不力", + "不了", + "不料", + "不满", + "不免", + "不能不", + "不起", + "不巧", + "不然的话", + "不日", + "不少", + "不胜", + "不时", + "不是", + "不同", + "不能", + "不要", + "不外", + "不外乎", + "不下", + "不限", + "不消", + "不已", + "不亦乐乎", + "不由得", + "不再", + "不择手段", + "不怎么", + "不曾", + "不知不觉", + "不止", + "不止一次", + "不至于", + "才", + "才能", + "策略地", + "差不多", + "差一点", + "常", + "常常", + "常言道", + "常言说", + "常言说得好", + "长此下去", + "长话短说", + "长期以来", + "长线", + "敞开儿", + "彻夜", + "陈年", + "趁便", + "趁机", + "趁热", + "趁势", + "趁早", + "成年", + "成年累月", + "成心", + "乘机", + "乘胜", + "乘势", + "乘隙", + "乘虚", + "诚然", + "迟早", + "充分", + "充其极", + "充其量", + "抽冷子", + "臭", + "初", + "出", + "出来", + "出去", + "除此", + "除此而外", + "除此以外", + "除开", + "除去", + "除却", + "除外", + "处处", + "川流不息", + "传", + "传说", + "传闻", + "串行", + "纯", + "纯粹", + "此后", + "此中", + "次第", + "匆匆", + "从不", + "从此", + "从此以后", + "从古到今", + "从古至今", + "从今以后", + "从宽", + "从来", + "从轻", + "从速", + "从头", + "从未", + "从无到有", + "从小", + "从新", + "从严", + "从优", + "从早到晚", + "从中", + "从重", + "凑巧", + "粗", + "存心", + "达旦", + "打从", + "打开天窗说亮话", + "大", + "大不了", + "大大", + "大抵", + "大都", + "大多", + "大凡", + "大概", + "大家", + "大举", + "大略", + "大面儿上", + "大事", + "大体", + "大体上", + "大约", + "大张旗鼓", + "大致", + "呆呆地", + "带", + "殆", + "待到", + "单", + "单纯", + "单单", + "但愿", + "弹指之间", + "当场", + "当儿", + "当即", + "当口儿", + "当然", + "当庭", + "当头", + "当下", + "当真", + "当中", + "倒不如", + "倒不如说", + "倒是", + "到处", + "到底", + "到了儿", + "到目前为止", + "到头", + "到头来", + "得起", + "得天独厚", + "的确", + "等到", + "叮当", + "顶多", + "定", + "动不动", + "动辄", + "陡然", + "都", + "独", + "独自", + "断然", + "顿时", + "多次", + "多多", + "多多少少", + "多多益善", + "多亏", + "多年来", + "多年前", + "而后", + "而论", + "而又", + "尔等", + "二话不说", + "二话没说", + "反倒", + "反倒是", + "反而", + "反手", + "反之亦然", + "反之则", + "方", + "方才", + "方能", + "放量", + "非常", + "非得", + "分期", + "分期分批", + "分头", + "奋勇", + "愤然", + "风雨无阻", + "逢", + "弗", + "甫", + "嘎嘎", + "该当", + "概", + "赶快", + "赶早不赶晚", + "敢", + "敢情", + "敢于", + "刚", + "刚才", + "刚好", + "刚巧", + "高低", + "格外", + "隔日", + "隔夜", + "个人", + "各式", + "更", + "更加", + "更进一步", + "更为", + "公然", + "共", + "共总", + "够瞧的", + "姑且", + "古来", + "故而", + "故意", + "固", + "怪", + "怪不得", + "惯常", + "光", + "光是", + "归根到底", + "归根结底", + "过于", + "毫不", + "毫无", + "毫无保留地", + "毫无例外", + "好在", + "何必", + "何尝", + "何妨", + "何苦", + "何乐而不为", + "何须", + "何止", + "很", + "很多", + "很少", + "轰然", + "后来", + "呼啦", + "忽地", + "忽然", + "互", + "互相", + "哗啦", + "话说", + "还", + "恍然", + "会", + "豁然", + "活", + "伙同", + "或多或少", + "或许", + "基本", + "基本上", + "基于", + "极", + "极大", + "极度", + "极端", + "极力", + "极其", + "极为", + "急匆匆", + "即将", + "即刻", + "即是说", + "几度", + "几番", + "几乎", + "几经", + "既...又", + "继之", + "加上", + "加以", + "间或", + "简而言之", + "简言之", + "简直", + "见", + "将才", + "将近", + "将要", + "交口", + "较比", + "较为", + "接连不断", + "接下来", + "皆可", + "截然", + "截至", + "藉以", + "借此", + "借以", + "届时", + "仅", + "仅仅", + "谨", + "进来", + "进去", + "近", + "近几年来", + "近来", + "近年来", + "尽管如此", + "尽可能", + "尽快", + "尽量", + "尽然", + "尽如人意", + "尽心竭力", + "尽心尽力", + "尽早", + "精光", + "经常", + "竟", + "竟然", + "究竟", + "就此", + "就地", + "就算", + "居然", + "局外", + "举凡", + "据称", + "据此", + "据实", + "据说", + "据我所知", + "据悉", + "具体来说", + "决不", + "决非", + "绝", + "绝不", + "绝顶", + "绝对", + "绝非", + "均", + "喀", + "看", + "看来", + "看起来", + "看上去", + "看样子", + "可好", + "可能", + "恐怕", + "快", + "快要", + "来不及", + "来得及", + "来讲", + "来看", + "拦腰", + "牢牢", + "老", + "老大", + "老老实实", + "老是", + "累次", + "累年", + "理当", + "理该", + "理应", + "历", + "立", + "立地", + "立刻", + "立马", + "立时", + "联袂", + "连连", + "连日", + "连日来", + "连声", + "连袂", + "临到", + "另方面", + "另行", + "另一个", + "路经", + "屡", + "屡次", + "屡次三番", + "屡屡", + "缕缕", + "率尔", + "率然", + "略", + "略加", + "略微", + "略为", + "论说", + "马上", + "蛮", + "满", + "没", + "没有", + "每逢", + "每每", + "每时每刻", + "猛然", + "猛然间", + "莫", + "莫不", + "莫非", + "莫如", + "默默地", + "默然", + "呐", + "那末", + "奈", + "难道", + "难得", + "难怪", + "难说", + "内", + "年复一年", + "凝神", + "偶而", + "偶尔", + "怕", + "砰", + "碰巧", + "譬如", + "偏偏", + "乒", + "平素", + "颇", + "迫于", + "扑通", + "其后", + "其实", + "奇", + "齐", + "起初", + "起来", + "起首", + "起头", + "起先", + "岂", + "岂非", + "岂止", + "迄", + "恰逢", + "恰好", + "恰恰", + "恰巧", + "恰如", + "恰似", + "千", + "千万", + "千万千万", + "切", + "切不可", + "切莫", + "切切", + "切勿", + "窃", + "亲口", + "亲身", + "亲手", + "亲眼", + "亲自", + "顷", + "顷刻", + "顷刻间", + "顷刻之间", + "请勿", + "穷年累月", + "取道", + "去", + "权时", + "全都", + "全力", + "全年", + "全然", + "全身心", + "然", + "人人", + "仍", + "仍旧", + "仍然", + "日复一日", + "日见", + "日渐", + "日益", + "日臻", + "如常", + "如此等等", + "如次", + "如今", + "如期", + "如前所述", + "如上", + "如下", + "汝", + "三番两次", + "三番五次", + "三天两头", + "瑟瑟", + "沙沙", + "上", + "上来", + "上去", + "一个", + "月", + "日", + "\n", + ) +) diff --git a/api/core/rag/datasource/vdb/myscale/myscale_vector.py b/api/core/rag/datasource/vdb/myscale/myscale_vector.py index 17aac25b87..6c62671380 100644 --- a/api/core/rag/datasource/vdb/myscale/myscale_vector.py +++ b/api/core/rag/datasource/vdb/myscale/myscale_vector.py @@ -4,7 +4,7 @@ import uuid from enum import StrEnum from typing import Any -from clickhouse_connect import get_client +from clickhouse_connect import get_client # type: ignore[import-untyped] from pydantic import BaseModel from configs import dify_config diff --git a/api/core/rag/extractor/pdf_extractor.py b/api/core/rag/extractor/pdf_extractor.py index 9abdb31325..02f0efc908 100644 --- a/api/core/rag/extractor/pdf_extractor.py +++ b/api/core/rag/extractor/pdf_extractor.py @@ -35,7 +35,7 @@ class PdfExtractor(BaseExtractor): """ # Magic bytes for image format detection: (magic_bytes, extension, mime_type) - IMAGE_FORMATS = [ + IMAGE_FORMATS: tuple[tuple[bytes, str, str], ...] = ( (b"\xff\xd8\xff", "jpg", "image/jpeg"), (b"\x89PNG\r\n\x1a\n", "png", "image/png"), (b"\x00\x00\x00\x0c\x6a\x50\x20\x20\x0d\x0a\x87\x0a", "jp2", "image/jp2"), @@ -45,7 +45,7 @@ class PdfExtractor(BaseExtractor): (b"MM\x00*", "tiff", "image/tiff"), (b"II+\x00", "tiff", "image/tiff"), (b"MM\x00+", "tiff", "image/tiff"), - ] + ) MAX_MAGIC_LEN = max(len(m) for m, _, _ in IMAGE_FORMATS) def __init__(self, file_path: str, tenant_id: str, user_id: str, file_cache_key: str | None = None): diff --git a/api/core/trigger/constants.py b/api/core/trigger/constants.py index 192faa2d3e..4047e9bc88 100644 --- a/api/core/trigger/constants.py +++ b/api/core/trigger/constants.py @@ -5,11 +5,11 @@ TRIGGER_SCHEDULE_NODE_TYPE: Final[str] = "trigger-schedule" TRIGGER_PLUGIN_NODE_TYPE: Final[str] = "trigger-plugin" TRIGGER_NODE_TYPES: Final[frozenset[str]] = frozenset( - { + ( TRIGGER_WEBHOOK_NODE_TYPE, TRIGGER_SCHEDULE_NODE_TYPE, TRIGGER_PLUGIN_NODE_TYPE, - } + ) ) diff --git a/api/core/workflow/nodes/trigger_webhook/entities.py b/api/core/workflow/nodes/trigger_webhook/entities.py index 4d5ad72154..a30f877e4b 100644 --- a/api/core/workflow/nodes/trigger_webhook/entities.py +++ b/api/core/workflow/nodes/trigger_webhook/entities.py @@ -8,24 +8,20 @@ from pydantic import BaseModel, Field, field_validator from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE -_WEBHOOK_HEADER_ALLOWED_TYPES = frozenset( - { - SegmentType.STRING, - } -) +_WEBHOOK_HEADER_ALLOWED_TYPES: frozenset[SegmentType] = frozenset((SegmentType.STRING,)) -_WEBHOOK_QUERY_PARAMETER_ALLOWED_TYPES = frozenset( - { +_WEBHOOK_QUERY_PARAMETER_ALLOWED_TYPES: frozenset[SegmentType] = frozenset( + ( SegmentType.STRING, SegmentType.NUMBER, SegmentType.BOOLEAN, - } + ) ) _WEBHOOK_PARAMETER_ALLOWED_TYPES = _WEBHOOK_HEADER_ALLOWED_TYPES | _WEBHOOK_QUERY_PARAMETER_ALLOWED_TYPES -_WEBHOOK_BODY_ALLOWED_TYPES = frozenset( - { +_WEBHOOK_BODY_ALLOWED_TYPES: frozenset[SegmentType] = frozenset( + ( SegmentType.STRING, SegmentType.NUMBER, SegmentType.BOOLEAN, @@ -35,7 +31,7 @@ _WEBHOOK_BODY_ALLOWED_TYPES = frozenset( SegmentType.ARRAY_BOOLEAN, SegmentType.ARRAY_OBJECT, SegmentType.FILE, - } + ) ) diff --git a/api/libs/collection_utils.py b/api/libs/collection_utils.py index f97308ca44..7054fe401e 100644 --- a/api/libs/collection_utils.py +++ b/api/libs/collection_utils.py @@ -1,9 +1,12 @@ -def convert_to_lower_and_upper_set(inputs: list[str] | set[str]) -> set[str]: +from collections.abc import Collection + + +def convert_to_lower_and_upper_set(inputs: Collection[str]) -> set[str]: """ - Convert a list or set of strings to a set containing both lower and upper case versions of each string. + Convert a collection of strings to a set containing both lower and upper case versions of each string. Args: - inputs (list[str] | set[str]): A list or set of strings to be converted. + inputs (Collection[str]): A collection of strings to be converted. Returns: set[str]: A set containing both lower and upper case versions of each string. diff --git a/api/models/workflow.py b/api/models/workflow.py index f8868cb73c..1063016370 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -1386,7 +1386,7 @@ class ConversationVariable(TypeBase): # Only `sys.query` and `sys.files` could be modified. -_EDITABLE_SYSTEM_VARIABLE = frozenset(["query", "files"]) +_EDITABLE_SYSTEM_VARIABLE = frozenset(("query", "files")) class WorkflowDraftVariable(Base): diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 98e338a2d4..9ed60bf86b 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -800,8 +800,8 @@ class DraftVariableSaver: # technical variables from being exposed in the draft environment, particularly those # that aren't meant to be directly edited or viewed by users. _EXCLUDE_VARIABLE_NAMES_MAPPING: dict[NodeType, frozenset[str]] = { - BuiltinNodeTypes.LLM: frozenset(["finish_reason"]), - BuiltinNodeTypes.LOOP: frozenset(["loop_round"]), + BuiltinNodeTypes.LLM: frozenset(("finish_reason",)), + BuiltinNodeTypes.LOOP: frozenset(("loop_round",)), } # Database session used for persisting draft variables. diff --git a/api/tests/unit_tests/core/datasource/test_file_upload.py b/api/tests/unit_tests/core/datasource/test_file_upload.py index 63b86e64fc..c6d6dd5808 100644 --- a/api/tests/unit_tests/core/datasource/test_file_upload.py +++ b/api/tests/unit_tests/core/datasource/test_file_upload.py @@ -1249,9 +1249,9 @@ class TestFileConstants: """ def test_image_extensions_set_properties(self): - """Test that IMAGE_EXTENSIONS set has expected properties.""" - # Assert - Should be a set - assert isinstance(IMAGE_EXTENSIONS, set) + """Test that IMAGE_EXTENSIONS frozenset has expected properties.""" + # Assert - Should be immutable + assert isinstance(IMAGE_EXTENSIONS, frozenset) # Should not be empty assert len(IMAGE_EXTENSIONS) > 0 # Should contain common image formats @@ -1260,9 +1260,9 @@ class TestFileConstants: assert ext in IMAGE_EXTENSIONS or ext.upper() in IMAGE_EXTENSIONS def test_video_extensions_set_properties(self): - """Test that VIDEO_EXTENSIONS set has expected properties.""" - # Assert - Should be a set - assert isinstance(VIDEO_EXTENSIONS, set) + """Test that VIDEO_EXTENSIONS frozenset has expected properties.""" + # Assert - Should be immutable + assert isinstance(VIDEO_EXTENSIONS, frozenset) # Should not be empty assert len(VIDEO_EXTENSIONS) > 0 # Should contain common video formats @@ -1271,9 +1271,9 @@ class TestFileConstants: assert ext in VIDEO_EXTENSIONS or ext.upper() in VIDEO_EXTENSIONS def test_audio_extensions_set_properties(self): - """Test that AUDIO_EXTENSIONS set has expected properties.""" - # Assert - Should be a set - assert isinstance(AUDIO_EXTENSIONS, set) + """Test that AUDIO_EXTENSIONS frozenset has expected properties.""" + # Assert - Should be immutable + assert isinstance(AUDIO_EXTENSIONS, frozenset) # Should not be empty assert len(AUDIO_EXTENSIONS) > 0 # Should contain common audio formats @@ -1282,9 +1282,9 @@ class TestFileConstants: assert ext in AUDIO_EXTENSIONS or ext.upper() in AUDIO_EXTENSIONS def test_document_extensions_set_properties(self): - """Test that DOCUMENT_EXTENSIONS set has expected properties.""" - # Assert - Should be a set - assert isinstance(DOCUMENT_EXTENSIONS, set) + """Test that DOCUMENT_EXTENSIONS frozenset has expected properties.""" + # Assert - Should be immutable + assert isinstance(DOCUMENT_EXTENSIONS, frozenset) # Should not be empty assert len(DOCUMENT_EXTENSIONS) > 0 # Should contain common document formats diff --git a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_stopwords.py b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_stopwords.py index 1b1541ddd6..4375d854ba 100644 --- a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_stopwords.py +++ b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_stopwords.py @@ -2,5 +2,6 @@ from core.rag.datasource.keyword.jieba.stopwords import STOPWORDS def test_stopwords_loaded(): + assert isinstance(STOPWORDS, frozenset) assert "during" in STOPWORDS assert "the" in STOPWORDS diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py index a215e9d350..7841bf05ad 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py @@ -1,4 +1,5 @@ import base64 +import logging import uuid from collections.abc import Sequence from unittest import mock @@ -1261,6 +1262,10 @@ def test_llm_node_image_file_to_markdown(llm_node: LLMNode): class TestSaveMultimodalOutputAndConvertResultToMarkdown: + class _UnknownItem: + def __str__(self) -> str: + return "" + def test_str_content(self, llm_node_for_multimodal): llm_node, mock_file_saver = llm_node_for_multimodal gen = llm_node._save_multimodal_output_and_convert_result_to_markdown( @@ -1330,18 +1335,23 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: def test_unknown_content_type(self, llm_node_for_multimodal): llm_node, mock_file_saver = llm_node_for_multimodal gen = llm_node._save_multimodal_output_and_convert_result_to_markdown( - contents=frozenset(["hello world"]), file_saver=mock_file_saver, file_outputs=[] + contents=frozenset(("hello world",)), file_saver=mock_file_saver, file_outputs=[] ) assert list(gen) == ["hello world"] mock_file_saver.save_binary_string.assert_not_called() mock_file_saver.save_remote_url.assert_not_called() - def test_unknown_item_type(self, llm_node_for_multimodal): + def test_unknown_item_type(self, llm_node_for_multimodal, caplog): llm_node, mock_file_saver = llm_node_for_multimodal - gen = llm_node._save_multimodal_output_and_convert_result_to_markdown( - contents=[frozenset(["hello world"])], file_saver=mock_file_saver, file_outputs=[] - ) - assert list(gen) == ["frozenset({'hello world'})"] + unknown_item = self._UnknownItem() + + with caplog.at_level(logging.WARNING, logger="graphon.nodes.llm.node"): + gen = llm_node._save_multimodal_output_and_convert_result_to_markdown( + contents=[unknown_item], file_saver=mock_file_saver, file_outputs=[] + ) + assert list(gen) == [str(unknown_item)] + + assert "unknown item type encountered" in caplog.text mock_file_saver.save_binary_string.assert_not_called() mock_file_saver.save_remote_url.assert_not_called() diff --git a/api/tests/unit_tests/factories/test_variable_factory.py b/api/tests/unit_tests/factories/test_variable_factory.py index 8d573b1154..a06c42507d 100644 --- a/api/tests/unit_tests/factories/test_variable_factory.py +++ b/api/tests/unit_tests/factories/test_variable_factory.py @@ -837,7 +837,7 @@ class TestBuildSegmentValueErrors: self.ValueErrorTestCase( name="frozenset_type", description="frozenset (unsupported type)", - test_value=frozenset([1, 2, 3]), + test_value=frozenset((1, 2, 3)), ), self.ValueErrorTestCase( name="memoryview_type", diff --git a/dev/pytest/pytest_config_tests.py b/dev/pytest/pytest_config_tests.py index 1ae115f85c..d56cceff5e 100644 --- a/dev/pytest/pytest_config_tests.py +++ b/dev/pytest/pytest_config_tests.py @@ -3,89 +3,93 @@ from pathlib import Path import yaml # type: ignore from dotenv import dotenv_values -BASE_API_AND_DOCKER_CONFIG_SET_DIFF = { - "APP_MAX_EXECUTION_TIME", - "BATCH_UPLOAD_LIMIT", - "CELERY_BEAT_SCHEDULER_TIME", - "CODE_EXECUTION_API_KEY", - "HTTP_REQUEST_MAX_CONNECT_TIMEOUT", - "HTTP_REQUEST_MAX_READ_TIMEOUT", - "HTTP_REQUEST_MAX_WRITE_TIMEOUT", - "INNER_API_KEY", - "INNER_API_KEY_FOR_PLUGIN", - "KEYWORD_DATA_SOURCE_TYPE", - "LOGIN_LOCKOUT_DURATION", - "LOG_FORMAT", - "OCI_ACCESS_KEY", - "OCI_BUCKET_NAME", - "OCI_ENDPOINT", - "OCI_REGION", - "OCI_SECRET_KEY", - "PLUGIN_DAEMON_KEY", - "PLUGIN_DAEMON_URL", - "PLUGIN_REMOTE_INSTALL_HOST", - "PLUGIN_REMOTE_INSTALL_PORT", - "REDIS_DB", - "RESEND_API_URL", - "RESPECT_XFORWARD_HEADERS_ENABLED", - "SENTRY_DSN", - "SSRF_DEFAULT_CONNECT_TIME_OUT", - "SSRF_DEFAULT_MAX_RETRIES", - "SSRF_DEFAULT_READ_TIME_OUT", - "SSRF_DEFAULT_TIME_OUT", - "SSRF_DEFAULT_WRITE_TIME_OUT", - "UPSTASH_VECTOR_TOKEN", - "UPSTASH_VECTOR_URL", - "USING_UGC_INDEX", - "WEAVIATE_BATCH_SIZE", -} +BASE_API_AND_DOCKER_CONFIG_SET_DIFF: frozenset[str] = frozenset( + ( + "APP_MAX_EXECUTION_TIME", + "BATCH_UPLOAD_LIMIT", + "CELERY_BEAT_SCHEDULER_TIME", + "CODE_EXECUTION_API_KEY", + "HTTP_REQUEST_MAX_CONNECT_TIMEOUT", + "HTTP_REQUEST_MAX_READ_TIMEOUT", + "HTTP_REQUEST_MAX_WRITE_TIMEOUT", + "INNER_API_KEY", + "INNER_API_KEY_FOR_PLUGIN", + "KEYWORD_DATA_SOURCE_TYPE", + "LOGIN_LOCKOUT_DURATION", + "LOG_FORMAT", + "OCI_ACCESS_KEY", + "OCI_BUCKET_NAME", + "OCI_ENDPOINT", + "OCI_REGION", + "OCI_SECRET_KEY", + "PLUGIN_DAEMON_KEY", + "PLUGIN_DAEMON_URL", + "PLUGIN_REMOTE_INSTALL_HOST", + "PLUGIN_REMOTE_INSTALL_PORT", + "REDIS_DB", + "RESEND_API_URL", + "RESPECT_XFORWARD_HEADERS_ENABLED", + "SENTRY_DSN", + "SSRF_DEFAULT_CONNECT_TIME_OUT", + "SSRF_DEFAULT_MAX_RETRIES", + "SSRF_DEFAULT_READ_TIME_OUT", + "SSRF_DEFAULT_TIME_OUT", + "SSRF_DEFAULT_WRITE_TIME_OUT", + "UPSTASH_VECTOR_TOKEN", + "UPSTASH_VECTOR_URL", + "USING_UGC_INDEX", + "WEAVIATE_BATCH_SIZE", + ) +) -BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF = { - "BATCH_UPLOAD_LIMIT", - "CELERY_BEAT_SCHEDULER_TIME", - "HTTP_REQUEST_MAX_CONNECT_TIMEOUT", - "HTTP_REQUEST_MAX_READ_TIMEOUT", - "HTTP_REQUEST_MAX_WRITE_TIMEOUT", - "INNER_API_KEY", - "INNER_API_KEY_FOR_PLUGIN", - "KEYWORD_DATA_SOURCE_TYPE", - "LOGIN_LOCKOUT_DURATION", - "LOG_FORMAT", - "OPENDAL_FS_ROOT", - "OPENDAL_S3_ACCESS_KEY_ID", - "OPENDAL_S3_BUCKET", - "OPENDAL_S3_ENDPOINT", - "OPENDAL_S3_REGION", - "OPENDAL_S3_ROOT", - "OPENDAL_S3_SECRET_ACCESS_KEY", - "OPENDAL_S3_SERVER_SIDE_ENCRYPTION", - "PGVECTOR_MAX_CONNECTION", - "PGVECTOR_MIN_CONNECTION", - "PGVECTO_RS_DATABASE", - "PGVECTO_RS_HOST", - "PGVECTO_RS_PASSWORD", - "PGVECTO_RS_PORT", - "PGVECTO_RS_USER", - "PLUGIN_DAEMON_KEY", - "PLUGIN_DAEMON_URL", - "PLUGIN_REMOTE_INSTALL_HOST", - "PLUGIN_REMOTE_INSTALL_PORT", - "RESPECT_XFORWARD_HEADERS_ENABLED", - "SCARF_NO_ANALYTICS", - "SSRF_DEFAULT_CONNECT_TIME_OUT", - "SSRF_DEFAULT_MAX_RETRIES", - "SSRF_DEFAULT_READ_TIME_OUT", - "SSRF_DEFAULT_TIME_OUT", - "SSRF_DEFAULT_WRITE_TIME_OUT", - "STORAGE_OPENDAL_SCHEME", - "SUPABASE_API_KEY", - "SUPABASE_BUCKET_NAME", - "SUPABASE_URL", - "USING_UGC_INDEX", - "VIKINGDB_CONNECTION_TIMEOUT", - "VIKINGDB_SOCKET_TIMEOUT", - "WEAVIATE_BATCH_SIZE", -} +BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset( + ( + "BATCH_UPLOAD_LIMIT", + "CELERY_BEAT_SCHEDULER_TIME", + "HTTP_REQUEST_MAX_CONNECT_TIMEOUT", + "HTTP_REQUEST_MAX_READ_TIMEOUT", + "HTTP_REQUEST_MAX_WRITE_TIMEOUT", + "INNER_API_KEY", + "INNER_API_KEY_FOR_PLUGIN", + "KEYWORD_DATA_SOURCE_TYPE", + "LOGIN_LOCKOUT_DURATION", + "LOG_FORMAT", + "OPENDAL_FS_ROOT", + "OPENDAL_S3_ACCESS_KEY_ID", + "OPENDAL_S3_BUCKET", + "OPENDAL_S3_ENDPOINT", + "OPENDAL_S3_REGION", + "OPENDAL_S3_ROOT", + "OPENDAL_S3_SECRET_ACCESS_KEY", + "OPENDAL_S3_SERVER_SIDE_ENCRYPTION", + "PGVECTOR_MAX_CONNECTION", + "PGVECTOR_MIN_CONNECTION", + "PGVECTO_RS_DATABASE", + "PGVECTO_RS_HOST", + "PGVECTO_RS_PASSWORD", + "PGVECTO_RS_PORT", + "PGVECTO_RS_USER", + "PLUGIN_DAEMON_KEY", + "PLUGIN_DAEMON_URL", + "PLUGIN_REMOTE_INSTALL_HOST", + "PLUGIN_REMOTE_INSTALL_PORT", + "RESPECT_XFORWARD_HEADERS_ENABLED", + "SCARF_NO_ANALYTICS", + "SSRF_DEFAULT_CONNECT_TIME_OUT", + "SSRF_DEFAULT_MAX_RETRIES", + "SSRF_DEFAULT_READ_TIME_OUT", + "SSRF_DEFAULT_TIME_OUT", + "SSRF_DEFAULT_WRITE_TIME_OUT", + "STORAGE_OPENDAL_SCHEME", + "SUPABASE_API_KEY", + "SUPABASE_BUCKET_NAME", + "SUPABASE_URL", + "USING_UGC_INDEX", + "VIKINGDB_CONNECTION_TIMEOUT", + "VIKINGDB_SOCKET_TIMEOUT", + "WEAVIATE_BATCH_SIZE", + ) +) API_CONFIG_SET = set(dotenv_values(Path("api") / Path(".env.example")).keys()) DOCKER_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.example")).keys()) From b54a0dc1e4acaa100ae7d8271d2db57614129e03 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:41:20 +0800 Subject: [PATCH 03/30] fix(web): localize error boundary copy (#34332) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../error-boundary/__tests__/index.spec.tsx | 14 +++++ .../components/base/error-boundary/index.tsx | 52 ++++++++++++++----- .../__tests__/index.spec.tsx | 12 ++++- web/i18n/en-US/common.json | 9 ++++ 4 files changed, 71 insertions(+), 16 deletions(-) diff --git a/web/app/components/base/error-boundary/__tests__/index.spec.tsx b/web/app/components/base/error-boundary/__tests__/index.spec.tsx index 8c34026175..b9838130f7 100644 --- a/web/app/components/base/error-boundary/__tests__/index.spec.tsx +++ b/web/app/components/base/error-boundary/__tests__/index.spec.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createReactI18nextMock } from '@/test/i18n-mock' import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from '../index' const mockConfig = vi.hoisted(() => ({ @@ -13,6 +14,19 @@ vi.mock('@/config', () => ({ }, })) +vi.mock('react-i18next', () => createReactI18nextMock({ + 'error': 'Error', + 'errorBoundary.componentStack': 'Component Stack:', + 'errorBoundary.details': 'Error Details (Development Only)', + 'errorBoundary.errorCount': 'This error has occurred {{count}} times', + 'errorBoundary.fallbackTitle': 'Oops! Something went wrong', + 'errorBoundary.message': 'An unexpected error occurred while rendering this component.', + 'errorBoundary.reloadPage': 'Reload Page', + 'errorBoundary.title': 'Something went wrong', + 'errorBoundary.tryAgain': 'Try Again', + 'errorBoundary.tryAgainCompact': 'Try again', +})) + type ThrowOnRenderProps = { message?: string shouldThrow: boolean diff --git a/web/app/components/base/error-boundary/index.tsx b/web/app/components/base/error-boundary/index.tsx index 9cb4b70cf5..3e7bc03ed6 100644 --- a/web/app/components/base/error-boundary/index.tsx +++ b/web/app/components/base/error-boundary/index.tsx @@ -3,6 +3,7 @@ import type { ErrorInfo, ReactNode } from 'react' import { RiAlertLine, RiBugLine } from '@remixicon/react' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { IS_DEV } from '@/config' import { cn } from '@/utils/classnames' @@ -29,9 +30,21 @@ type ErrorBoundaryProps = { customMessage?: string } +type ErrorBoundaryCopy = { + componentStack: string + details: string + error: string + formatErrorCount: (count: number) => string + message: string + reload: string + title: string + tryAgain: string +} + // Internal class component for error catching class ErrorBoundaryInner extends React.Component< ErrorBoundaryProps & { + copy: ErrorBoundaryCopy resetErrorBoundary: () => void onResetKeysChange: (prevResetKeys?: Array) => void }, @@ -96,6 +109,7 @@ class ErrorBoundaryInner extends React.Component< enableRecovery = true, customTitle, customMessage, + copy, resetErrorBoundary, } = this.props @@ -118,12 +132,12 @@ class ErrorBoundaryInner extends React.Component<

- {customTitle || 'Something went wrong'} + {customTitle || copy.title}

- {customMessage || 'An unexpected error occurred while rendering this component.'} + {customMessage || copy.message}

{showDetails && errorInfo && ( @@ -131,19 +145,19 @@ class ErrorBoundaryInner extends React.Component< - Error Details (Development Only) + {copy.details}
- Error: + {copy.error}
                     {error.toString()}
                   
{errorInfo && (
- Component Stack: + {copy.componentStack}
                       {errorInfo.componentStack}
                     
@@ -151,11 +165,7 @@ class ErrorBoundaryInner extends React.Component< )} {errorCount > 1 && (
- This error has occurred - {' '} - {errorCount} - {' '} - times + {copy.formatErrorCount(errorCount)}
)}
@@ -169,14 +179,14 @@ class ErrorBoundaryInner extends React.Component< size="small" onClick={resetErrorBoundary} > - Try Again + {copy.tryAgain}
)} @@ -190,9 +200,20 @@ class ErrorBoundaryInner extends React.Component< // Main functional component wrapper const ErrorBoundary: React.FC = (props) => { + const { t } = useTranslation() const [errorBoundaryKey, setErrorBoundaryKey] = useState(0) const resetKeysRef = useRef(props.resetKeys) const prevResetKeysRef = useRef | undefined>(undefined) + const copy = { + componentStack: t('errorBoundary.componentStack', { ns: 'common' }), + details: t('errorBoundary.details', { ns: 'common' }), + error: `${t('error', { ns: 'common' })}:`, + formatErrorCount: (count: number) => t('errorBoundary.errorCount', { ns: 'common', count }), + message: t('errorBoundary.message', { ns: 'common' }), + reload: t('errorBoundary.reloadPage', { ns: 'common' }), + title: t('errorBoundary.title', { ns: 'common' }), + tryAgain: t('errorBoundary.tryAgain', { ns: 'common' }), + } const resetErrorBoundary = useCallback(() => { setErrorBoundaryKey(prev => prev + 1) @@ -211,6 +232,7 @@ const ErrorBoundary: React.FC = (props) => { return ( void }> = ({ error, resetErrorBoundaryAction }) => { + const { t } = useTranslation() + return (
-

Oops! Something went wrong

+

{t('errorBoundary.fallbackTitle', { ns: 'common' })}

{error.message}

) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx index 5c7ebfc57a..d41dfaa7d0 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/index.spec.tsx @@ -3,9 +3,17 @@ import type { TriggerSubscription } from '@/app/components/workflow/block-select import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { createReactI18nextMock } from '@/test/i18n-mock' import { SubscriptionList } from '../index' import { SubscriptionListMode } from '../types' +vi.mock('react-i18next', () => createReactI18nextMock({ + 'errorBoundary.title': 'Something went wrong', + 'errorBoundary.message': 'An unexpected error occurred while rendering this component.', + 'errorBoundary.tryAgain': 'Try Again', + 'errorBoundary.reloadPage': 'Reload Page', +})) + const mockRefetch = vi.fn() let mockSubscriptionListError: Error | null = null let mockSubscriptionListState: { @@ -209,12 +217,12 @@ describe('SubscriptionList', () => { }) describe('Edge Cases', () => { - it('should render error boundary fallback when an error occurs', () => { + it('should render error boundary fallback when an error occurs', async () => { mockSubscriptionListError = new Error('boom') render() - expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect(await screen.findByText('Something went wrong')).toBeInTheDocument() }) }) }) diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 36301ed72b..c21aa25eae 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -162,6 +162,15 @@ "environment.development": "DEVELOPMENT", "environment.testing": "TESTING", "error": "Error", + "errorBoundary.componentStack": "Component Stack:", + "errorBoundary.details": "Error Details (Development Only)", + "errorBoundary.errorCount": "This error has occurred {{count}} times", + "errorBoundary.fallbackTitle": "Oops! Something went wrong", + "errorBoundary.message": "An unexpected error occurred while rendering this component.", + "errorBoundary.reloadPage": "Reload Page", + "errorBoundary.title": "Something went wrong", + "errorBoundary.tryAgain": "Try Again", + "errorBoundary.tryAgainCompact": "Try again", "errorMsg.fieldRequired": "{{field}} is required", "errorMsg.urlError": "url should start with http:// or https://", "feedback.content": "Feedback Content", From fbd2d31624646d6e83121b03270fd45c461dc340 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:41:30 +0800 Subject: [PATCH 04/30] refactor(nodejs-sdk): replace axios with fetch transport (#34325) --- pnpm-lock.yaml | 215 +------ pnpm-workspace.yaml | 4 +- sdks/nodejs-client/eslint.config.js | 4 +- sdks/nodejs-client/package.json | 8 +- .../src/client/{base.test.js => base.test.ts} | 10 +- sdks/nodejs-client/src/client/base.ts | 47 +- .../src/client/{chat.test.js => chat.test.ts} | 10 +- sdks/nodejs-client/src/client/chat.ts | 62 +- ...{completion.test.js => completion.test.ts} | 0 sdks/nodejs-client/src/client/completion.ts | 31 +- ...ge-base.test.js => knowledge-base.test.ts} | 21 +- .../src/client/knowledge-base.ts | 27 +- ...{validation.test.js => validation.test.ts} | 7 +- sdks/nodejs-client/src/client/validation.ts | 5 +- .../{workflow.test.js => workflow.test.ts} | 1 - sdks/nodejs-client/src/client/workflow.ts | 20 +- .../{workspace.test.js => workspace.test.ts} | 0 ...{dify-error.test.js => dify-error.test.ts} | 0 sdks/nodejs-client/src/http/client.test.js | 304 ---------- sdks/nodejs-client/src/http/client.test.ts | 527 ++++++++++++++++ sdks/nodejs-client/src/http/client.ts | 569 ++++++++++++------ .../{form-data.test.js => form-data.test.ts} | 10 +- sdks/nodejs-client/src/http/form-data.ts | 20 +- .../src/http/{retry.test.js => retry.test.ts} | 2 +- .../src/http/{sse.test.js => sse.test.ts} | 29 +- sdks/nodejs-client/src/http/sse.ts | 40 +- sdks/nodejs-client/src/index.test.js | 227 ------- sdks/nodejs-client/src/index.test.ts | 240 ++++++++ .../nodejs-client/src/internal/type-guards.ts | 9 + sdks/nodejs-client/src/types/annotation.ts | 3 +- sdks/nodejs-client/src/types/chat.ts | 23 +- sdks/nodejs-client/src/types/common.ts | 24 +- sdks/nodejs-client/src/types/completion.ts | 17 +- .../nodejs-client/src/types/knowledge-base.ts | 33 +- sdks/nodejs-client/src/types/workflow.ts | 17 +- sdks/nodejs-client/src/types/workspace.ts | 4 +- .../tests/http.integration.test.ts | 137 +++++ sdks/nodejs-client/tests/test-utils.js | 30 - sdks/nodejs-client/tests/test-utils.ts | 48 ++ sdks/nodejs-client/tsconfig.json | 4 +- sdks/nodejs-client/vitest.config.ts | 2 +- 41 files changed, 1673 insertions(+), 1118 deletions(-) rename sdks/nodejs-client/src/client/{base.test.js => base.test.ts} (96%) rename sdks/nodejs-client/src/client/{chat.test.js => chat.test.ts} (97%) rename sdks/nodejs-client/src/client/{completion.test.js => completion.test.ts} (100%) rename sdks/nodejs-client/src/client/{knowledge-base.test.js => knowledge-base.test.ts} (92%) rename sdks/nodejs-client/src/client/{validation.test.js => validation.test.ts} (93%) rename sdks/nodejs-client/src/client/{workflow.test.js => workflow.test.ts} (97%) rename sdks/nodejs-client/src/client/{workspace.test.js => workspace.test.ts} (100%) rename sdks/nodejs-client/src/errors/{dify-error.test.js => dify-error.test.ts} (100%) delete mode 100644 sdks/nodejs-client/src/http/client.test.js create mode 100644 sdks/nodejs-client/src/http/client.test.ts rename sdks/nodejs-client/src/http/{form-data.test.js => form-data.test.ts} (73%) rename sdks/nodejs-client/src/http/{retry.test.js => retry.test.ts} (94%) rename sdks/nodejs-client/src/http/{sse.test.js => sse.test.ts} (73%) delete mode 100644 sdks/nodejs-client/src/index.test.js create mode 100644 sdks/nodejs-client/src/index.test.ts create mode 100644 sdks/nodejs-client/src/internal/type-guards.ts create mode 100644 sdks/nodejs-client/tests/http.integration.test.ts delete mode 100644 sdks/nodejs-client/tests/test-utils.js create mode 100644 sdks/nodejs-client/tests/test-utils.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6c234d8ad..eb45ea0ef8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,8 +235,8 @@ catalogs: specifier: 0.5.21 version: 0.5.21 '@vitest/coverage-v8': - specifier: 4.1.2 - version: 4.1.2 + specifier: 4.1.1 + version: 4.1.1 abcjs: specifier: 6.6.2 version: 6.6.2 @@ -570,7 +570,6 @@ overrides: array.prototype.flatmap: npm:@nolyfill/array.prototype.flatmap@^1.0.44 array.prototype.tosorted: npm:@nolyfill/array.prototype.tosorted@^1.0.44 assert: npm:@nolyfill/assert@^1.0.26 - axios: 1.14.0 brace-expansion@<2.0.2: 2.0.2 canvas: ^3.2.2 devalue@<5.3.2: 5.3.2 @@ -648,10 +647,6 @@ importers: version: 0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) sdks/nodejs-client: - dependencies: - axios: - specifier: 1.14.0 - version: 1.14.0 devDependencies: '@eslint/js': specifier: 'catalog:' @@ -667,7 +662,7 @@ importers: version: 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.2(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) + version: 4.1.1(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) eslint: specifier: 'catalog:' version: 10.1.0(jiti@2.6.1) @@ -1124,7 +1119,7 @@ importers: version: 0.5.21(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.2(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) + version: 4.1.1(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) agentation: specifier: 'catalog:' version: 3.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2405,10 +2400,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nolyfill/hasown@1.0.44': - resolution: {integrity: sha512-GA/21lkTr2PAQuT6jGnhLuBD5IFd/AEhBXJ/tf33+/bVxPxg+5ejKx9jGQGnyV/P0eSmdup5E+s8b2HL6lOrwQ==} - engines: {node: '>=12.4.0'} - '@nolyfill/is-core-module@1.0.39': resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} @@ -4440,11 +4431,11 @@ packages: react-server-dom-webpack: optional: true - '@vitest/coverage-v8@4.1.2': - resolution: {integrity: sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==} + '@vitest/coverage-v8@4.1.1': + resolution: {integrity: sha512-nZ4RWwGCoGOQRMmU/Q9wlUY540RVRxJZ9lxFsFfy0QV7Zmo5VVBhB6Sl9Xa0KIp2iIs3zWfPlo9LcY1iqbpzCw==} peerDependencies: - '@vitest/browser': 4.1.2 - vitest: 4.1.2 + '@vitest/browser': 4.1.1 + vitest: 4.1.1 peerDependenciesMeta: '@vitest/browser': optional: true @@ -4471,8 +4462,8 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.1.2': - resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + '@vitest/pretty-format@4.1.1': + resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} @@ -4480,8 +4471,8 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.1.2': - resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + '@vitest/utils@4.1.1': + resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} '@voidzero-dev/vite-plus-core@0.1.14': resolution: {integrity: sha512-CCWzdkfW0fo0cQNlIsYp5fOuH2IwKuPZEb2UY2Z8gXcp5pG74A82H2Pthj0heAuvYTAnfT7kEC6zM+RbiBgQbg==} @@ -4841,9 +4832,6 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - autoprefixer@10.4.27: resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} @@ -4851,9 +4839,6 @@ packages: peerDependencies: postcss: ^8.1.0 - axios@1.14.0: - resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} - bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -4951,10 +4936,6 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -5126,10 +5107,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - comma-separated-tokens@1.0.8: resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} @@ -5464,10 +5441,6 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -5533,10 +5506,6 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - echarts-for-react@3.0.6: resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==} peerDependencies: @@ -5613,28 +5582,12 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - es-toolkit@1.45.1: resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} @@ -6115,19 +6068,6 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -6164,9 +6104,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - functional-red-black-tree@1.0.1: resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} @@ -6181,18 +6118,10 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - get-stream@5.2.0: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} @@ -6249,10 +6178,6 @@ packages: peerDependencies: csstype: ^3.0.10 - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -6271,14 +6196,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - hast-util-from-dom@5.0.1: resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} @@ -6920,10 +6837,6 @@ packages: engines: {node: '>= 20'} hasBin: true - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - mdast-util-directive@3.1.0: resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} @@ -7651,10 +7564,6 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - proxy-from-env@2.1.0: - resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} - engines: {node: '>=10'} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -10497,8 +10406,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@nolyfill/hasown@1.0.44': {} - '@nolyfill/is-core-module@1.0.39': {} '@nolyfill/safer-buffer@1.0.44': {} @@ -12354,10 +12261,10 @@ snapshots: optionalDependencies: react-server-dom-webpack: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - '@vitest/coverage-v8@4.1.2(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))': + '@vitest/coverage-v8@4.1.1(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.2 + '@vitest/utils': 4.1.1 ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -12368,10 +12275,10 @@ snapshots: tinyrainbow: 3.1.0 vitest: '@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' - '@vitest/coverage-v8@4.1.2(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))': + '@vitest/coverage-v8@4.1.1(@voidzero-dev/vite-plus-test@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.2 + '@vitest/utils': 4.1.1 ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -12406,7 +12313,7 @@ snapshots: dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.1.2': + '@vitest/pretty-format@4.1.1': dependencies: tinyrainbow: 3.1.0 @@ -12420,9 +12327,9 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.1.2': + '@vitest/utils@4.1.1': dependencies: - '@vitest/pretty-format': 4.1.2 + '@vitest/pretty-format': 4.1.1 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -12816,8 +12723,6 @@ snapshots: async@3.2.6: {} - asynckit@0.4.0: {} - autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 @@ -12827,14 +12732,6 @@ snapshots: postcss: 8.5.8 postcss-value-parser: 4.2.0 - axios@1.14.0: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.5 - proxy-from-env: 2.1.0 - transitivePeerDependencies: - - debug - bail@2.0.2: {} balanced-match@1.0.2: {} @@ -12914,11 +12811,6 @@ snapshots: cac@7.0.0: {} - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - callsites@3.1.0: {} camelcase-css@2.0.1: {} @@ -13108,10 +13000,6 @@ snapshots: colorette@2.0.20: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - comma-separated-tokens@1.0.8: {} comma-separated-tokens@2.0.3: {} @@ -13441,8 +13329,6 @@ snapshots: dependencies: robust-predicates: 3.0.3 - delayed-stream@1.0.0: {} - dequal@2.0.3: {} destr@2.0.5: {} @@ -13499,12 +13385,6 @@ snapshots: dotenv@16.6.1: {} - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.4): dependencies: echarts: 6.0.0 @@ -13571,25 +13451,10 @@ snapshots: dependencies: stackframe: 1.3.4 - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} es-module-lexer@2.0.0: {} - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: '@nolyfill/hasown@1.0.44' - es-toolkit@1.45.1: {} esast-util-from-estree@2.0.0: @@ -14344,16 +14209,6 @@ snapshots: flatted@3.4.2: {} - follow-redirects@1.15.11: {} - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: '@nolyfill/hasown@1.0.44' - mime-types: 2.1.35 - format@0.2.2: {} formatly@0.3.0: @@ -14380,8 +14235,6 @@ snapshots: fsevents@2.3.3: optional: true - function-bind@1.1.2: {} - functional-red-black-tree@1.0.1: {} fzf@0.5.2: {} @@ -14390,26 +14243,8 @@ snapshots: get-east-asian-width@1.5.0: {} - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: '@nolyfill/hasown@1.0.44' - math-intrinsics: 1.1.0 - get-nonce@1.0.1: {} - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - get-stream@5.2.0: dependencies: pump: 3.0.4 @@ -14457,8 +14292,6 @@ snapshots: dependencies: csstype: 3.2.3 - gopd@1.2.0: {} - graceful-fs@4.2.11: {} hachure-fill@0.5.2: {} @@ -14481,12 +14314,6 @@ snapshots: has-flag@4.0.0: {} - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - hast-util-from-dom@5.0.1: dependencies: '@types/hast': 3.0.4 @@ -15127,8 +14954,6 @@ snapshots: marked@17.0.5: {} - math-intrinsics@1.1.0: {} - mdast-util-directive@3.1.0: dependencies: '@types/mdast': 4.0.4 @@ -16267,8 +16092,6 @@ snapshots: property-information@7.1.0: {} - proxy-from-env@2.1.0: {} - pump@3.0.4: dependencies: end-of-stream: 1.4.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ae53a57832..b11cca6642 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,7 +22,6 @@ overrides: array.prototype.flatmap: npm:@nolyfill/array.prototype.flatmap@^1.0.44 array.prototype.tosorted: npm:@nolyfill/array.prototype.tosorted@^1.0.44 assert: npm:@nolyfill/assert@^1.0.26 - axios: 1.14.0 brace-expansion@<2.0.2: 2.0.2 canvas: ^3.2.2 devalue@<5.3.2: 5.3.2 @@ -147,12 +146,11 @@ catalog: "@typescript/native-preview": 7.0.0-dev.20260329.1 "@vitejs/plugin-react": 6.0.1 "@vitejs/plugin-rsc": 0.5.21 - "@vitest/coverage-v8": 4.1.2 + "@vitest/coverage-v8": 4.1.1 abcjs: 6.6.2 agentation: 3.0.2 ahooks: 3.9.7 autoprefixer: 10.4.27 - axios: 1.14.0 class-variance-authority: 0.7.1 clsx: 2.1.1 cmdk: 1.1.1 diff --git a/sdks/nodejs-client/eslint.config.js b/sdks/nodejs-client/eslint.config.js index 9e659f5d28..21ac872f2a 100644 --- a/sdks/nodejs-client/eslint.config.js +++ b/sdks/nodejs-client/eslint.config.js @@ -12,11 +12,11 @@ const typeCheckedRules = export default [ { - ignores: ["dist", "node_modules", "scripts", "tests", "**/*.test.*", "**/*.spec.*"], + ignores: ["dist", "node_modules", "scripts"], }, js.configs.recommended, { - files: ["src/**/*.ts"], + files: ["src/**/*.ts", "tests/**/*.ts"], languageOptions: { parser: tsParser, ecmaVersion: "latest", diff --git a/sdks/nodejs-client/package.json b/sdks/nodejs-client/package.json index 63fa6799b1..d487c3abb3 100644 --- a/sdks/nodejs-client/package.json +++ b/sdks/nodejs-client/package.json @@ -1,6 +1,6 @@ { "name": "dify-client", - "version": "3.0.0", + "version": "3.1.0", "description": "This is the Node.js SDK for the Dify.AI API, which allows you to easily integrate Dify.AI into your Node.js applications.", "type": "module", "main": "./dist/index.js", @@ -15,7 +15,8 @@ "node": ">=18.0.0" }, "files": [ - "dist", + "dist/index.js", + "dist/index.d.ts", "README.md", "LICENSE" ], @@ -53,9 +54,6 @@ "publish:check": "./scripts/publish.sh --dry-run", "publish:npm": "./scripts/publish.sh" }, - "dependencies": { - "axios": "catalog:" - }, "devDependencies": { "@eslint/js": "catalog:", "@types/node": "catalog:", diff --git a/sdks/nodejs-client/src/client/base.test.js b/sdks/nodejs-client/src/client/base.test.ts similarity index 96% rename from sdks/nodejs-client/src/client/base.test.js rename to sdks/nodejs-client/src/client/base.test.ts index 5e1b21d0f1..868c476432 100644 --- a/sdks/nodejs-client/src/client/base.test.js +++ b/sdks/nodejs-client/src/client/base.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { DifyClient } from "./base"; import { ValidationError } from "../errors/dify-error"; +import { DifyClient } from "./base"; import { createHttpClientWithSpies } from "../../tests/test-utils"; describe("DifyClient base", () => { @@ -103,7 +103,7 @@ describe("DifyClient base", () => { }); }); - it("filePreview uses arraybuffer response", async () => { + it("filePreview uses bytes response", async () => { const { client, request } = createHttpClientWithSpies(); const dify = new DifyClient(client); @@ -113,7 +113,7 @@ describe("DifyClient base", () => { method: "GET", path: "/files/file/preview", query: { user: "user", as_attachment: "true" }, - responseType: "arraybuffer", + responseType: "bytes", }); }); @@ -162,11 +162,11 @@ describe("DifyClient base", () => { streaming: false, voice: "voice", }, - responseType: "arraybuffer", + responseType: "bytes", }); }); - it("textToAudio requires text or message id", async () => { + it("textToAudio requires text or message id", () => { const { client } = createHttpClientWithSpies(); const dify = new DifyClient(client); diff --git a/sdks/nodejs-client/src/client/base.ts b/sdks/nodejs-client/src/client/base.ts index 0fa535a488..f02b88be3a 100644 --- a/sdks/nodejs-client/src/client/base.ts +++ b/sdks/nodejs-client/src/client/base.ts @@ -2,14 +2,18 @@ import type { BinaryStream, DifyClientConfig, DifyResponse, + JsonObject, MessageFeedbackRequest, QueryParams, RequestMethod, + SuccessResponse, TextToAudioRequest, } from "../types/common"; +import type { HttpRequestBody } from "../http/client"; import { HttpClient } from "../http/client"; import { ensureNonEmptyString, ensureRating } from "./validation"; import { FileUploadError, ValidationError } from "../errors/dify-error"; +import type { SdkFormData } from "../http/form-data"; import { isFormData } from "../http/form-data"; const toConfig = ( @@ -25,13 +29,8 @@ const toConfig = ( return init; }; -const appendUserToFormData = (form: unknown, user: string): void => { - if (!isFormData(form)) { - throw new FileUploadError("FormData is required for file uploads"); - } - if (typeof form.append === "function") { - form.append("user", user); - } +const appendUserToFormData = (form: SdkFormData, user: string): void => { + form.append("user", user); }; export class DifyClient { @@ -57,7 +56,7 @@ export class DifyClient { sendRequest( method: RequestMethod, endpoint: string, - data: unknown = null, + data: HttpRequestBody = null, params: QueryParams | null = null, stream = false, headerParams: Record = {} @@ -72,14 +71,14 @@ export class DifyClient { }); } - getRoot(): Promise> { + getRoot(): Promise> { return this.http.request({ method: "GET", path: "/", }); } - getApplicationParameters(user?: string): Promise> { + getApplicationParameters(user?: string): Promise> { if (user) { ensureNonEmptyString(user, "user"); } @@ -90,11 +89,11 @@ export class DifyClient { }); } - async getParameters(user?: string): Promise> { + async getParameters(user?: string): Promise> { return this.getApplicationParameters(user); } - getMeta(user?: string): Promise> { + getMeta(user?: string): Promise> { if (user) { ensureNonEmptyString(user, "user"); } @@ -107,21 +106,21 @@ export class DifyClient { messageFeedback( request: MessageFeedbackRequest - ): Promise>>; + ): Promise>; messageFeedback( messageId: string, rating: "like" | "dislike" | null, user: string, content?: string - ): Promise>>; + ): Promise>; messageFeedback( messageIdOrRequest: string | MessageFeedbackRequest, rating?: "like" | "dislike" | null, user?: string, content?: string - ): Promise>> { + ): Promise> { let messageId: string; - const payload: Record = {}; + const payload: JsonObject = {}; if (typeof messageIdOrRequest === "string") { messageId = messageIdOrRequest; @@ -157,7 +156,7 @@ export class DifyClient { }); } - getInfo(user?: string): Promise> { + getInfo(user?: string): Promise> { if (user) { ensureNonEmptyString(user, "user"); } @@ -168,7 +167,7 @@ export class DifyClient { }); } - getSite(user?: string): Promise> { + getSite(user?: string): Promise> { if (user) { ensureNonEmptyString(user, "user"); } @@ -179,7 +178,7 @@ export class DifyClient { }); } - fileUpload(form: unknown, user: string): Promise> { + fileUpload(form: unknown, user: string): Promise> { if (!isFormData(form)) { throw new FileUploadError("FormData is required for file uploads"); } @@ -199,18 +198,18 @@ export class DifyClient { ): Promise> { ensureNonEmptyString(fileId, "fileId"); ensureNonEmptyString(user, "user"); - return this.http.request({ + return this.http.request({ method: "GET", path: `/files/${fileId}/preview`, query: { user, as_attachment: asAttachment ? "true" : undefined, }, - responseType: "arraybuffer", + responseType: "bytes", }); } - audioToText(form: unknown, user: string): Promise> { + audioToText(form: unknown, user: string): Promise> { if (!isFormData(form)) { throw new FileUploadError("FormData is required for audio uploads"); } @@ -274,11 +273,11 @@ export class DifyClient { }); } - return this.http.request({ + return this.http.request({ method: "POST", path: "/text-to-audio", data: payload, - responseType: "arraybuffer", + responseType: "bytes", }); } } diff --git a/sdks/nodejs-client/src/client/chat.test.js b/sdks/nodejs-client/src/client/chat.test.ts similarity index 97% rename from sdks/nodejs-client/src/client/chat.test.js rename to sdks/nodejs-client/src/client/chat.test.ts index a97c9d4a5c..712ad64fd1 100644 --- a/sdks/nodejs-client/src/client/chat.test.js +++ b/sdks/nodejs-client/src/client/chat.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ChatClient } from "./chat"; import { ValidationError } from "../errors/dify-error"; +import { ChatClient } from "./chat"; import { createHttpClientWithSpies } from "../../tests/test-utils"; describe("ChatClient", () => { @@ -156,13 +156,13 @@ describe("ChatClient", () => { }); }); - it("requires name when autoGenerate is false", async () => { + it("requires name when autoGenerate is false", () => { const { client } = createHttpClientWithSpies(); const chat = new ChatClient(client); - expect(() => - chat.renameConversation("conv", "", "user", false) - ).toThrow(ValidationError); + expect(() => chat.renameConversation("conv", "", "user", false)).toThrow( + ValidationError + ); }); it("deletes conversations", async () => { diff --git a/sdks/nodejs-client/src/client/chat.ts b/sdks/nodejs-client/src/client/chat.ts index 745c999552..9c232e5117 100644 --- a/sdks/nodejs-client/src/client/chat.ts +++ b/sdks/nodejs-client/src/client/chat.ts @@ -1,5 +1,9 @@ import { DifyClient } from "./base"; -import type { ChatMessageRequest, ChatMessageResponse } from "../types/chat"; +import type { + ChatMessageRequest, + ChatMessageResponse, + ConversationSortBy, +} from "../types/chat"; import type { AnnotationCreateRequest, AnnotationListOptions, @@ -9,7 +13,11 @@ import type { import type { DifyResponse, DifyStream, + JsonObject, + JsonValue, QueryParams, + SuccessResponse, + SuggestedQuestionsResponse, } from "../types/common"; import { ensureNonEmptyString, @@ -22,20 +30,20 @@ export class ChatClient extends DifyClient { request: ChatMessageRequest ): Promise | DifyStream>; createChatMessage( - inputs: Record, + inputs: JsonObject, query: string, user: string, stream?: boolean, conversationId?: string | null, - files?: Array> | null + files?: ChatMessageRequest["files"] ): Promise | DifyStream>; createChatMessage( - inputOrRequest: ChatMessageRequest | Record, + inputOrRequest: ChatMessageRequest | JsonObject, query?: string, user?: string, stream = false, conversationId?: string | null, - files?: Array> | null + files?: ChatMessageRequest["files"] ): Promise | DifyStream> { let payload: ChatMessageRequest; let shouldStream = stream; @@ -46,8 +54,8 @@ export class ChatClient extends DifyClient { } else { ensureNonEmptyString(query, "query"); ensureNonEmptyString(user, "user"); - payload = { - inputs: inputOrRequest as Record, + payload = { + inputs: inputOrRequest, query, user, response_mode: stream ? "streaming" : "blocking", @@ -79,10 +87,10 @@ export class ChatClient extends DifyClient { stopChatMessage( taskId: string, user: string - ): Promise> { + ): Promise> { ensureNonEmptyString(taskId, "taskId"); ensureNonEmptyString(user, "user"); - return this.http.request({ + return this.http.request({ method: "POST", path: `/chat-messages/${taskId}/stop`, data: { user }, @@ -92,17 +100,17 @@ export class ChatClient extends DifyClient { stopMessage( taskId: string, user: string - ): Promise> { + ): Promise> { return this.stopChatMessage(taskId, user); } getSuggested( messageId: string, user: string - ): Promise> { + ): Promise> { ensureNonEmptyString(messageId, "messageId"); ensureNonEmptyString(user, "user"); - return this.http.request({ + return this.http.request({ method: "GET", path: `/messages/${messageId}/suggested`, query: { user }, @@ -114,7 +122,7 @@ export class ChatClient extends DifyClient { getAppFeedbacks( page?: number, limit?: number - ): Promise>> { + ): Promise> { ensureOptionalInt(page, "page"); ensureOptionalInt(limit, "limit"); return this.http.request({ @@ -131,8 +139,8 @@ export class ChatClient extends DifyClient { user: string, lastId?: string | null, limit?: number | null, - sortByOrPinned?: string | boolean | null - ): Promise>> { + sortBy?: ConversationSortBy | null + ): Promise> { ensureNonEmptyString(user, "user"); ensureOptionalString(lastId, "lastId"); ensureOptionalInt(limit, "limit"); @@ -144,10 +152,8 @@ export class ChatClient extends DifyClient { if (limit) { params.limit = limit; } - if (typeof sortByOrPinned === "string") { - params.sort_by = sortByOrPinned; - } else if (typeof sortByOrPinned === "boolean") { - params.pinned = sortByOrPinned; + if (sortBy) { + params.sort_by = sortBy; } return this.http.request({ @@ -162,7 +168,7 @@ export class ChatClient extends DifyClient { conversationId: string, firstId?: string | null, limit?: number | null - ): Promise>> { + ): Promise> { ensureNonEmptyString(user, "user"); ensureNonEmptyString(conversationId, "conversationId"); ensureOptionalString(firstId, "firstId"); @@ -189,18 +195,18 @@ export class ChatClient extends DifyClient { name: string, user: string, autoGenerate?: boolean - ): Promise>>; + ): Promise>; renameConversation( conversationId: string, user: string, options?: { name?: string | null; autoGenerate?: boolean } - ): Promise>>; + ): Promise>; renameConversation( conversationId: string, nameOrUser: string, userOrOptions?: string | { name?: string | null; autoGenerate?: boolean }, autoGenerate?: boolean - ): Promise>> { + ): Promise> { ensureNonEmptyString(conversationId, "conversationId"); let name: string | null | undefined; @@ -222,7 +228,7 @@ export class ChatClient extends DifyClient { ensureNonEmptyString(name, "name"); } - const payload: Record = { + const payload: JsonObject = { user, auto_generate: resolvedAutoGenerate, }; @@ -240,7 +246,7 @@ export class ChatClient extends DifyClient { deleteConversation( conversationId: string, user: string - ): Promise>> { + ): Promise> { ensureNonEmptyString(conversationId, "conversationId"); ensureNonEmptyString(user, "user"); return this.http.request({ @@ -256,7 +262,7 @@ export class ChatClient extends DifyClient { lastId?: string | null, limit?: number | null, variableName?: string | null - ): Promise>> { + ): Promise> { ensureNonEmptyString(conversationId, "conversationId"); ensureNonEmptyString(user, "user"); ensureOptionalString(lastId, "lastId"); @@ -279,8 +285,8 @@ export class ChatClient extends DifyClient { conversationId: string, variableId: string, user: string, - value: unknown - ): Promise>> { + value: JsonValue + ): Promise> { ensureNonEmptyString(conversationId, "conversationId"); ensureNonEmptyString(variableId, "variableId"); ensureNonEmptyString(user, "user"); diff --git a/sdks/nodejs-client/src/client/completion.test.js b/sdks/nodejs-client/src/client/completion.test.ts similarity index 100% rename from sdks/nodejs-client/src/client/completion.test.js rename to sdks/nodejs-client/src/client/completion.test.ts diff --git a/sdks/nodejs-client/src/client/completion.ts b/sdks/nodejs-client/src/client/completion.ts index 9e39898e8b..f4e7121776 100644 --- a/sdks/nodejs-client/src/client/completion.ts +++ b/sdks/nodejs-client/src/client/completion.ts @@ -1,6 +1,11 @@ import { DifyClient } from "./base"; import type { CompletionRequest, CompletionResponse } from "../types/completion"; -import type { DifyResponse, DifyStream } from "../types/common"; +import type { + DifyResponse, + DifyStream, + JsonObject, + SuccessResponse, +} from "../types/common"; import { ensureNonEmptyString } from "./validation"; const warned = new Set(); @@ -17,16 +22,16 @@ export class CompletionClient extends DifyClient { request: CompletionRequest ): Promise | DifyStream>; createCompletionMessage( - inputs: Record, + inputs: JsonObject, user: string, stream?: boolean, - files?: Array> | null + files?: CompletionRequest["files"] ): Promise | DifyStream>; createCompletionMessage( - inputOrRequest: CompletionRequest | Record, + inputOrRequest: CompletionRequest | JsonObject, user?: string, stream = false, - files?: Array> | null + files?: CompletionRequest["files"] ): Promise | DifyStream> { let payload: CompletionRequest; let shouldStream = stream; @@ -37,7 +42,7 @@ export class CompletionClient extends DifyClient { } else { ensureNonEmptyString(user, "user"); payload = { - inputs: inputOrRequest as Record, + inputs: inputOrRequest, user, files, response_mode: stream ? "streaming" : "blocking", @@ -64,10 +69,10 @@ export class CompletionClient extends DifyClient { stopCompletionMessage( taskId: string, user: string - ): Promise> { + ): Promise> { ensureNonEmptyString(taskId, "taskId"); ensureNonEmptyString(user, "user"); - return this.http.request({ + return this.http.request({ method: "POST", path: `/completion-messages/${taskId}/stop`, data: { user }, @@ -77,15 +82,15 @@ export class CompletionClient extends DifyClient { stop( taskId: string, user: string - ): Promise> { + ): Promise> { return this.stopCompletionMessage(taskId, user); } runWorkflow( - inputs: Record, + inputs: JsonObject, user: string, stream = false - ): Promise> | DifyStream>> { + ): Promise | DifyStream> { warnOnce( "CompletionClient.runWorkflow is deprecated. Use WorkflowClient.run instead." ); @@ -96,13 +101,13 @@ export class CompletionClient extends DifyClient { response_mode: stream ? "streaming" : "blocking", }; if (stream) { - return this.http.requestStream>({ + return this.http.requestStream({ method: "POST", path: "/workflows/run", data: payload, }); } - return this.http.request>({ + return this.http.request({ method: "POST", path: "/workflows/run", data: payload, diff --git a/sdks/nodejs-client/src/client/knowledge-base.test.js b/sdks/nodejs-client/src/client/knowledge-base.test.ts similarity index 92% rename from sdks/nodejs-client/src/client/knowledge-base.test.js rename to sdks/nodejs-client/src/client/knowledge-base.test.ts index 4381b39e56..113a9db24b 100644 --- a/sdks/nodejs-client/src/client/knowledge-base.test.js +++ b/sdks/nodejs-client/src/client/knowledge-base.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FileUploadError, ValidationError } from "../errors/dify-error"; import { KnowledgeBaseClient } from "./knowledge-base"; import { createHttpClientWithSpies } from "../../tests/test-utils"; @@ -174,7 +175,6 @@ describe("KnowledgeBaseClient", () => { it("handles pipeline operations", async () => { const { client, request, requestStream } = createHttpClientWithSpies(); const kb = new KnowledgeBaseClient(client); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const form = { append: vi.fn(), getHeaders: () => ({}) }; await kb.listDatasourcePlugins("ds", { isPublished: true }); @@ -201,7 +201,6 @@ describe("KnowledgeBaseClient", () => { }); await kb.uploadPipelineFile(form); - expect(warn).toHaveBeenCalled(); expect(request).toHaveBeenCalledWith({ method: "GET", path: "/datasets/ds/pipeline/datasource-plugins", @@ -246,4 +245,22 @@ describe("KnowledgeBaseClient", () => { data: form, }); }); + + it("validates form-data and optional array filters", async () => { + const { client } = createHttpClientWithSpies(); + const kb = new KnowledgeBaseClient(client); + + await expect(kb.createDocumentByFile("ds", {})).rejects.toBeInstanceOf( + FileUploadError + ); + await expect( + kb.listSegments("ds", "doc", { status: ["ok", 1] as unknown as string[] }) + ).rejects.toBeInstanceOf(ValidationError); + await expect( + kb.hitTesting("ds", { + query: "q", + attachment_ids: ["att-1", 2] as unknown as string[], + }) + ).rejects.toBeInstanceOf(ValidationError); + }); }); diff --git a/sdks/nodejs-client/src/client/knowledge-base.ts b/sdks/nodejs-client/src/client/knowledge-base.ts index 7a0e39898b..9871c098e9 100644 --- a/sdks/nodejs-client/src/client/knowledge-base.ts +++ b/sdks/nodejs-client/src/client/knowledge-base.ts @@ -38,22 +38,17 @@ import { ensureStringArray, } from "./validation"; import { FileUploadError, ValidationError } from "../errors/dify-error"; +import type { SdkFormData } from "../http/form-data"; import { isFormData } from "../http/form-data"; -const warned = new Set(); -const warnOnce = (message: string): void => { - if (warned.has(message)) { - return; - } - warned.add(message); - console.warn(message); -}; - -const ensureFormData = (form: unknown, context: string): void => { +function ensureFormData( + form: unknown, + context: string +): asserts form is SdkFormData { if (!isFormData(form)) { throw new FileUploadError(`${context} requires FormData`); } -}; +} const ensureNonEmptyArray = (value: unknown, name: string): void => { if (!Array.isArray(value) || value.length === 0) { @@ -61,12 +56,6 @@ const ensureNonEmptyArray = (value: unknown, name: string): void => { } }; -const warnPipelineRoutes = (): void => { - warnOnce( - "RAG pipeline endpoints may be unavailable unless the service API registers dataset/rag_pipeline routes." - ); -}; - export class KnowledgeBaseClient extends DifyClient { async listDatasets( options?: DatasetListOptions @@ -641,7 +630,6 @@ export class KnowledgeBaseClient extends DifyClient { datasetId: string, options?: DatasourcePluginListOptions ): Promise> { - warnPipelineRoutes(); ensureNonEmptyString(datasetId, "datasetId"); ensureOptionalBoolean(options?.isPublished, "isPublished"); return this.http.request({ @@ -658,7 +646,6 @@ export class KnowledgeBaseClient extends DifyClient { nodeId: string, request: DatasourceNodeRunRequest ): Promise> { - warnPipelineRoutes(); ensureNonEmptyString(datasetId, "datasetId"); ensureNonEmptyString(nodeId, "nodeId"); ensureNonEmptyString(request.datasource_type, "datasource_type"); @@ -673,7 +660,6 @@ export class KnowledgeBaseClient extends DifyClient { datasetId: string, request: PipelineRunRequest ): Promise | DifyStream> { - warnPipelineRoutes(); ensureNonEmptyString(datasetId, "datasetId"); ensureNonEmptyString(request.datasource_type, "datasource_type"); ensureNonEmptyString(request.start_node_id, "start_node_id"); @@ -695,7 +681,6 @@ export class KnowledgeBaseClient extends DifyClient { async uploadPipelineFile( form: unknown ): Promise> { - warnPipelineRoutes(); ensureFormData(form, "uploadPipelineFile"); return this.http.request({ method: "POST", diff --git a/sdks/nodejs-client/src/client/validation.test.js b/sdks/nodejs-client/src/client/validation.test.ts similarity index 93% rename from sdks/nodejs-client/src/client/validation.test.js rename to sdks/nodejs-client/src/client/validation.test.ts index 65bfa471a6..384dd46309 100644 --- a/sdks/nodejs-client/src/client/validation.test.js +++ b/sdks/nodejs-client/src/client/validation.test.ts @@ -10,7 +10,7 @@ import { validateParams, } from "./validation"; -const makeLongString = (length) => "a".repeat(length); +const makeLongString = (length: number) => "a".repeat(length); describe("validation utilities", () => { it("ensureNonEmptyString throws on empty or whitespace", () => { @@ -19,9 +19,7 @@ describe("validation utilities", () => { }); it("ensureNonEmptyString throws on overly long strings", () => { - expect(() => - ensureNonEmptyString(makeLongString(10001), "name") - ).toThrow(); + expect(() => ensureNonEmptyString(makeLongString(10001), "name")).toThrow(); }); it("ensureOptionalString ignores undefined and validates when set", () => { @@ -73,7 +71,6 @@ describe("validation utilities", () => { expect(() => validateParams({ rating: "bad" })).toThrow(); expect(() => validateParams({ page: 1.1 })).toThrow(); expect(() => validateParams({ files: "bad" })).toThrow(); - // Empty strings are allowed for optional params (e.g., keyword: "" means no filter) expect(() => validateParams({ keyword: "" })).not.toThrow(); expect(() => validateParams({ name: makeLongString(10001) })).toThrow(); expect(() => diff --git a/sdks/nodejs-client/src/client/validation.ts b/sdks/nodejs-client/src/client/validation.ts index 6aeec36bdc..0fe747a8f9 100644 --- a/sdks/nodejs-client/src/client/validation.ts +++ b/sdks/nodejs-client/src/client/validation.ts @@ -1,4 +1,5 @@ import { ValidationError } from "../errors/dify-error"; +import { isRecord } from "../internal/type-guards"; const MAX_STRING_LENGTH = 10000; const MAX_LIST_LENGTH = 1000; @@ -109,8 +110,8 @@ export function validateParams(params: Record): void { `Parameter '${key}' exceeds maximum size of ${MAX_LIST_LENGTH} items` ); } - } else if (typeof value === "object") { - if (Object.keys(value as Record).length > MAX_DICT_LENGTH) { + } else if (isRecord(value)) { + if (Object.keys(value).length > MAX_DICT_LENGTH) { throw new ValidationError( `Parameter '${key}' exceeds maximum size of ${MAX_DICT_LENGTH} items` ); diff --git a/sdks/nodejs-client/src/client/workflow.test.js b/sdks/nodejs-client/src/client/workflow.test.ts similarity index 97% rename from sdks/nodejs-client/src/client/workflow.test.js rename to sdks/nodejs-client/src/client/workflow.test.ts index 79c419b55a..281540304e 100644 --- a/sdks/nodejs-client/src/client/workflow.test.js +++ b/sdks/nodejs-client/src/client/workflow.test.ts @@ -90,7 +90,6 @@ describe("WorkflowClient", () => { const { client, request } = createHttpClientWithSpies(); const workflow = new WorkflowClient(client); - // Use createdByEndUserSessionId to filter by user session (backend API parameter) await workflow.getLogs({ keyword: "k", status: "succeeded", diff --git a/sdks/nodejs-client/src/client/workflow.ts b/sdks/nodejs-client/src/client/workflow.ts index ae4d5861fa..6e073b12d2 100644 --- a/sdks/nodejs-client/src/client/workflow.ts +++ b/sdks/nodejs-client/src/client/workflow.ts @@ -1,6 +1,12 @@ import { DifyClient } from "./base"; import type { WorkflowRunRequest, WorkflowRunResponse } from "../types/workflow"; -import type { DifyResponse, DifyStream, QueryParams } from "../types/common"; +import type { + DifyResponse, + DifyStream, + JsonObject, + QueryParams, + SuccessResponse, +} from "../types/common"; import { ensureNonEmptyString, ensureOptionalInt, @@ -12,12 +18,12 @@ export class WorkflowClient extends DifyClient { request: WorkflowRunRequest ): Promise | DifyStream>; run( - inputs: Record, + inputs: JsonObject, user: string, stream?: boolean ): Promise | DifyStream>; run( - inputOrRequest: WorkflowRunRequest | Record, + inputOrRequest: WorkflowRunRequest | JsonObject, user?: string, stream = false ): Promise | DifyStream> { @@ -30,7 +36,7 @@ export class WorkflowClient extends DifyClient { } else { ensureNonEmptyString(user, "user"); payload = { - inputs: inputOrRequest as Record, + inputs: inputOrRequest, user, response_mode: stream ? "streaming" : "blocking", }; @@ -84,10 +90,10 @@ export class WorkflowClient extends DifyClient { stop( taskId: string, user: string - ): Promise> { + ): Promise> { ensureNonEmptyString(taskId, "taskId"); ensureNonEmptyString(user, "user"); - return this.http.request({ + return this.http.request({ method: "POST", path: `/workflows/tasks/${taskId}/stop`, data: { user }, @@ -111,7 +117,7 @@ export class WorkflowClient extends DifyClient { limit?: number; startTime?: string; endTime?: string; - }): Promise>> { + }): Promise> { if (options?.keyword) { ensureOptionalString(options.keyword, "keyword"); } diff --git a/sdks/nodejs-client/src/client/workspace.test.js b/sdks/nodejs-client/src/client/workspace.test.ts similarity index 100% rename from sdks/nodejs-client/src/client/workspace.test.js rename to sdks/nodejs-client/src/client/workspace.test.ts diff --git a/sdks/nodejs-client/src/errors/dify-error.test.js b/sdks/nodejs-client/src/errors/dify-error.test.ts similarity index 100% rename from sdks/nodejs-client/src/errors/dify-error.test.js rename to sdks/nodejs-client/src/errors/dify-error.test.ts diff --git a/sdks/nodejs-client/src/http/client.test.js b/sdks/nodejs-client/src/http/client.test.js deleted file mode 100644 index 05892547ed..0000000000 --- a/sdks/nodejs-client/src/http/client.test.js +++ /dev/null @@ -1,304 +0,0 @@ -import axios from "axios"; -import { Readable } from "node:stream"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - APIError, - AuthenticationError, - FileUploadError, - NetworkError, - RateLimitError, - TimeoutError, - ValidationError, -} from "../errors/dify-error"; -import { HttpClient } from "./client"; - -describe("HttpClient", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - it("builds requests with auth headers and JSON content type", async () => { - const mockRequest = vi.fn().mockResolvedValue({ - status: 200, - data: { ok: true }, - headers: { "x-request-id": "req" }, - }); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - - const client = new HttpClient({ apiKey: "test" }); - const response = await client.request({ - method: "POST", - path: "/chat-messages", - data: { user: "u" }, - }); - - expect(response.requestId).toBe("req"); - const config = mockRequest.mock.calls[0][0]; - expect(config.headers.Authorization).toBe("Bearer test"); - expect(config.headers["Content-Type"]).toBe("application/json"); - expect(config.responseType).toBe("json"); - }); - - it("serializes array query params", async () => { - const mockRequest = vi.fn().mockResolvedValue({ - status: 200, - data: "ok", - headers: {}, - }); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - - const client = new HttpClient({ apiKey: "test" }); - await client.requestRaw({ - method: "GET", - path: "/datasets", - query: { tag_ids: ["a", "b"], limit: 2 }, - }); - - const config = mockRequest.mock.calls[0][0]; - const queryString = config.paramsSerializer.serialize({ - tag_ids: ["a", "b"], - limit: 2, - }); - expect(queryString).toBe("tag_ids=a&tag_ids=b&limit=2"); - }); - - it("returns SSE stream helpers", async () => { - const mockRequest = vi.fn().mockResolvedValue({ - status: 200, - data: Readable.from(["data: {\"text\":\"hi\"}\n\n"]), - headers: { "x-request-id": "req" }, - }); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - - const client = new HttpClient({ apiKey: "test" }); - const stream = await client.requestStream({ - method: "POST", - path: "/chat-messages", - data: { user: "u" }, - }); - - expect(stream.status).toBe(200); - expect(stream.requestId).toBe("req"); - await expect(stream.toText()).resolves.toBe("hi"); - }); - - it("returns binary stream helpers", async () => { - const mockRequest = vi.fn().mockResolvedValue({ - status: 200, - data: Readable.from(["chunk"]), - headers: { "x-request-id": "req" }, - }); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - - const client = new HttpClient({ apiKey: "test" }); - const stream = await client.requestBinaryStream({ - method: "POST", - path: "/text-to-audio", - data: { user: "u", text: "hi" }, - }); - - expect(stream.status).toBe(200); - expect(stream.requestId).toBe("req"); - }); - - it("respects form-data headers", async () => { - const mockRequest = vi.fn().mockResolvedValue({ - status: 200, - data: "ok", - headers: {}, - }); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - - const client = new HttpClient({ apiKey: "test" }); - const form = { - append: () => {}, - getHeaders: () => ({ "content-type": "multipart/form-data; boundary=abc" }), - }; - - await client.requestRaw({ - method: "POST", - path: "/files/upload", - data: form, - }); - - const config = mockRequest.mock.calls[0][0]; - expect(config.headers["content-type"]).toBe( - "multipart/form-data; boundary=abc" - ); - expect(config.headers["Content-Type"]).toBeUndefined(); - }); - - it("maps 401 and 429 errors", async () => { - const mockRequest = vi.fn(); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); - - mockRequest.mockRejectedValueOnce({ - isAxiosError: true, - response: { - status: 401, - data: { message: "unauthorized" }, - headers: {}, - }, - }); - await expect( - client.requestRaw({ method: "GET", path: "/meta" }) - ).rejects.toBeInstanceOf(AuthenticationError); - - mockRequest.mockRejectedValueOnce({ - isAxiosError: true, - response: { - status: 429, - data: { message: "rate" }, - headers: { "retry-after": "2" }, - }, - }); - const error = await client - .requestRaw({ method: "GET", path: "/meta" }) - .catch((err) => err); - expect(error).toBeInstanceOf(RateLimitError); - expect(error.retryAfter).toBe(2); - }); - - it("maps validation and upload errors", async () => { - const mockRequest = vi.fn(); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); - - mockRequest.mockRejectedValueOnce({ - isAxiosError: true, - response: { - status: 422, - data: { message: "invalid" }, - headers: {}, - }, - }); - await expect( - client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } }) - ).rejects.toBeInstanceOf(ValidationError); - - mockRequest.mockRejectedValueOnce({ - isAxiosError: true, - config: { url: "/files/upload" }, - response: { - status: 400, - data: { message: "bad upload" }, - headers: {}, - }, - }); - await expect( - client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } }) - ).rejects.toBeInstanceOf(FileUploadError); - }); - - it("maps timeout and network errors", async () => { - const mockRequest = vi.fn(); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); - - mockRequest.mockRejectedValueOnce({ - isAxiosError: true, - code: "ECONNABORTED", - message: "timeout", - }); - await expect( - client.requestRaw({ method: "GET", path: "/meta" }) - ).rejects.toBeInstanceOf(TimeoutError); - - mockRequest.mockRejectedValueOnce({ - isAxiosError: true, - message: "network", - }); - await expect( - client.requestRaw({ method: "GET", path: "/meta" }) - ).rejects.toBeInstanceOf(NetworkError); - }); - - it("retries on timeout errors", async () => { - const mockRequest = vi.fn(); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 }); - - mockRequest - .mockRejectedValueOnce({ - isAxiosError: true, - code: "ECONNABORTED", - message: "timeout", - }) - .mockResolvedValueOnce({ status: 200, data: "ok", headers: {} }); - - await client.requestRaw({ method: "GET", path: "/meta" }); - expect(mockRequest).toHaveBeenCalledTimes(2); - }); - - it("validates query parameters before request", async () => { - const mockRequest = vi.fn(); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - const client = new HttpClient({ apiKey: "test" }); - - await expect( - client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } }) - ).rejects.toBeInstanceOf(ValidationError); - expect(mockRequest).not.toHaveBeenCalled(); - }); - - it("returns APIError for other http failures", async () => { - const mockRequest = vi.fn(); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); - - mockRequest.mockRejectedValueOnce({ - isAxiosError: true, - response: { status: 500, data: { message: "server" }, headers: {} }, - }); - - await expect( - client.requestRaw({ method: "GET", path: "/meta" }) - ).rejects.toBeInstanceOf(APIError); - }); - - it("logs requests and responses when enableLogging is true", async () => { - const mockRequest = vi.fn().mockResolvedValue({ - status: 200, - data: { ok: true }, - headers: {}, - }); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); - - const client = new HttpClient({ apiKey: "test", enableLogging: true }); - await client.requestRaw({ method: "GET", path: "/meta" }); - - expect(consoleInfo).toHaveBeenCalledWith( - expect.stringContaining("dify-client-node response 200 GET") - ); - consoleInfo.mockRestore(); - }); - - it("logs retry attempts when enableLogging is true", async () => { - const mockRequest = vi.fn(); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); - - const client = new HttpClient({ - apiKey: "test", - maxRetries: 1, - retryDelay: 0, - enableLogging: true, - }); - - mockRequest - .mockRejectedValueOnce({ - isAxiosError: true, - code: "ECONNABORTED", - message: "timeout", - }) - .mockResolvedValueOnce({ status: 200, data: "ok", headers: {} }); - - await client.requestRaw({ method: "GET", path: "/meta" }); - - expect(consoleInfo).toHaveBeenCalledWith( - expect.stringContaining("dify-client-node retry") - ); - consoleInfo.mockRestore(); - }); -}); diff --git a/sdks/nodejs-client/src/http/client.test.ts b/sdks/nodejs-client/src/http/client.test.ts new file mode 100644 index 0000000000..af859801c6 --- /dev/null +++ b/sdks/nodejs-client/src/http/client.test.ts @@ -0,0 +1,527 @@ +import { Readable, Stream } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + APIError, + AuthenticationError, + FileUploadError, + NetworkError, + RateLimitError, + TimeoutError, + ValidationError, +} from "../errors/dify-error"; +import { HttpClient } from "./client"; + +const stubFetch = (): ReturnType => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; + +const getFetchCall = ( + fetchMock: ReturnType, + index = 0 +): [string, RequestInit | undefined] => { + const call = fetchMock.mock.calls[index]; + if (!call) { + throw new Error(`Missing fetch call at index ${index}`); + } + return call as [string, RequestInit | undefined]; +}; + +const toHeaderRecord = (headers: HeadersInit | undefined): Record => + Object.fromEntries(new Headers(headers).entries()); + +const jsonResponse = ( + body: unknown, + init: ResponseInit = {} +): Response => + new Response(JSON.stringify(body), { + ...init, + headers: { + "content-type": "application/json", + ...(init.headers ?? {}), + }, + }); + +const textResponse = (body: string, init: ResponseInit = {}): Response => + new Response(body, { + ...init, + headers: { + ...(init.headers ?? {}), + }, + }); + +describe("HttpClient", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("builds requests with auth headers and JSON content type", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce( + jsonResponse({ ok: true }, { status: 200, headers: { "x-request-id": "req" } }) + ); + + const client = new HttpClient({ apiKey: "test" }); + const response = await client.request({ + method: "POST", + path: "/chat-messages", + data: { user: "u" }, + }); + + expect(response.requestId).toBe("req"); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = getFetchCall(fetchMock); + expect(url).toBe("https://api.dify.ai/v1/chat-messages"); + expect(toHeaderRecord(init?.headers)).toMatchObject({ + authorization: "Bearer test", + "content-type": "application/json", + "user-agent": "dify-client-node", + }); + expect(init?.body).toBe(JSON.stringify({ user: "u" })); + }); + + it("serializes array query params", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); + + const client = new HttpClient({ apiKey: "test" }); + await client.requestRaw({ + method: "GET", + path: "/datasets", + query: { tag_ids: ["a", "b"], limit: 2 }, + }); + + const [url] = getFetchCall(fetchMock); + expect(new URL(url).searchParams.toString()).toBe( + "tag_ids=a&tag_ids=b&limit=2" + ); + }); + + it("returns SSE stream helpers", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce( + new Response('data: {"text":"hi"}\n\n', { + status: 200, + headers: { "x-request-id": "req" }, + }) + ); + + const client = new HttpClient({ apiKey: "test" }); + const stream = await client.requestStream({ + method: "POST", + path: "/chat-messages", + data: { user: "u" }, + }); + + expect(stream.status).toBe(200); + expect(stream.requestId).toBe("req"); + await expect(stream.toText()).resolves.toBe("hi"); + }); + + it("returns binary stream helpers", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce( + new Response("chunk", { + status: 200, + headers: { "x-request-id": "req" }, + }) + ); + + const client = new HttpClient({ apiKey: "test" }); + const stream = await client.requestBinaryStream({ + method: "POST", + path: "/text-to-audio", + data: { user: "u", text: "hi" }, + }); + + expect(stream.status).toBe(200); + expect(stream.requestId).toBe("req"); + expect(stream.data).toBeInstanceOf(Readable); + }); + + it("respects form-data headers", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); + + const client = new HttpClient({ apiKey: "test" }); + const form = new FormData(); + form.append("file", new Blob(["abc"]), "file.txt"); + + await client.requestRaw({ + method: "POST", + path: "/files/upload", + data: form, + }); + + const [, init] = getFetchCall(fetchMock); + expect(toHeaderRecord(init?.headers)).toMatchObject({ + authorization: "Bearer test", + }); + expect(toHeaderRecord(init?.headers)["content-type"]).toBeUndefined(); + }); + + it("sends legacy form-data as a readable request body", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); + + const client = new HttpClient({ apiKey: "test" }); + const legacyForm = Object.assign(Readable.from(["chunk"]), { + append: vi.fn(), + getHeaders: () => ({ + "content-type": "multipart/form-data; boundary=test", + }), + }); + + await client.requestRaw({ + method: "POST", + path: "/files/upload", + data: legacyForm, + }); + + const [, init] = getFetchCall(fetchMock); + expect(toHeaderRecord(init?.headers)).toMatchObject({ + authorization: "Bearer test", + "content-type": "multipart/form-data; boundary=test", + }); + expect((init as RequestInit & { duplex?: string } | undefined)?.duplex).toBe( + "half" + ); + expect(init?.body).not.toBe(legacyForm); + }); + + it("rejects legacy form-data objects that are not readable streams", async () => { + const fetchMock = stubFetch(); + const client = new HttpClient({ apiKey: "test" }); + const legacyForm = { + append: vi.fn(), + getHeaders: () => ({ + "content-type": "multipart/form-data; boundary=test", + }), + }; + + await expect( + client.requestRaw({ + method: "POST", + path: "/files/upload", + data: legacyForm, + }) + ).rejects.toBeInstanceOf(FileUploadError); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("accepts legacy pipeable streams that are not Readable instances", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); + const client = new HttpClient({ apiKey: "test" }); + + const legacyStream = new Stream() as Stream & + NodeJS.ReadableStream & { + append: ReturnType; + getHeaders: () => Record; + }; + legacyStream.readable = true; + legacyStream.pause = () => legacyStream; + legacyStream.resume = () => legacyStream; + legacyStream.append = vi.fn(); + legacyStream.getHeaders = () => ({ + "content-type": "multipart/form-data; boundary=test", + }); + queueMicrotask(() => { + legacyStream.emit("data", Buffer.from("chunk")); + legacyStream.emit("end"); + }); + + await client.requestRaw({ + method: "POST", + path: "/files/upload", + data: legacyStream as unknown as FormData, + }); + + const [, init] = getFetchCall(fetchMock); + expect((init as RequestInit & { duplex?: string } | undefined)?.duplex).toBe( + "half" + ); + }); + + it("returns buffers for byte responses", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce( + new Response(Uint8Array.from([1, 2, 3]), { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }) + ); + + const client = new HttpClient({ apiKey: "test" }); + const response = await client.request({ + method: "GET", + path: "/files/file-1/preview", + responseType: "bytes", + }); + + expect(Buffer.isBuffer(response.data)).toBe(true); + expect(Array.from(response.data.values())).toEqual([1, 2, 3]); + }); + + it("keeps arraybuffer as a backward-compatible binary alias", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce( + new Response(Uint8Array.from([4, 5, 6]), { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }) + ); + + const client = new HttpClient({ apiKey: "test" }); + const response = await client.request({ + method: "GET", + path: "/files/file-1/preview", + responseType: "arraybuffer", + }); + + expect(Buffer.isBuffer(response.data)).toBe(true); + expect(Array.from(response.data.values())).toEqual([4, 5, 6]); + }); + + it("returns null for empty no-content responses", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + + const client = new HttpClient({ apiKey: "test" }); + const response = await client.requestRaw({ + method: "GET", + path: "/meta", + }); + + expect(response.data).toBeNull(); + }); + + it("maps 401 and 429 errors", async () => { + const fetchMock = stubFetch(); + fetchMock + .mockResolvedValueOnce( + jsonResponse({ message: "unauthorized" }, { status: 401 }) + ) + .mockResolvedValueOnce( + jsonResponse({ message: "rate" }, { status: 429, headers: { "retry-after": "2" } }) + ); + const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); + + await expect( + client.requestRaw({ method: "GET", path: "/meta" }) + ).rejects.toBeInstanceOf(AuthenticationError); + + const error = await client + .requestRaw({ method: "GET", path: "/meta" }) + .catch((err: unknown) => err); + expect(error).toBeInstanceOf(RateLimitError); + expect((error as RateLimitError).retryAfter).toBe(2); + }); + + it("maps validation and upload errors", async () => { + const fetchMock = stubFetch(); + fetchMock + .mockResolvedValueOnce(jsonResponse({ message: "invalid" }, { status: 422 })) + .mockResolvedValueOnce(jsonResponse({ message: "bad upload" }, { status: 400 })); + const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); + + await expect( + client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } }) + ).rejects.toBeInstanceOf(ValidationError); + + await expect( + client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } }) + ).rejects.toBeInstanceOf(FileUploadError); + }); + + it("maps timeout and network errors", async () => { + const fetchMock = stubFetch(); + fetchMock + .mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" })) + .mockRejectedValueOnce(new Error("network")); + const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); + + await expect( + client.requestRaw({ method: "GET", path: "/meta" }) + ).rejects.toBeInstanceOf(TimeoutError); + + await expect( + client.requestRaw({ method: "GET", path: "/meta" }) + ).rejects.toBeInstanceOf(NetworkError); + }); + + it("maps unknown transport failures to NetworkError", async () => { + const fetchMock = stubFetch(); + fetchMock.mockRejectedValueOnce("boom"); + const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); + + await expect( + client.requestRaw({ method: "GET", path: "/meta" }) + ).rejects.toMatchObject({ + name: "NetworkError", + message: "Unexpected network error", + }); + }); + + it("retries on timeout errors", async () => { + const fetchMock = stubFetch(); + fetchMock + .mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" })) + .mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); + const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 }); + + await client.requestRaw({ method: "GET", path: "/meta" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does not retry non-replayable readable request bodies", async () => { + const fetchMock = stubFetch(); + fetchMock.mockRejectedValueOnce(new Error("network")); + const client = new HttpClient({ apiKey: "test", maxRetries: 2, retryDelay: 0 }); + + await expect( + client.requestRaw({ + method: "POST", + path: "/chat-messages", + data: Readable.from(["chunk"]), + }) + ).rejects.toBeInstanceOf(NetworkError); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = getFetchCall(fetchMock); + expect((init as RequestInit & { duplex?: string } | undefined)?.duplex).toBe( + "half" + ); + }); + + it("validates query parameters before request", async () => { + const fetchMock = stubFetch(); + const client = new HttpClient({ apiKey: "test" }); + + await expect( + client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } }) + ).rejects.toBeInstanceOf(ValidationError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns APIError for other http failures", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce(jsonResponse({ message: "server" }, { status: 500 })); + const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); + + await expect( + client.requestRaw({ method: "GET", path: "/meta" }) + ).rejects.toBeInstanceOf(APIError); + }); + + it("uses plain text bodies when json parsing is not possible", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce( + textResponse("plain text", { + status: 200, + headers: { "content-type": "text/plain" }, + }) + ); + const client = new HttpClient({ apiKey: "test" }); + + const response = await client.requestRaw({ + method: "GET", + path: "/info", + }); + + expect(response.data).toBe("plain text"); + }); + + it("keeps invalid json error bodies as API errors", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce( + textResponse("{invalid", { + status: 500, + headers: { "content-type": "application/json", "x-request-id": "req-500" }, + }) + ); + const client = new HttpClient({ apiKey: "test", maxRetries: 0 }); + + await expect( + client.requestRaw({ method: "GET", path: "/meta" }) + ).rejects.toMatchObject({ + name: "APIError", + statusCode: 500, + requestId: "req-500", + responseBody: "{invalid", + }); + }); + + it("sends raw string bodies without additional json encoding", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); + const client = new HttpClient({ apiKey: "test" }); + + await client.requestRaw({ + method: "POST", + path: "/meta", + data: '{"pre":"serialized"}', + headers: { "Content-Type": "application/custom+json" }, + }); + + const [, init] = getFetchCall(fetchMock); + expect(init?.body).toBe('{"pre":"serialized"}'); + expect(toHeaderRecord(init?.headers)).toMatchObject({ + "content-type": "application/custom+json", + }); + }); + + it("preserves explicit user-agent headers", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 })); + const client = new HttpClient({ apiKey: "test" }); + + await client.requestRaw({ + method: "GET", + path: "/meta", + headers: { "User-Agent": "custom-agent" }, + }); + + const [, init] = getFetchCall(fetchMock); + expect(toHeaderRecord(init?.headers)).toMatchObject({ + "user-agent": "custom-agent", + }); + }); + + it("logs requests and responses when enableLogging is true", async () => { + const fetchMock = stubFetch(); + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 })); + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); + + const client = new HttpClient({ apiKey: "test", enableLogging: true }); + await client.requestRaw({ method: "GET", path: "/meta" }); + + expect(consoleInfo).toHaveBeenCalledWith( + expect.stringContaining("dify-client-node response 200 GET") + ); + }); + + it("logs retry attempts when enableLogging is true", async () => { + const fetchMock = stubFetch(); + fetchMock + .mockRejectedValueOnce(Object.assign(new Error("timeout"), { name: "AbortError" })) + .mockResolvedValueOnce(jsonResponse("ok", { status: 200 })); + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); + + const client = new HttpClient({ + apiKey: "test", + maxRetries: 1, + retryDelay: 0, + enableLogging: true, + }); + + await client.requestRaw({ method: "GET", path: "/meta" }); + + expect(consoleInfo).toHaveBeenCalledWith( + expect.stringContaining("dify-client-node retry") + ); + }); +}); diff --git a/sdks/nodejs-client/src/http/client.ts b/sdks/nodejs-client/src/http/client.ts index 44b63c9903..c233d9807d 100644 --- a/sdks/nodejs-client/src/http/client.ts +++ b/sdks/nodejs-client/src/http/client.ts @@ -1,11 +1,4 @@ -import axios from "axios"; -import type { - AxiosError, - AxiosInstance, - AxiosRequestConfig, - AxiosResponse, -} from "axios"; -import type { Readable } from "node:stream"; +import { Readable } from "node:stream"; import { DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, @@ -13,36 +6,69 @@ import { DEFAULT_TIMEOUT_SECONDS, } from "../types/common"; import type { + BinaryStream, DifyClientConfig, DifyResponse, + DifyStream, Headers, + JsonValue, QueryParams, RequestMethod, } from "../types/common"; -import type { DifyError } from "../errors/dify-error"; import { APIError, AuthenticationError, + DifyError, FileUploadError, NetworkError, RateLimitError, TimeoutError, ValidationError, } from "../errors/dify-error"; +import type { SdkFormData } from "./form-data"; import { getFormDataHeaders, isFormData } from "./form-data"; import { createBinaryStream, createSseStream } from "./sse"; import { getRetryDelayMs, shouldRetry, sleep } from "./retry"; import { validateParams } from "../client/validation"; +import { hasStringProperty, isRecord } from "../internal/type-guards"; const DEFAULT_USER_AGENT = "dify-client-node"; -export type RequestOptions = { +export type HttpResponseType = "json" | "bytes" | "stream" | "arraybuffer"; + +export type HttpRequestBody = + | JsonValue + | Readable + | SdkFormData + | URLSearchParams + | ArrayBuffer + | ArrayBufferView + | Blob + | string + | null; + +export type ResponseDataFor = + TResponseType extends "stream" + ? Readable + : TResponseType extends "bytes" | "arraybuffer" + ? Buffer + : JsonValue | string | null; + +export type RawHttpResponse = { + data: TData; + status: number; + headers: Headers; + requestId?: string; + url: string; +}; + +export type RequestOptions = { method: RequestMethod; path: string; query?: QueryParams; - data?: unknown; + data?: HttpRequestBody; headers?: Headers; - responseType?: AxiosRequestConfig["responseType"]; + responseType?: TResponseType; }; export type HttpClientSettings = Required< @@ -51,6 +77,23 @@ export type HttpClientSettings = Required< apiKey: string; }; +type FetchRequestInit = RequestInit & { + duplex?: "half"; +}; + +type PreparedRequestBody = { + body?: BodyInit | null; + headers: Headers; + duplex?: "half"; + replayable: boolean; +}; + +type TimeoutContext = { + cleanup: () => void; + reason: Error; + signal: AbortSignal; +}; + const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({ apiKey: config.apiKey, baseUrl: config.baseUrl ?? DEFAULT_BASE_URL, @@ -60,19 +103,10 @@ const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({ enableLogging: config.enableLogging ?? false, }); -const normalizeHeaders = (headers: AxiosResponse["headers"]): Headers => { +const normalizeHeaders = (headers: globalThis.Headers): Headers => { const result: Headers = {}; - if (!headers) { - return result; - } - Object.entries(headers).forEach(([key, value]) => { - if (Array.isArray(value)) { - result[key.toLowerCase()] = value.join(", "); - } else if (typeof value === "string") { - result[key.toLowerCase()] = value; - } else if (typeof value === "number") { - result[key.toLowerCase()] = value.toString(); - } + headers.forEach((value, key) => { + result[key.toLowerCase()] = value; }); return result; }; @@ -80,9 +114,18 @@ const normalizeHeaders = (headers: AxiosResponse["headers"]): Headers => { const resolveRequestId = (headers: Headers): string | undefined => headers["x-request-id"] ?? headers["x-requestid"]; -const buildRequestUrl = (baseUrl: string, path: string): string => { +const buildRequestUrl = ( + baseUrl: string, + path: string, + query?: QueryParams +): string => { const trimmed = baseUrl.replace(/\/+$/, ""); - return `${trimmed}${path}`; + const url = new URL(`${trimmed}${path}`); + const queryString = buildQueryString(query); + if (queryString) { + url.search = queryString; + } + return url.toString(); }; const buildQueryString = (params?: QueryParams): string => { @@ -121,24 +164,53 @@ const parseRetryAfterSeconds = (headerValue?: string): number | undefined => { return undefined; }; -const isReadableStream = (value: unknown): value is Readable => { +const isPipeableStream = (value: unknown): value is { pipe: (destination: unknown) => unknown } => { if (!value || typeof value !== "object") { return false; } return typeof (value as { pipe?: unknown }).pipe === "function"; }; -const isUploadLikeRequest = (config?: AxiosRequestConfig): boolean => { - const url = (config?.url ?? "").toLowerCase(); - if (!url) { - return false; +const toNodeReadable = (value: unknown): Readable | null => { + if (value instanceof Readable) { + return value; } + if (!isPipeableStream(value)) { + return null; + } + const readable = new Readable({ + read() {}, + }); + return readable.wrap(value as NodeJS.ReadableStream); +}; + +const isBinaryBody = ( + value: unknown +): value is ArrayBuffer | ArrayBufferView | Blob => { + if (value instanceof Blob) { + return true; + } + if (value instanceof ArrayBuffer) { + return true; + } + return ArrayBuffer.isView(value); +}; + +const isJsonBody = (value: unknown): value is Exclude => + value === null || + typeof value === "boolean" || + typeof value === "number" || + Array.isArray(value) || + isRecord(value); + +const isUploadLikeRequest = (path: string): boolean => { + const normalizedPath = path.toLowerCase(); return ( - url.includes("upload") || - url.includes("/files/") || - url.includes("audio-to-text") || - url.includes("create_by_file") || - url.includes("update_by_file") + normalizedPath.includes("upload") || + normalizedPath.includes("/files/") || + normalizedPath.includes("audio-to-text") || + normalizedPath.includes("create_by_file") || + normalizedPath.includes("update_by_file") ); }; @@ -146,88 +218,242 @@ const resolveErrorMessage = (status: number, responseBody: unknown): string => { if (typeof responseBody === "string" && responseBody.trim().length > 0) { return responseBody; } - if ( - responseBody && - typeof responseBody === "object" && - "message" in responseBody - ) { - const message = (responseBody as Record).message; - if (typeof message === "string" && message.trim().length > 0) { + if (hasStringProperty(responseBody, "message")) { + const message = responseBody.message.trim(); + if (message.length > 0) { return message; } } return `Request failed with status code ${status}`; }; -const mapAxiosError = (error: unknown): DifyError => { - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError; - if (axiosError.response) { - const status = axiosError.response.status; - const headers = normalizeHeaders(axiosError.response.headers); - const requestId = resolveRequestId(headers); - const responseBody = axiosError.response.data; - const message = resolveErrorMessage(status, responseBody); - - if (status === 401) { - return new AuthenticationError(message, { - statusCode: status, - responseBody, - requestId, - }); - } - if (status === 429) { - const retryAfter = parseRetryAfterSeconds(headers["retry-after"]); - return new RateLimitError(message, { - statusCode: status, - responseBody, - requestId, - retryAfter, - }); - } - if (status === 422) { - return new ValidationError(message, { - statusCode: status, - responseBody, - requestId, - }); - } - if (status === 400) { - if (isUploadLikeRequest(axiosError.config)) { - return new FileUploadError(message, { - statusCode: status, - responseBody, - requestId, - }); - } - } - return new APIError(message, { - statusCode: status, - responseBody, - requestId, - }); - } - if (axiosError.code === "ECONNABORTED") { - return new TimeoutError("Request timed out", { cause: axiosError }); - } - return new NetworkError(axiosError.message, { cause: axiosError }); +const parseJsonLikeText = ( + value: string, + contentType?: string | null +): JsonValue | string | null => { + if (value.length === 0) { + return null; } + const shouldParseJson = + contentType?.includes("application/json") === true || + contentType?.includes("+json") === true; + if (!shouldParseJson) { + try { + return JSON.parse(value) as JsonValue; + } catch { + return value; + } + } + return JSON.parse(value) as JsonValue; +}; + +const prepareRequestBody = ( + method: RequestMethod, + data: HttpRequestBody | undefined +): PreparedRequestBody => { + if (method === "GET" || data === undefined) { + return { + body: undefined, + headers: {}, + replayable: true, + }; + } + + if (isFormData(data)) { + if ("getHeaders" in data && typeof data.getHeaders === "function") { + const readable = toNodeReadable(data); + if (!readable) { + throw new FileUploadError( + "Legacy FormData must be a readable stream when used with fetch" + ); + } + return { + body: Readable.toWeb(readable) as BodyInit, + headers: getFormDataHeaders(data), + duplex: "half", + replayable: false, + }; + } + return { + body: data as BodyInit, + headers: getFormDataHeaders(data), + replayable: true, + }; + } + + if (typeof data === "string") { + return { + body: data, + headers: {}, + replayable: true, + }; + } + + const readable = toNodeReadable(data); + if (readable) { + return { + body: Readable.toWeb(readable) as BodyInit, + headers: {}, + duplex: "half", + replayable: false, + }; + } + + if (data instanceof URLSearchParams || isBinaryBody(data)) { + const body = + ArrayBuffer.isView(data) && !(data instanceof Uint8Array) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : data; + return { + body: body as BodyInit, + headers: {}, + replayable: true, + }; + } + + if (isJsonBody(data)) { + return { + body: JSON.stringify(data), + headers: { + "Content-Type": "application/json", + }, + replayable: true, + }; + } + + throw new ValidationError("Unsupported request body type"); +}; + +const createTimeoutContext = (timeoutMs: number): TimeoutContext => { + const controller = new AbortController(); + const reason = new Error("Request timed out"); + const timer = setTimeout(() => { + controller.abort(reason); + }, timeoutMs); + return { + signal: controller.signal, + reason, + cleanup: () => { + clearTimeout(timer); + }, + }; +}; + +const parseResponseBody = async ( + response: Response, + responseType: TResponseType +): Promise> => { + if (responseType === "stream") { + if (!response.body) { + throw new NetworkError("Response body is empty"); + } + return Readable.fromWeb( + response.body as unknown as Parameters[0] + ) as ResponseDataFor; + } + + if (responseType === "bytes" || responseType === "arraybuffer") { + const bytes = Buffer.from(await response.arrayBuffer()); + return bytes as ResponseDataFor; + } + + if (response.status === 204 || response.status === 205 || response.status === 304) { + return null as ResponseDataFor; + } + + const text = await response.text(); + try { + return parseJsonLikeText( + text, + response.headers.get("content-type") + ) as ResponseDataFor; + } catch (error) { + if (!response.ok && error instanceof SyntaxError) { + return text as ResponseDataFor; + } + throw error; + } +}; + +const mapHttpError = ( + response: RawHttpResponse, + path: string +): DifyError => { + const status = response.status; + const responseBody = response.data; + const message = resolveErrorMessage(status, responseBody); + + if (status === 401) { + return new AuthenticationError(message, { + statusCode: status, + responseBody, + requestId: response.requestId, + }); + } + + if (status === 429) { + const retryAfter = parseRetryAfterSeconds(response.headers["retry-after"]); + return new RateLimitError(message, { + statusCode: status, + responseBody, + requestId: response.requestId, + retryAfter, + }); + } + + if (status === 422) { + return new ValidationError(message, { + statusCode: status, + responseBody, + requestId: response.requestId, + }); + } + + if (status === 400 && isUploadLikeRequest(path)) { + return new FileUploadError(message, { + statusCode: status, + responseBody, + requestId: response.requestId, + }); + } + + return new APIError(message, { + statusCode: status, + responseBody, + requestId: response.requestId, + }); +}; + +const mapTransportError = ( + error: unknown, + timeoutContext: TimeoutContext +): DifyError => { + if (error instanceof DifyError) { + return error; + } + + if ( + timeoutContext.signal.aborted && + timeoutContext.signal.reason === timeoutContext.reason + ) { + return new TimeoutError("Request timed out", { cause: error }); + } + if (error instanceof Error) { + if (error.name === "AbortError" || error.name === "TimeoutError") { + return new TimeoutError("Request timed out", { cause: error }); + } return new NetworkError(error.message, { cause: error }); } + return new NetworkError("Unexpected network error", { cause: error }); }; export class HttpClient { - private axios: AxiosInstance; private settings: HttpClientSettings; constructor(config: DifyClientConfig) { this.settings = normalizeSettings(config); - this.axios = axios.create({ - baseURL: this.settings.baseUrl, - timeout: this.settings.timeout * 1000, - }); } updateApiKey(apiKey: string): void { @@ -238,118 +464,123 @@ export class HttpClient { return { ...this.settings }; } - async request(options: RequestOptions): Promise> { + async request< + T, + TResponseType extends HttpResponseType = "json", + >(options: RequestOptions): Promise> { const response = await this.requestRaw(options); - const headers = normalizeHeaders(response.headers); return { data: response.data as T, status: response.status, - headers, - requestId: resolveRequestId(headers), + headers: response.headers, + requestId: response.requestId, }; } - async requestStream(options: RequestOptions) { + async requestStream(options: RequestOptions): Promise> { const response = await this.requestRaw({ ...options, responseType: "stream", }); - const headers = normalizeHeaders(response.headers); - return createSseStream(response.data as Readable, { + return createSseStream(response.data, { status: response.status, - headers, - requestId: resolveRequestId(headers), + headers: response.headers, + requestId: response.requestId, }); } - async requestBinaryStream(options: RequestOptions) { + async requestBinaryStream(options: RequestOptions): Promise { const response = await this.requestRaw({ ...options, responseType: "stream", }); - const headers = normalizeHeaders(response.headers); - return createBinaryStream(response.data as Readable, { + return createBinaryStream(response.data, { status: response.status, - headers, - requestId: resolveRequestId(headers), + headers: response.headers, + requestId: response.requestId, }); } - async requestRaw(options: RequestOptions): Promise { - const { method, path, query, data, headers, responseType } = options; - const { apiKey, enableLogging, maxRetries, retryDelay, timeout } = - this.settings; + async requestRaw( + options: RequestOptions + ): Promise>> { + const responseType = options.responseType ?? "json"; + const { method, path, query, data, headers } = options; + const { apiKey, enableLogging, maxRetries, retryDelay, timeout } = this.settings; if (query) { validateParams(query as Record); } - if ( - data && - typeof data === "object" && - !Array.isArray(data) && - !isFormData(data) && - !isReadableStream(data) - ) { - validateParams(data as Record); + + if (isRecord(data) && !Array.isArray(data) && !isFormData(data) && !isPipeableStream(data)) { + validateParams(data); } - const requestHeaders: Headers = { - Authorization: `Bearer ${apiKey}`, - ...headers, - }; - if ( - typeof process !== "undefined" && - !!process.versions?.node && - !requestHeaders["User-Agent"] && - !requestHeaders["user-agent"] - ) { - requestHeaders["User-Agent"] = DEFAULT_USER_AGENT; - } - - if (isFormData(data)) { - Object.assign(requestHeaders, getFormDataHeaders(data)); - } else if (data && method !== "GET") { - requestHeaders["Content-Type"] = "application/json"; - } - - const url = buildRequestUrl(this.settings.baseUrl, path); + const url = buildRequestUrl(this.settings.baseUrl, path, query); if (enableLogging) { console.info(`dify-client-node request ${method} ${url}`); } - const axiosConfig: AxiosRequestConfig = { - method, - url: path, - params: query, - paramsSerializer: { - serialize: (params) => buildQueryString(params as QueryParams), - }, - headers: requestHeaders, - responseType: responseType ?? "json", - timeout: timeout * 1000, - }; - - if (method !== "GET" && data !== undefined) { - axiosConfig.data = data; - } - let attempt = 0; - // `attempt` is a zero-based retry counter - // Total attempts = 1 (initial) + maxRetries - // e.g., maxRetries=3 means: attempt 0 (initial), then retries at 1, 2, 3 while (true) { + const preparedBody = prepareRequestBody(method, data); + const requestHeaders: Headers = { + Authorization: `Bearer ${apiKey}`, + ...preparedBody.headers, + ...headers, + }; + + if ( + typeof process !== "undefined" && + !!process.versions?.node && + !requestHeaders["User-Agent"] && + !requestHeaders["user-agent"] + ) { + requestHeaders["User-Agent"] = DEFAULT_USER_AGENT; + } + + const timeoutContext = createTimeoutContext(timeout * 1000); + const requestInit: FetchRequestInit = { + method, + headers: requestHeaders, + body: preparedBody.body, + signal: timeoutContext.signal, + }; + + if (preparedBody.duplex) { + requestInit.duplex = preparedBody.duplex; + } + try { - const response = await this.axios.request(axiosConfig); + const fetchResponse = await fetch(url, requestInit); + const responseHeaders = normalizeHeaders(fetchResponse.headers); + const parsedBody = + (await parseResponseBody(fetchResponse, responseType)) as ResponseDataFor; + const response: RawHttpResponse> = { + data: parsedBody, + status: fetchResponse.status, + headers: responseHeaders, + requestId: resolveRequestId(responseHeaders), + url, + }; + + if (!fetchResponse.ok) { + throw mapHttpError(response, path); + } + if (enableLogging) { console.info( `dify-client-node response ${response.status} ${method} ${url}` ); } + return response; } catch (error) { - const mapped = mapAxiosError(error); - if (!shouldRetry(mapped, attempt, maxRetries)) { + const mapped = mapTransportError(error, timeoutContext); + const shouldRetryRequest = + preparedBody.replayable && shouldRetry(mapped, attempt, maxRetries); + if (!shouldRetryRequest) { throw mapped; } const retryAfterSeconds = @@ -362,6 +593,8 @@ export class HttpClient { } attempt += 1; await sleep(delay); + } finally { + timeoutContext.cleanup(); } } } diff --git a/sdks/nodejs-client/src/http/form-data.test.js b/sdks/nodejs-client/src/http/form-data.test.ts similarity index 73% rename from sdks/nodejs-client/src/http/form-data.test.js rename to sdks/nodejs-client/src/http/form-data.test.ts index 2938e41435..922f220c69 100644 --- a/sdks/nodejs-client/src/http/form-data.test.js +++ b/sdks/nodejs-client/src/http/form-data.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getFormDataHeaders, isFormData } from "./form-data"; describe("form-data helpers", () => { @@ -11,9 +11,15 @@ describe("form-data helpers", () => { expect(isFormData({})).toBe(false); }); + it("detects native FormData", () => { + const form = new FormData(); + form.append("field", "value"); + expect(isFormData(form)).toBe(true); + }); + it("returns headers from form-data", () => { const formLike = { - append: () => {}, + append: vi.fn(), getHeaders: () => ({ "content-type": "multipart/form-data" }), }; expect(getFormDataHeaders(formLike)).toEqual({ diff --git a/sdks/nodejs-client/src/http/form-data.ts b/sdks/nodejs-client/src/http/form-data.ts index 2efa23e54e..6091b7cfdd 100644 --- a/sdks/nodejs-client/src/http/form-data.ts +++ b/sdks/nodejs-client/src/http/form-data.ts @@ -1,19 +1,25 @@ import type { Headers } from "../types/common"; -export type FormDataLike = { - append: (...args: unknown[]) => void; - getHeaders?: () => Headers; +type FormDataAppendValue = Blob | string; + +export type WebFormData = FormData; + +export type LegacyNodeFormData = { + append: (name: string, value: FormDataAppendValue, fileName?: string) => void; + getHeaders: () => Headers; constructor?: { name?: string }; }; -export const isFormData = (value: unknown): value is FormDataLike => { +export type SdkFormData = WebFormData | LegacyNodeFormData; + +export const isFormData = (value: unknown): value is SdkFormData => { if (!value || typeof value !== "object") { return false; } if (typeof FormData !== "undefined" && value instanceof FormData) { return true; } - const candidate = value as FormDataLike; + const candidate = value as Partial; if (typeof candidate.append !== "function") { return false; } @@ -23,8 +29,8 @@ export const isFormData = (value: unknown): value is FormDataLike => { return candidate.constructor?.name === "FormData"; }; -export const getFormDataHeaders = (form: FormDataLike): Headers => { - if (typeof form.getHeaders === "function") { +export const getFormDataHeaders = (form: SdkFormData): Headers => { + if ("getHeaders" in form && typeof form.getHeaders === "function") { return form.getHeaders(); } return {}; diff --git a/sdks/nodejs-client/src/http/retry.test.js b/sdks/nodejs-client/src/http/retry.test.ts similarity index 94% rename from sdks/nodejs-client/src/http/retry.test.js rename to sdks/nodejs-client/src/http/retry.test.ts index fc017f631b..f53f7428b7 100644 --- a/sdks/nodejs-client/src/http/retry.test.js +++ b/sdks/nodejs-client/src/http/retry.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { getRetryDelayMs, shouldRetry } from "./retry"; import { NetworkError, RateLimitError, TimeoutError } from "../errors/dify-error"; -const withMockedRandom = (value, fn) => { +const withMockedRandom = (value: number, fn: () => void): void => { const original = Math.random; Math.random = () => value; try { diff --git a/sdks/nodejs-client/src/http/sse.test.js b/sdks/nodejs-client/src/http/sse.test.ts similarity index 73% rename from sdks/nodejs-client/src/http/sse.test.js rename to sdks/nodejs-client/src/http/sse.test.ts index fff85fd29b..70cd11007d 100644 --- a/sdks/nodejs-client/src/http/sse.test.js +++ b/sdks/nodejs-client/src/http/sse.test.ts @@ -6,10 +6,10 @@ describe("sse parsing", () => { it("parses event and data lines", async () => { const stream = Readable.from([ "event: message\n", - "data: {\"answer\":\"hi\"}\n", + 'data: {"answer":"hi"}\n', "\n", ]); - const events = []; + const events: Array<{ event?: string; data: unknown; raw: string }> = []; for await (const event of parseSseStream(stream)) { events.push(event); } @@ -20,7 +20,7 @@ describe("sse parsing", () => { it("handles multi-line data payloads", async () => { const stream = Readable.from(["data: line1\n", "data: line2\n", "\n"]); - const events = []; + const events: Array<{ event?: string; data: unknown; raw: string }> = []; for await (const event of parseSseStream(stream)) { events.push(event); } @@ -28,10 +28,28 @@ describe("sse parsing", () => { expect(events[0].data).toBe("line1\nline2"); }); + it("ignores comments and flushes the last event without a trailing separator", async () => { + const stream = Readable.from([ + Buffer.from(": keep-alive\n"), + Uint8Array.from(Buffer.from('event: message\ndata: {"delta":"hi"}\n')), + ]); + const events: Array<{ event?: string; data: unknown; raw: string }> = []; + for await (const event of parseSseStream(stream)) { + events.push(event); + } + expect(events).toEqual([ + { + event: "message", + data: { delta: "hi" }, + raw: '{"delta":"hi"}', + }, + ]); + }); + it("createSseStream exposes toText", async () => { const stream = Readable.from([ - "data: {\"answer\":\"hello\"}\n\n", - "data: {\"delta\":\" world\"}\n\n", + 'data: {"answer":"hello"}\n\n', + 'data: {"delta":" world"}\n\n', ]); const sseStream = createSseStream(stream, { status: 200, @@ -72,5 +90,6 @@ describe("sse parsing", () => { }); expect(binary.status).toBe(200); expect(binary.headers["content-type"]).toBe("audio/mpeg"); + expect(binary.toReadable()).toBe(stream); }); }); diff --git a/sdks/nodejs-client/src/http/sse.ts b/sdks/nodejs-client/src/http/sse.ts index ed5a17fe39..75a2544f71 100644 --- a/sdks/nodejs-client/src/http/sse.ts +++ b/sdks/nodejs-client/src/http/sse.ts @@ -1,12 +1,29 @@ import type { Readable } from "node:stream"; import { StringDecoder } from "node:string_decoder"; -import type { BinaryStream, DifyStream, Headers, StreamEvent } from "../types/common"; +import type { + BinaryStream, + DifyStream, + Headers, + JsonValue, + StreamEvent, +} from "../types/common"; +import { isRecord } from "../internal/type-guards"; + +const toBufferChunk = (chunk: unknown): Buffer => { + if (Buffer.isBuffer(chunk)) { + return chunk; + } + if (chunk instanceof Uint8Array) { + return Buffer.from(chunk); + } + return Buffer.from(String(chunk)); +}; const readLines = async function* (stream: Readable): AsyncIterable { const decoder = new StringDecoder("utf8"); let buffered = ""; for await (const chunk of stream) { - buffered += decoder.write(chunk as Buffer); + buffered += decoder.write(toBufferChunk(chunk)); let index = buffered.indexOf("\n"); while (index >= 0) { let line = buffered.slice(0, index); @@ -24,12 +41,12 @@ const readLines = async function* (stream: Readable): AsyncIterable { } }; -const parseMaybeJson = (value: string): unknown => { +const parseMaybeJson = (value: string): JsonValue | string | null => { if (!value) { return null; } try { - return JSON.parse(value); + return JSON.parse(value) as JsonValue; } catch { return value; } @@ -81,18 +98,17 @@ const extractTextFromEvent = (data: unknown): string => { if (typeof data === "string") { return data; } - if (!data || typeof data !== "object") { + if (!isRecord(data)) { return ""; } - const record = data as Record; - if (typeof record.answer === "string") { - return record.answer; + if (typeof data.answer === "string") { + return data.answer; } - if (typeof record.text === "string") { - return record.text; + if (typeof data.text === "string") { + return data.text; } - if (typeof record.delta === "string") { - return record.delta; + if (typeof data.delta === "string") { + return data.delta; } return ""; }; diff --git a/sdks/nodejs-client/src/index.test.js b/sdks/nodejs-client/src/index.test.js deleted file mode 100644 index 289f4d9b1b..0000000000 --- a/sdks/nodejs-client/src/index.test.js +++ /dev/null @@ -1,227 +0,0 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { ChatClient, DifyClient, WorkflowClient, BASE_URL, routes } from "./index"; -import axios from "axios"; - -const mockRequest = vi.fn(); - -const setupAxiosMock = () => { - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); -}; - -beforeEach(() => { - vi.restoreAllMocks(); - mockRequest.mockReset(); - setupAxiosMock(); -}); - -describe("Client", () => { - it("should create a client", () => { - new DifyClient("test"); - - expect(axios.create).toHaveBeenCalledWith({ - baseURL: BASE_URL, - timeout: 60000, - }); - }); - - it("should update the api key", () => { - const difyClient = new DifyClient("test"); - difyClient.updateApiKey("test2"); - - expect(difyClient.getHttpClient().getSettings().apiKey).toBe("test2"); - }); -}); - -describe("Send Requests", () => { - it("should make a successful request to the application parameter", async () => { - const difyClient = new DifyClient("test"); - const method = "GET"; - const endpoint = routes.application.url(); - mockRequest.mockResolvedValue({ - status: 200, - data: "response", - headers: {}, - }); - - await difyClient.sendRequest(method, endpoint); - - const requestConfig = mockRequest.mock.calls[0][0]; - expect(requestConfig).toMatchObject({ - method, - url: endpoint, - params: undefined, - responseType: "json", - timeout: 60000, - }); - expect(requestConfig.headers.Authorization).toBe("Bearer test"); - }); - - it("uses the getMeta route configuration", async () => { - const difyClient = new DifyClient("test"); - mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); - - await difyClient.getMeta("end-user"); - - expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ - method: routes.getMeta.method, - url: routes.getMeta.url(), - params: { user: "end-user" }, - headers: expect.objectContaining({ - Authorization: "Bearer test", - }), - responseType: "json", - timeout: 60000, - })); - }); -}); - -describe("File uploads", () => { - const OriginalFormData = globalThis.FormData; - - beforeAll(() => { - globalThis.FormData = class FormDataMock { - append() {} - - getHeaders() { - return { - "content-type": "multipart/form-data; boundary=test", - }; - } - }; - }); - - afterAll(() => { - globalThis.FormData = OriginalFormData; - }); - - it("does not override multipart boundary headers for FormData", async () => { - const difyClient = new DifyClient("test"); - const form = new globalThis.FormData(); - mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); - - await difyClient.fileUpload(form, "end-user"); - - expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ - method: routes.fileUpload.method, - url: routes.fileUpload.url(), - params: undefined, - headers: expect.objectContaining({ - Authorization: "Bearer test", - "content-type": "multipart/form-data; boundary=test", - }), - responseType: "json", - timeout: 60000, - data: form, - })); - }); -}); - -describe("Workflow client", () => { - it("uses tasks stop path for workflow stop", async () => { - const workflowClient = new WorkflowClient("test"); - mockRequest.mockResolvedValue({ status: 200, data: "stopped", headers: {} }); - - await workflowClient.stop("task-1", "end-user"); - - expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ - method: routes.stopWorkflow.method, - url: routes.stopWorkflow.url("task-1"), - params: undefined, - headers: expect.objectContaining({ - Authorization: "Bearer test", - "Content-Type": "application/json", - }), - responseType: "json", - timeout: 60000, - data: { user: "end-user" }, - })); - }); - - it("maps workflow log filters to service api params", async () => { - const workflowClient = new WorkflowClient("test"); - mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); - - await workflowClient.getLogs({ - createdAtAfter: "2024-01-01T00:00:00Z", - createdAtBefore: "2024-01-02T00:00:00Z", - createdByEndUserSessionId: "sess-1", - createdByAccount: "acc-1", - page: 2, - limit: 10, - }); - - expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ - method: "GET", - url: "/workflows/logs", - params: { - created_at__after: "2024-01-01T00:00:00Z", - created_at__before: "2024-01-02T00:00:00Z", - created_by_end_user_session_id: "sess-1", - created_by_account: "acc-1", - page: 2, - limit: 10, - }, - headers: expect.objectContaining({ - Authorization: "Bearer test", - }), - responseType: "json", - timeout: 60000, - })); - }); -}); - -describe("Chat client", () => { - it("places user in query for suggested messages", async () => { - const chatClient = new ChatClient("test"); - mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); - - await chatClient.getSuggested("msg-1", "end-user"); - - expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ - method: routes.getSuggested.method, - url: routes.getSuggested.url("msg-1"), - params: { user: "end-user" }, - headers: expect.objectContaining({ - Authorization: "Bearer test", - }), - responseType: "json", - timeout: 60000, - })); - }); - - it("uses last_id when listing conversations", async () => { - const chatClient = new ChatClient("test"); - mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); - - await chatClient.getConversations("end-user", "last-1", 10); - - expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ - method: routes.getConversations.method, - url: routes.getConversations.url(), - params: { user: "end-user", last_id: "last-1", limit: 10 }, - headers: expect.objectContaining({ - Authorization: "Bearer test", - }), - responseType: "json", - timeout: 60000, - })); - }); - - it("lists app feedbacks without user params", async () => { - const chatClient = new ChatClient("test"); - mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} }); - - await chatClient.getAppFeedbacks(1, 20); - - expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ - method: "GET", - url: "/app/feedbacks", - params: { page: 1, limit: 20 }, - headers: expect.objectContaining({ - Authorization: "Bearer test", - }), - responseType: "json", - timeout: 60000, - })); - }); -}); diff --git a/sdks/nodejs-client/src/index.test.ts b/sdks/nodejs-client/src/index.test.ts new file mode 100644 index 0000000000..d194680379 --- /dev/null +++ b/sdks/nodejs-client/src/index.test.ts @@ -0,0 +1,240 @@ +import { Readable } from "node:stream"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { BASE_URL, ChatClient, DifyClient, WorkflowClient, routes } from "./index"; + +const stubFetch = (): ReturnType => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; + +const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => + new Response(JSON.stringify(body), { + status: 200, + ...init, + headers: { + "content-type": "application/json", + ...(init.headers ?? {}), + }, + }); + +describe("Client", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("creates a client with default settings", () => { + const difyClient = new DifyClient("test"); + + expect(difyClient.getHttpClient().getSettings()).toMatchObject({ + apiKey: "test", + baseUrl: BASE_URL, + timeout: 60, + }); + }); + + it("updates the api key", () => { + const difyClient = new DifyClient("test"); + difyClient.updateApiKey("test2"); + + expect(difyClient.getHttpClient().getSettings().apiKey).toBe("test2"); + }); +}); + +describe("Send Requests", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("makes a successful request to the application parameter route", async () => { + const fetchMock = stubFetch(); + const difyClient = new DifyClient("test"); + const method = "GET"; + const endpoint = routes.application.url(); + + fetchMock.mockResolvedValueOnce(jsonResponse("response")); + + const response = await difyClient.sendRequest(method, endpoint); + + expect(response).toMatchObject({ + status: 200, + data: "response", + headers: { + "content-type": "application/json", + }, + }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${endpoint}`); + expect(init.method).toBe(method); + expect(init.headers).toMatchObject({ + Authorization: "Bearer test", + "User-Agent": "dify-client-node", + }); + }); + + it("uses the getMeta route configuration", async () => { + const fetchMock = stubFetch(); + const difyClient = new DifyClient("test"); + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + + await difyClient.getMeta("end-user"); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${routes.getMeta.url()}?user=end-user`); + expect(init.method).toBe(routes.getMeta.method); + expect(init.headers).toMatchObject({ + Authorization: "Bearer test", + }); + }); +}); + +describe("File uploads", () => { + const OriginalFormData = globalThis.FormData; + + beforeAll(() => { + globalThis.FormData = class FormDataMock extends Readable { + constructor() { + super(); + } + + _read() {} + + append() {} + + getHeaders() { + return { + "content-type": "multipart/form-data; boundary=test", + }; + } + } as unknown as typeof FormData; + }); + + afterAll(() => { + globalThis.FormData = OriginalFormData; + }); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("does not override multipart boundary headers for legacy FormData", async () => { + const fetchMock = stubFetch(); + const difyClient = new DifyClient("test"); + const form = new globalThis.FormData(); + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + + await difyClient.fileUpload(form, "end-user"); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${routes.fileUpload.url()}`); + expect(init.method).toBe(routes.fileUpload.method); + expect(init.headers).toMatchObject({ + Authorization: "Bearer test", + "content-type": "multipart/form-data; boundary=test", + }); + expect(init.body).not.toBe(form); + expect((init as RequestInit & { duplex?: string }).duplex).toBe("half"); + }); +}); + +describe("Workflow client", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("uses tasks stop path for workflow stop", async () => { + const fetchMock = stubFetch(); + const workflowClient = new WorkflowClient("test"); + fetchMock.mockResolvedValueOnce(jsonResponse({ result: "success" })); + + await workflowClient.stop("task-1", "end-user"); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${routes.stopWorkflow.url("task-1")}`); + expect(init.method).toBe(routes.stopWorkflow.method); + expect(init.headers).toMatchObject({ + Authorization: "Bearer test", + "Content-Type": "application/json", + }); + expect(init.body).toBe(JSON.stringify({ user: "end-user" })); + }); + + it("maps workflow log filters to service api params", async () => { + const fetchMock = stubFetch(); + const workflowClient = new WorkflowClient("test"); + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + + await workflowClient.getLogs({ + createdAtAfter: "2024-01-01T00:00:00Z", + createdAtBefore: "2024-01-02T00:00:00Z", + createdByEndUserSessionId: "sess-1", + createdByAccount: "acc-1", + page: 2, + limit: 10, + }); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + const parsedUrl = new URL(url); + expect(parsedUrl.origin + parsedUrl.pathname).toBe(`${BASE_URL}/workflows/logs`); + expect(parsedUrl.searchParams.get("created_at__before")).toBe( + "2024-01-02T00:00:00Z" + ); + expect(parsedUrl.searchParams.get("created_at__after")).toBe( + "2024-01-01T00:00:00Z" + ); + expect(parsedUrl.searchParams.get("created_by_end_user_session_id")).toBe( + "sess-1" + ); + expect(parsedUrl.searchParams.get("created_by_account")).toBe("acc-1"); + expect(parsedUrl.searchParams.get("page")).toBe("2"); + expect(parsedUrl.searchParams.get("limit")).toBe("10"); + }); +}); + +describe("Chat client", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("places user in query for suggested messages", async () => { + const fetchMock = stubFetch(); + const chatClient = new ChatClient("test"); + fetchMock.mockResolvedValueOnce(jsonResponse({ result: "success", data: [] })); + + await chatClient.getSuggested("msg-1", "end-user"); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${routes.getSuggested.url("msg-1")}?user=end-user`); + expect(init.method).toBe(routes.getSuggested.method); + expect(init.headers).toMatchObject({ + Authorization: "Bearer test", + }); + }); + + it("uses last_id when listing conversations", async () => { + const fetchMock = stubFetch(); + const chatClient = new ChatClient("test"); + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + + await chatClient.getConversations("end-user", "last-1", 10); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${routes.getConversations.url()}?user=end-user&last_id=last-1&limit=10`); + }); + + it("lists app feedbacks without user params", async () => { + const fetchMock = stubFetch(); + const chatClient = new ChatClient("test"); + fetchMock.mockResolvedValueOnce(jsonResponse({ data: [] })); + + await chatClient.getAppFeedbacks(1, 20); + + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}/app/feedbacks?page=1&limit=20`); + }); +}); diff --git a/sdks/nodejs-client/src/internal/type-guards.ts b/sdks/nodejs-client/src/internal/type-guards.ts new file mode 100644 index 0000000000..3d74df00fb --- /dev/null +++ b/sdks/nodejs-client/src/internal/type-guards.ts @@ -0,0 +1,9 @@ +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +export const hasStringProperty = < + TKey extends string, +>( + value: unknown, + key: TKey +): value is Record => isRecord(value) && typeof value[key] === "string"; diff --git a/sdks/nodejs-client/src/types/annotation.ts b/sdks/nodejs-client/src/types/annotation.ts index dcbd644dab..eda48e565c 100644 --- a/sdks/nodejs-client/src/types/annotation.ts +++ b/sdks/nodejs-client/src/types/annotation.ts @@ -15,4 +15,5 @@ export type AnnotationListOptions = { keyword?: string; }; -export type AnnotationResponse = Record; +export type AnnotationResponse = JsonObject; +import type { JsonObject } from "./common"; diff --git a/sdks/nodejs-client/src/types/chat.ts b/sdks/nodejs-client/src/types/chat.ts index 5b627f6cf6..0e714c83f9 100644 --- a/sdks/nodejs-client/src/types/chat.ts +++ b/sdks/nodejs-client/src/types/chat.ts @@ -1,17 +1,28 @@ -import type { StreamEvent } from "./common"; +import type { + DifyRequestFile, + JsonObject, + ResponseMode, + StreamEvent, +} from "./common"; export type ChatMessageRequest = { - inputs?: Record; + inputs?: JsonObject; query: string; user: string; - response_mode?: "blocking" | "streaming"; - files?: Array> | null; + response_mode?: ResponseMode; + files?: DifyRequestFile[] | null; conversation_id?: string; auto_generate_name?: boolean; workflow_id?: string; retriever_from?: "app" | "dataset"; }; -export type ChatMessageResponse = Record; +export type ChatMessageResponse = JsonObject; -export type ChatStreamEvent = StreamEvent>; +export type ChatStreamEvent = StreamEvent; + +export type ConversationSortBy = + | "created_at" + | "-created_at" + | "updated_at" + | "-updated_at"; diff --git a/sdks/nodejs-client/src/types/common.ts b/sdks/nodejs-client/src/types/common.ts index 00b0fcc756..60b1f8adf5 100644 --- a/sdks/nodejs-client/src/types/common.ts +++ b/sdks/nodejs-client/src/types/common.ts @@ -1,9 +1,18 @@ +import type { Readable } from "node:stream"; + export const DEFAULT_BASE_URL = "https://api.dify.ai/v1"; export const DEFAULT_TIMEOUT_SECONDS = 60; export const DEFAULT_MAX_RETRIES = 3; export const DEFAULT_RETRY_DELAY_SECONDS = 1; export type RequestMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; +export type ResponseMode = "blocking" | "streaming"; +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; +export type JsonObject = { + [key: string]: JsonValue; +}; +export type JsonArray = JsonValue[]; export type QueryParamValue = | string @@ -15,6 +24,13 @@ export type QueryParamValue = export type QueryParams = Record; export type Headers = Record; +export type DifyRequestFile = JsonObject; +export type SuccessResponse = { + result: "success"; +}; +export type SuggestedQuestionsResponse = SuccessResponse & { + data: string[]; +}; export type DifyClientConfig = { apiKey: string; @@ -54,18 +70,18 @@ export type StreamEvent = { }; export type DifyStream = AsyncIterable> & { - data: NodeJS.ReadableStream; + data: Readable; status: number; headers: Headers; requestId?: string; toText(): Promise; - toReadable(): NodeJS.ReadableStream; + toReadable(): Readable; }; export type BinaryStream = { - data: NodeJS.ReadableStream; + data: Readable; status: number; headers: Headers; requestId?: string; - toReadable(): NodeJS.ReadableStream; + toReadable(): Readable; }; diff --git a/sdks/nodejs-client/src/types/completion.ts b/sdks/nodejs-client/src/types/completion.ts index 4074137c5d..99b1757b66 100644 --- a/sdks/nodejs-client/src/types/completion.ts +++ b/sdks/nodejs-client/src/types/completion.ts @@ -1,13 +1,18 @@ -import type { StreamEvent } from "./common"; +import type { + DifyRequestFile, + JsonObject, + ResponseMode, + StreamEvent, +} from "./common"; export type CompletionRequest = { - inputs?: Record; - response_mode?: "blocking" | "streaming"; + inputs?: JsonObject; + response_mode?: ResponseMode; user: string; - files?: Array> | null; + files?: DifyRequestFile[] | null; retriever_from?: "app" | "dataset"; }; -export type CompletionResponse = Record; +export type CompletionResponse = JsonObject; -export type CompletionStreamEvent = StreamEvent>; +export type CompletionStreamEvent = StreamEvent; diff --git a/sdks/nodejs-client/src/types/knowledge-base.ts b/sdks/nodejs-client/src/types/knowledge-base.ts index a4ddef50ea..3180148ce7 100644 --- a/sdks/nodejs-client/src/types/knowledge-base.ts +++ b/sdks/nodejs-client/src/types/knowledge-base.ts @@ -14,7 +14,7 @@ export type DatasetCreateRequest = { external_knowledge_api_id?: string | null; provider?: string; external_knowledge_id?: string | null; - retrieval_model?: Record | null; + retrieval_model?: JsonObject | null; embedding_model?: string | null; embedding_model_provider?: string | null; }; @@ -26,9 +26,9 @@ export type DatasetUpdateRequest = { permission?: string | null; embedding_model?: string | null; embedding_model_provider?: string | null; - retrieval_model?: Record | null; + retrieval_model?: JsonObject | null; partial_member_list?: Array> | null; - external_retrieval_model?: Record | null; + external_retrieval_model?: JsonObject | null; external_knowledge_id?: string | null; external_knowledge_api_id?: string | null; }; @@ -61,12 +61,12 @@ export type DatasetTagUnbindingRequest = { export type DocumentTextCreateRequest = { name: string; text: string; - process_rule?: Record | null; + process_rule?: JsonObject | null; original_document_id?: string | null; doc_form?: string; doc_language?: string; indexing_technique?: string | null; - retrieval_model?: Record | null; + retrieval_model?: JsonObject | null; embedding_model?: string | null; embedding_model_provider?: string | null; }; @@ -74,10 +74,10 @@ export type DocumentTextCreateRequest = { export type DocumentTextUpdateRequest = { name?: string | null; text?: string | null; - process_rule?: Record | null; + process_rule?: JsonObject | null; doc_form?: string; doc_language?: string; - retrieval_model?: Record | null; + retrieval_model?: JsonObject | null; }; export type DocumentListOptions = { @@ -92,7 +92,7 @@ export type DocumentGetOptions = { }; export type SegmentCreateRequest = { - segments: Array>; + segments: JsonObject[]; }; export type SegmentUpdateRequest = { @@ -155,8 +155,8 @@ export type MetadataOperationRequest = { export type HitTestingRequest = { query?: string | null; - retrieval_model?: Record | null; - external_retrieval_model?: Record | null; + retrieval_model?: JsonObject | null; + external_retrieval_model?: JsonObject | null; attachment_ids?: string[] | null; }; @@ -165,20 +165,21 @@ export type DatasourcePluginListOptions = { }; export type DatasourceNodeRunRequest = { - inputs: Record; + inputs: JsonObject; datasource_type: string; credential_id?: string | null; is_published: boolean; }; export type PipelineRunRequest = { - inputs: Record; + inputs: JsonObject; datasource_type: string; - datasource_info_list: Array>; + datasource_info_list: JsonObject[]; start_node_id: string; is_published: boolean; - response_mode: "streaming" | "blocking"; + response_mode: ResponseMode; }; -export type KnowledgeBaseResponse = Record; -export type PipelineStreamEvent = Record; +export type KnowledgeBaseResponse = JsonObject; +export type PipelineStreamEvent = JsonObject; +import type { JsonObject, ResponseMode } from "./common"; diff --git a/sdks/nodejs-client/src/types/workflow.ts b/sdks/nodejs-client/src/types/workflow.ts index 2b507c7352..9ddedce1c2 100644 --- a/sdks/nodejs-client/src/types/workflow.ts +++ b/sdks/nodejs-client/src/types/workflow.ts @@ -1,12 +1,17 @@ -import type { StreamEvent } from "./common"; +import type { + DifyRequestFile, + JsonObject, + ResponseMode, + StreamEvent, +} from "./common"; export type WorkflowRunRequest = { - inputs?: Record; + inputs?: JsonObject; user: string; - response_mode?: "blocking" | "streaming"; - files?: Array> | null; + response_mode?: ResponseMode; + files?: DifyRequestFile[] | null; }; -export type WorkflowRunResponse = Record; +export type WorkflowRunResponse = JsonObject; -export type WorkflowStreamEvent = StreamEvent>; +export type WorkflowStreamEvent = StreamEvent; diff --git a/sdks/nodejs-client/src/types/workspace.ts b/sdks/nodejs-client/src/types/workspace.ts index 0ab6743063..5bb07ad373 100644 --- a/sdks/nodejs-client/src/types/workspace.ts +++ b/sdks/nodejs-client/src/types/workspace.ts @@ -1,2 +1,4 @@ +import type { JsonObject } from "./common"; + export type WorkspaceModelType = string; -export type WorkspaceModelsResponse = Record; +export type WorkspaceModelsResponse = JsonObject; diff --git a/sdks/nodejs-client/tests/http.integration.test.ts b/sdks/nodejs-client/tests/http.integration.test.ts new file mode 100644 index 0000000000..e73b192a67 --- /dev/null +++ b/sdks/nodejs-client/tests/http.integration.test.ts @@ -0,0 +1,137 @@ +import { createServer } from "node:http"; +import { Readable } from "node:stream"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { HttpClient } from "../src/http/client"; + +const readBody = async (stream: NodeJS.ReadableStream): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +}; + +describe("HttpClient integration", () => { + const requests: Array<{ + url: string; + method: string; + headers: Record; + body: Buffer; + }> = []; + + const server = createServer((req, res) => { + void (async () => { + const body = await readBody(req); + requests.push({ + url: req.url ?? "", + method: req.method ?? "", + headers: req.headers, + body, + }); + + if (req.url?.startsWith("/json")) { + res.writeHead(200, { "content-type": "application/json", "x-request-id": "req-json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (req.url === "/stream") { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end('data: {"answer":"hello"}\n\ndata: {"delta":" world"}\n\n'); + return; + } + + if (req.url === "/bytes") { + res.writeHead(200, { "content-type": "application/octet-stream" }); + res.end(Buffer.from([1, 2, 3, 4])); + return; + } + + if (req.url === "/upload-stream") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ received: body.toString("utf8") })); + return; + } + + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "not found" })); + })(); + }); + + let client: HttpClient; + + beforeAll(async () => { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address() as AddressInfo; + client = new HttpClient({ + apiKey: "test-key", + baseUrl: `http://127.0.0.1:${address.port}`, + maxRetries: 0, + retryDelay: 0, + }); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }); + + it("uses real fetch for query serialization and json bodies", async () => { + const response = await client.request({ + method: "POST", + path: "/json", + query: { tag_ids: ["a", "b"], limit: 2 }, + data: { user: "u" }, + }); + + expect(response.requestId).toBe("req-json"); + expect(response.data).toEqual({ ok: true }); + expect(requests.at(-1)).toMatchObject({ + url: "/json?tag_ids=a&tag_ids=b&limit=2", + method: "POST", + }); + expect(requests.at(-1)?.headers.authorization).toBe("Bearer test-key"); + expect(requests.at(-1)?.headers["content-type"]).toBe("application/json"); + expect(requests.at(-1)?.body.toString("utf8")).toBe(JSON.stringify({ user: "u" })); + }); + + it("supports streaming request bodies with duplex fetch", async () => { + const response = await client.request<{ received: string }>({ + method: "POST", + path: "/upload-stream", + data: Readable.from(["hello ", "world"]), + }); + + expect(response.data).toEqual({ received: "hello world" }); + expect(requests.at(-1)?.body.toString("utf8")).toBe("hello world"); + }); + + it("parses real sse responses into text", async () => { + const stream = await client.requestStream({ + method: "GET", + path: "/stream", + }); + + await expect(stream.toText()).resolves.toBe("hello world"); + }); + + it("parses real byte responses into buffers", async () => { + const response = await client.request({ + method: "GET", + path: "/bytes", + responseType: "bytes", + }); + + expect(Array.from(response.data.values())).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/sdks/nodejs-client/tests/test-utils.js b/sdks/nodejs-client/tests/test-utils.js deleted file mode 100644 index 0d42514e9a..0000000000 --- a/sdks/nodejs-client/tests/test-utils.js +++ /dev/null @@ -1,30 +0,0 @@ -import axios from "axios"; -import { vi } from "vitest"; -import { HttpClient } from "../src/http/client"; - -export const createHttpClient = (configOverrides = {}) => { - const mockRequest = vi.fn(); - vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest }); - const client = new HttpClient({ apiKey: "test", ...configOverrides }); - return { client, mockRequest }; -}; - -export const createHttpClientWithSpies = (configOverrides = {}) => { - const { client, mockRequest } = createHttpClient(configOverrides); - const request = vi - .spyOn(client, "request") - .mockResolvedValue({ data: "ok", status: 200, headers: {} }); - const requestStream = vi - .spyOn(client, "requestStream") - .mockResolvedValue({ data: null }); - const requestBinaryStream = vi - .spyOn(client, "requestBinaryStream") - .mockResolvedValue({ data: null }); - return { - client, - mockRequest, - request, - requestStream, - requestBinaryStream, - }; -}; diff --git a/sdks/nodejs-client/tests/test-utils.ts b/sdks/nodejs-client/tests/test-utils.ts new file mode 100644 index 0000000000..5d45629e31 --- /dev/null +++ b/sdks/nodejs-client/tests/test-utils.ts @@ -0,0 +1,48 @@ +import { vi } from "vitest"; +import { HttpClient } from "../src/http/client"; +import type { DifyClientConfig, DifyResponse } from "../src/types/common"; + +type FetchMock = ReturnType; +type RequestSpy = ReturnType; + +type HttpClientWithFetchMock = { + client: HttpClient; + fetchMock: FetchMock; +}; + +type HttpClientWithSpies = HttpClientWithFetchMock & { + request: RequestSpy; + requestStream: RequestSpy; + requestBinaryStream: RequestSpy; +}; + +export const createHttpClient = ( + configOverrides: Partial = {} +): HttpClientWithFetchMock => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const client = new HttpClient({ apiKey: "test", ...configOverrides }); + return { client, fetchMock }; +}; + +export const createHttpClientWithSpies = ( + configOverrides: Partial = {} +): HttpClientWithSpies => { + const { client, fetchMock } = createHttpClient(configOverrides); + const request = vi + .spyOn(client, "request") + .mockResolvedValue({ data: "ok", status: 200, headers: {} } as DifyResponse); + const requestStream = vi + .spyOn(client, "requestStream") + .mockResolvedValue({ data: null, status: 200, headers: {} } as never); + const requestBinaryStream = vi + .spyOn(client, "requestBinaryStream") + .mockResolvedValue({ data: null, status: 200, headers: {} } as never); + return { + client, + fetchMock, + request, + requestStream, + requestBinaryStream, + }; +}; diff --git a/sdks/nodejs-client/tsconfig.json b/sdks/nodejs-client/tsconfig.json index d2da9a2a59..f6fb5e0555 100644 --- a/sdks/nodejs-client/tsconfig.json +++ b/sdks/nodejs-client/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "rootDir": "src", + "rootDir": ".", "outDir": "dist", "declaration": true, "declarationMap": true, @@ -13,5 +13,5 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts"] } diff --git a/sdks/nodejs-client/vitest.config.ts b/sdks/nodejs-client/vitest.config.ts index 5a0a8637a2..c3132e9ecf 100644 --- a/sdks/nodejs-client/vitest.config.ts +++ b/sdks/nodejs-client/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["**/*.test.js"], + include: ["**/*.test.ts"], coverage: { provider: "v8", reporter: ["text", "text-summary"], From 424d34a9c012308ba10817fe3ba990e4bcabb046 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:02:02 +0800 Subject: [PATCH 05/30] fix(ci): structure i18n sync payload and PR flow (#34342) --- .github/workflows/translate-i18n-claude.yml | 283 +++++++++++++++++--- .github/workflows/trigger-i18n-sync.yml | 100 ++++++- 2 files changed, 334 insertions(+), 49 deletions(-) diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index f3fbfe60e2..33af4f36fd 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -67,6 +67,92 @@ jobs: } " web/i18n-config/languages.ts | sed 's/[[:space:]]*$//') + generate_changes_json() { + node <<'NODE' + const { execFileSync } = require('node:child_process') + const fs = require('node:fs') + const path = require('node:path') + + const repoRoot = process.cwd() + const baseSha = process.env.BASE_SHA || '' + const headSha = process.env.HEAD_SHA || '' + const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean) + + const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`) + + const readCurrentJson = (fileStem) => { + const filePath = englishPath(fileStem) + if (!fs.existsSync(filePath)) + return null + + return JSON.parse(fs.readFileSync(filePath, 'utf8')) + } + + const readBaseJson = (fileStem) => { + if (!baseSha) + return null + + try { + const relativePath = `web/i18n/en-US/${fileStem}.json` + const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' }) + return JSON.parse(content) + } + catch (error) { + return null + } + } + + const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue) + + const changes = {} + + for (const fileStem of files) { + const currentJson = readCurrentJson(fileStem) + const beforeJson = readBaseJson(fileStem) || {} + const afterJson = currentJson || {} + const added = {} + const updated = {} + const deleted = [] + + for (const [key, value] of Object.entries(afterJson)) { + if (!(key in beforeJson)) { + added[key] = value + continue + } + + if (!compareJson(beforeJson[key], value)) { + updated[key] = { + before: beforeJson[key], + after: value, + } + } + } + + for (const key of Object.keys(beforeJson)) { + if (!(key in afterJson)) + deleted.push(key) + } + + changes[fileStem] = { + fileDeleted: currentJson === null, + added, + updated, + deleted, + } + } + + fs.writeFileSync( + '/tmp/i18n-changes.json', + JSON.stringify({ + baseSha, + headSha, + files, + changes, + }) + ) + NODE + } + if [ "${{ github.event_name }}" = "repository_dispatch" ]; then BASE_SHA="${{ github.event.client_payload.base_sha }}" HEAD_SHA="${{ github.event.client_payload.head_sha }}" @@ -74,12 +160,19 @@ jobs: TARGET_LANGS="$DEFAULT_TARGET_LANGS" SYNC_MODE="${{ github.event.client_payload.sync_mode || 'incremental' }}" - if [ -n "${{ github.event.client_payload.diff_base64 }}" ]; then - printf '%s' '${{ github.event.client_payload.diff_base64 }}' | base64 -d > /tmp/i18n-diff.txt - DIFF_AVAILABLE="true" + if [ -n "${{ github.event.client_payload.changes_base64 }}" ]; then + printf '%s' '${{ github.event.client_payload.changes_base64 }}' | base64 -d > /tmp/i18n-changes.json + CHANGES_AVAILABLE="true" + CHANGES_SOURCE="embedded" + elif [ -n "$BASE_SHA" ] && [ -n "$CHANGED_FILES" ]; then + export BASE_SHA HEAD_SHA CHANGED_FILES + generate_changes_json + CHANGES_AVAILABLE="true" + CHANGES_SOURCE="recomputed" else - : > /tmp/i18n-diff.txt - DIFF_AVAILABLE="false" + printf '%s' '{"baseSha":"","headSha":"","files":[],"changes":{}}' > /tmp/i18n-changes.json + CHANGES_AVAILABLE="false" + CHANGES_SOURCE="unavailable" fi else BASE_SHA="" @@ -106,16 +199,15 @@ jobs: CHANGED_FILES="" fi - if [ "$SYNC_MODE" = "incremental" ] && [ -n "$BASE_SHA" ]; then - git diff "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || : > /tmp/i18n-diff.txt + if [ "$SYNC_MODE" = "incremental" ] && [ -n "$CHANGED_FILES" ]; then + export BASE_SHA HEAD_SHA CHANGED_FILES + generate_changes_json + CHANGES_AVAILABLE="true" + CHANGES_SOURCE="local" else - : > /tmp/i18n-diff.txt - fi - - if [ -s /tmp/i18n-diff.txt ]; then - DIFF_AVAILABLE="true" - else - DIFF_AVAILABLE="false" + printf '%s' '{"baseSha":"","headSha":"","files":[],"changes":{}}' > /tmp/i18n-changes.json + CHANGES_AVAILABLE="false" + CHANGES_SOURCE="unavailable" fi fi @@ -136,7 +228,8 @@ jobs: echo "CHANGED_FILES=$CHANGED_FILES" echo "TARGET_LANGS=$TARGET_LANGS" echo "SYNC_MODE=$SYNC_MODE" - echo "DIFF_AVAILABLE=$DIFF_AVAILABLE" + echo "CHANGES_AVAILABLE=$CHANGES_AVAILABLE" + echo "CHANGES_SOURCE=$CHANGES_SOURCE" echo "FILE_ARGS=$FILE_ARGS" echo "LANG_ARGS=$LANG_ARGS" } >> "$GITHUB_OUTPUT" @@ -155,7 +248,7 @@ jobs: show_full_output: ${{ github.event_name == 'workflow_dispatch' }} prompt: | You are the i18n sync agent for the Dify repository. - Your job is to keep translations synchronized with the English source files under `${{ github.workspace }}/web/i18n/en-US/`, then open a PR with the result. + Your job is to keep translations synchronized with the English source files under `${{ github.workspace }}/web/i18n/en-US/`. Use absolute paths at all times: - Repo root: `${{ github.workspace }}` @@ -170,13 +263,15 @@ jobs: - Head SHA: `${{ steps.context.outputs.HEAD_SHA }}` - Scoped file args: `${{ steps.context.outputs.FILE_ARGS }}` - Scoped language args: `${{ steps.context.outputs.LANG_ARGS }}` - - Full English diff available: `${{ steps.context.outputs.DIFF_AVAILABLE }}` + - Structured change set available: `${{ steps.context.outputs.CHANGES_AVAILABLE }}` + - Structured change set source: `${{ steps.context.outputs.CHANGES_SOURCE }}` + - Structured change set file: `/tmp/i18n-changes.json` Tool rules: - Use Read for repository files. - Use Edit for JSON updates. - - Use Bash only for `git`, `gh`, `pnpm`, and `date`. - - Run Bash commands one by one. Do not combine commands with `&&`, `||`, pipes, or command substitution. + - Use Bash only for `pnpm`. + - Do not use Bash for `git`, `gh`, or branch management. Required execution plan: 1. Resolve target languages. @@ -187,30 +282,25 @@ jobs: - Only process the resolved target languages, never `en-US`. - Do not touch unrelated i18n files. - Do not modify `${{ github.workspace }}/web/i18n/en-US/`. - 3. Detect English changes per file. - - Treat the current English JSON files under `${{ github.workspace }}/web/i18n/en-US/` plus the scoped `i18n:check` result as the primary source of truth. - - Use `/tmp/i18n-diff.txt` only as supporting context to understand what changed between `Base SHA` and `Head SHA`. - - Never rely on diff alone when deciding final keys or values. - - Read the current English JSON file for each file in scope. - - If sync mode is `incremental` and `Base SHA` is not empty, run: - `git -C ${{ github.workspace }} show :web/i18n/en-US/.json` - - If sync mode is `full` or `Base SHA` is empty, skip historical comparison and treat the current English file as the only source of truth for structural sync. - - If the file did not exist at Base SHA, treat all current keys as ADD. - - Compare previous and current English JSON to identify: - - ADD: key only in current - - UPDATE: key exists in both and the English value changed - - DELETE: key only in previous - - If `/tmp/i18n-diff.txt` is available, read it before translating so wording changes are grounded in the full English patch, but resolve any ambiguity by trusting the actual English files and scoped checks. + 3. Resolve source changes. + - If `Structured change set available` is `true`, read `/tmp/i18n-changes.json` and use it as the source of truth for file-level and key-level changes. + - For each file entry: + - `added` contains new English keys that need translations. + - `updated` contains stale keys whose English source changed; re-translate using the `after` value. + - `deleted` contains keys that should be removed from locale files. + - `fileDeleted: true` means the English file no longer exists; remove the matching locale file if present. + - Read the current English JSON file for any file that still exists so wording, placeholders, and surrounding terminology stay accurate. + - If `Structured change set available` is `false`, treat this as a scoped full sync and use the current English files plus scoped checks as the source of truth. 4. Run a scoped pre-check before editing: - `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` - Use this command as the source of truth for missing and extra keys inside the current scope. 5. Apply translations. - For every target language and scoped file: + - If `fileDeleted` is `true`, remove the locale file if it exists and skip the rest of that file. - If the locale file does not exist yet, create it with `Write` and then continue with `Edit` as needed. - ADD missing keys. - UPDATE stale translations when the English value changed. - DELETE removed keys. Prefer `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope. - - For `zh-Hans` and `ja-JP`, if the locale file also changed between Base SHA and Head SHA, preserve manual translations unless they are clearly wrong for the new English value. If in doubt, keep the manual translation. - Preserve placeholders exactly: `{{variable}}`, `${variable}`, HTML tags, component tags, and variable names. - Match the existing terminology and register used by each locale. - Prefer one Edit per file when stable, but prioritize correctness over batching. @@ -218,14 +308,119 @@ jobs: - Run `pnpm --dir ${{ github.workspace }}/web lint:fix --quiet -- ` - Run `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` - If verification fails, fix the remaining problems before continuing. - 7. Create a PR only when there are changes in `web/i18n/`. - - Check `git -C ${{ github.workspace }} status --porcelain -- web/i18n/` - - Create branch `chore/i18n-sync-` - - Commit message: `chore(i18n): sync translations with en-US` - - Push the branch and open a PR against `main` - - PR title: `chore(i18n): sync translations with en-US` - - PR body: summarize files, languages, sync mode, and verification commands - 8. If there are no translation changes after verification, do not create a branch, commit, or PR. + 7. Stop after the scoped locale files are updated and verification passes. + - Do not create branches, commits, or pull requests. claude_args: | - --max-turns 80 - --allowedTools "Read,Write,Edit,Bash(git *),Bash(git:*),Bash(gh *),Bash(gh:*),Bash(pnpm *),Bash(pnpm:*),Bash(date *),Bash(date:*),Glob,Grep" + --max-turns 120 + --allowedTools "Read,Write,Edit,Bash(pnpm *),Bash(pnpm:*),Glob,Grep" + + - name: Prepare branch metadata + id: pr_meta + if: steps.context.outputs.CHANGED_FILES != '' + shell: bash + run: | + if [ -z "$(git -C "${{ github.workspace }}" status --porcelain -- web/i18n/)" ]; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + SCOPE_HASH=$(printf '%s|%s|%s' "${{ steps.context.outputs.CHANGED_FILES }}" "${{ steps.context.outputs.TARGET_LANGS }}" "${{ steps.context.outputs.SYNC_MODE }}" | sha256sum | cut -c1-8) + HEAD_SHORT=$(printf '%s' "${{ steps.context.outputs.HEAD_SHA }}" | cut -c1-12) + BRANCH_NAME="chore/i18n-sync-${HEAD_SHORT}-${SCOPE_HASH}" + + { + echo "has_changes=true" + echo "branch_name=$BRANCH_NAME" + } >> "$GITHUB_OUTPUT" + + - name: Commit translation changes + if: steps.pr_meta.outputs.has_changes == 'true' + shell: bash + run: | + git -C "${{ github.workspace }}" checkout -B "${{ steps.pr_meta.outputs.branch_name }}" + git -C "${{ github.workspace }}" add web/i18n/ + git -C "${{ github.workspace }}" commit -m "chore(i18n): sync translations with en-US" + + - name: Push translation branch + if: steps.pr_meta.outputs.has_changes == 'true' + shell: bash + run: | + if git -C "${{ github.workspace }}" ls-remote --exit-code --heads origin "${{ steps.pr_meta.outputs.branch_name }}" >/dev/null 2>&1; then + git -C "${{ github.workspace }}" push --force-with-lease origin "${{ steps.pr_meta.outputs.branch_name }}" + else + git -C "${{ github.workspace }}" push --set-upstream origin "${{ steps.pr_meta.outputs.branch_name }}" + fi + + - name: Create or update translation PR + if: steps.pr_meta.outputs.has_changes == 'true' + env: + BRANCH_NAME: ${{ steps.pr_meta.outputs.branch_name }} + FILES_IN_SCOPE: ${{ steps.context.outputs.CHANGED_FILES }} + TARGET_LANGS: ${{ steps.context.outputs.TARGET_LANGS }} + SYNC_MODE: ${{ steps.context.outputs.SYNC_MODE }} + CHANGES_SOURCE: ${{ steps.context.outputs.CHANGES_SOURCE }} + BASE_SHA: ${{ steps.context.outputs.BASE_SHA }} + HEAD_SHA: ${{ steps.context.outputs.HEAD_SHA }} + REPO_NAME: ${{ github.repository }} + shell: bash + run: | + PR_BODY_FILE=/tmp/i18n-pr-body.md + LANG_COUNT=$(printf '%s\n' "$TARGET_LANGS" | wc -w | tr -d ' ') + if [ "$LANG_COUNT" = "0" ]; then + LANG_COUNT="0" + fi + export LANG_COUNT + + node <<'NODE' > "$PR_BODY_FILE" + const fs = require('node:fs') + + const changesPath = '/tmp/i18n-changes.json' + const changes = fs.existsSync(changesPath) + ? JSON.parse(fs.readFileSync(changesPath, 'utf8')) + : { changes: {} } + + const filesInScope = (process.env.FILES_IN_SCOPE || '').split(/\s+/).filter(Boolean) + const lines = [ + '## Summary', + '', + `- **Files synced**: \`${process.env.FILES_IN_SCOPE || ''}\``, + `- **Languages updated**: ${process.env.TARGET_LANGS || ''} (${process.env.LANG_COUNT} languages)`, + `- **Sync mode**: ${process.env.SYNC_MODE}${process.env.BASE_SHA ? ` (base: \`${process.env.BASE_SHA.slice(0, 10)}\`, head: \`${process.env.HEAD_SHA.slice(0, 10)}\`)` : ` (head: \`${process.env.HEAD_SHA.slice(0, 10)}\`)`}`, + '', + '### Key changes', + ] + + for (const fileName of filesInScope) { + const fileChange = changes.changes?.[fileName] || { added: {}, updated: {}, deleted: [], fileDeleted: false } + const addedKeys = Object.keys(fileChange.added || {}) + const updatedKeys = Object.keys(fileChange.updated || {}) + const deletedKeys = fileChange.deleted || [] + lines.push(`- \`${fileName}\`: +${addedKeys.length} / ~${updatedKeys.length} / -${deletedKeys.length}${fileChange.fileDeleted ? ' (file deleted in en-US)' : ''}`) + } + + lines.push( + '', + '## Verification', + '', + `- \`pnpm --dir web run i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``, + `- \`pnpm --dir web lint:fix --quiet -- \``, + '', + '## Notes', + '', + '- This PR was generated from structured en-US key changes produced by `trigger-i18n-sync.yml`.', + `- Structured change source: ${process.env.CHANGES_SOURCE || 'unknown'}.`, + '- Branch name is deterministic for the head SHA and scope, so reruns update the same PR instead of opening duplicates.', + '', + '🤖 Generated with [Claude Code](https://claude.com/claude-code)' + ) + + process.stdout.write(lines.join('\n')) + NODE + + EXISTING_PR_NUMBER=$(gh pr list --repo "$REPO_NAME" --head "$BRANCH_NAME" --state open --json number --jq '.[0].number') + + if [ -n "$EXISTING_PR_NUMBER" ] && [ "$EXISTING_PR_NUMBER" != "null" ]; then + gh pr edit "$EXISTING_PR_NUMBER" --repo "$REPO_NAME" --title "chore(i18n): sync translations with en-US" --body-file "$PR_BODY_FILE" + else + gh pr create --repo "$REPO_NAME" --head "$BRANCH_NAME" --base main --title "chore(i18n): sync translations with en-US" --body-file "$PR_BODY_FILE" + fi diff --git a/.github/workflows/trigger-i18n-sync.yml b/.github/workflows/trigger-i18n-sync.yml index ee44fbb0c0..a1ca42b26e 100644 --- a/.github/workflows/trigger-i18n-sync.yml +++ b/.github/workflows/trigger-i18n-sync.yml @@ -25,7 +25,7 @@ jobs: with: fetch-depth: 0 - - name: Detect changed files and generate full diff + - name: Detect changed files and build structured change set id: detect shell: bash run: | @@ -37,12 +37,94 @@ jobs: if [ -n "$BASE_SHA" ]; then CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//') - git diff "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' > /tmp/i18n-diff.txt 2>/dev/null || : > /tmp/i18n-diff.txt else CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//') - : > /tmp/i18n-diff.txt fi + export BASE_SHA HEAD_SHA CHANGED_FILES + node <<'NODE' + const { execFileSync } = require('node:child_process') + const fs = require('node:fs') + const path = require('node:path') + + const repoRoot = process.cwd() + const baseSha = process.env.BASE_SHA || '' + const headSha = process.env.HEAD_SHA || '' + const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean) + + const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`) + + const readCurrentJson = (fileStem) => { + const filePath = englishPath(fileStem) + if (!fs.existsSync(filePath)) + return null + + return JSON.parse(fs.readFileSync(filePath, 'utf8')) + } + + const readBaseJson = (fileStem) => { + if (!baseSha) + return null + + try { + const relativePath = `web/i18n/en-US/${fileStem}.json` + const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' }) + return JSON.parse(content) + } + catch (error) { + return null + } + } + + const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue) + + const changes = {} + + for (const fileStem of files) { + const beforeJson = readBaseJson(fileStem) || {} + const afterJson = readCurrentJson(fileStem) || {} + const added = {} + const updated = {} + const deleted = [] + + for (const [key, value] of Object.entries(afterJson)) { + if (!(key in beforeJson)) { + added[key] = value + continue + } + + if (!compareJson(beforeJson[key], value)) { + updated[key] = { + before: beforeJson[key], + after: value, + } + } + } + + for (const key of Object.keys(beforeJson)) { + if (!(key in afterJson)) + deleted.push(key) + } + + changes[fileStem] = { + fileDeleted: readCurrentJson(fileStem) === null, + added, + updated, + deleted, + } + } + + fs.writeFileSync( + '/tmp/i18n-changes.json', + JSON.stringify({ + baseSha, + headSha, + files, + changes, + }) + ) + NODE + if [ -n "$CHANGED_FILES" ]; then echo "has_changes=true" >> "$GITHUB_OUTPUT" else @@ -65,7 +147,14 @@ jobs: script: | const fs = require('fs') - const diffBase64 = fs.readFileSync('/tmp/i18n-diff.txt').toString('base64') + const changesJson = fs.readFileSync('/tmp/i18n-changes.json', 'utf8') + const changesBase64 = Buffer.from(changesJson).toString('base64') + const maxEmbeddedChangesChars = 48000 + const changesEmbedded = changesBase64.length <= maxEmbeddedChangesChars + + if (!changesEmbedded) { + console.log(`Structured change set too large to embed safely (${changesBase64.length} chars). Downstream workflow will regenerate it from git history.`) + } await github.rest.repos.createDispatchEvent({ owner: context.repo.owner, @@ -73,7 +162,8 @@ jobs: event_type: 'i18n-sync', client_payload: { changed_files: process.env.CHANGED_FILES, - diff_base64: diffBase64, + changes_base64: changesEmbedded ? changesBase64 : '', + changes_embedded: changesEmbedded, sync_mode: 'incremental', base_sha: process.env.BASE_SHA, head_sha: process.env.HEAD_SHA, From 24111facdd8a209bdbcaf3a10b99be515aa8e391 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:26:22 +0000 Subject: [PATCH 06/30] chore(i18n): sync translations with en-US (#34339) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/i18n/ar-TN/workflow.json | 3 +++ web/i18n/de-DE/workflow.json | 3 +++ web/i18n/es-ES/workflow.json | 3 +++ web/i18n/fa-IR/workflow.json | 3 +++ web/i18n/fr-FR/workflow.json | 3 +++ web/i18n/hi-IN/workflow.json | 3 +++ web/i18n/id-ID/workflow.json | 3 +++ web/i18n/it-IT/workflow.json | 3 +++ web/i18n/ja-JP/workflow.json | 3 +++ web/i18n/ko-KR/workflow.json | 3 +++ web/i18n/nl-NL/workflow.json | 3 +++ web/i18n/pl-PL/workflow.json | 3 +++ web/i18n/pt-BR/workflow.json | 3 +++ web/i18n/ro-RO/workflow.json | 3 +++ web/i18n/ru-RU/workflow.json | 3 +++ web/i18n/sl-SI/workflow.json | 3 +++ web/i18n/th-TH/workflow.json | 3 +++ web/i18n/tr-TR/workflow.json | 3 +++ web/i18n/uk-UA/workflow.json | 3 +++ web/i18n/vi-VN/workflow.json | 3 +++ web/i18n/zh-Hans/workflow.json | 3 +++ web/i18n/zh-Hant/workflow.json | 3 +++ 22 files changed, 66 insertions(+) diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index 2487538071..9396649c69 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "تصدير القيم السرية", "env.export.export": "تصدير DSL مع القيم السرية ", "env.export.ignore": "تصدير DSL", + "env.export.name": "الاسم", + "env.export.secret": "سري", "env.export.title": "تصدير متغيرات البيئة السرية؟", + "env.export.value": "القيمة", "env.modal.description": "الوصف", "env.modal.descriptionPlaceholder": "وصف المتغير", "env.modal.editTitle": "تعديل متغير بيئة", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index 362eac19b6..6648450686 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Geheime Werte exportieren", "env.export.export": "DSL mit geheimen Werten exportieren", "env.export.ignore": "DSL exportieren", + "env.export.name": "Name", + "env.export.secret": "Geheim", "env.export.title": "Geheime Umgebungsvariablen exportieren?", + "env.export.value": "Wert", "env.modal.description": "Beschreibung", "env.modal.descriptionPlaceholder": "Beschreiben Sie die Variable", "env.modal.editTitle": "Umgebungsvariable bearbeiten", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index 393859a36f..d23dd40a16 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Exportar valores secretos", "env.export.export": "Exportar DSL con valores secretos", "env.export.ignore": "Exportar DSL", + "env.export.name": "Nombre", + "env.export.secret": "Secreto", "env.export.title": "¿Exportar variables de entorno secretas?", + "env.export.value": "Valor", "env.modal.description": "Descripción", "env.modal.descriptionPlaceholder": "Describa la variable", "env.modal.editTitle": "Editar Variable de Entorno", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index 7a8fca11f1..1b1bf59d94 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "خروجی مقادیر محرمانه", "env.export.export": "خروجی DSL با مقادیر محرمانه", "env.export.ignore": "خروجی DSL", + "env.export.name": "نام", + "env.export.secret": "محرمانه", "env.export.title": "آیا متغیرهای محیطی محرمانه صادر شوند؟", + "env.export.value": "مقدار", "env.modal.description": "توضیحات", "env.modal.descriptionPlaceholder": "توصیف متغیر", "env.modal.editTitle": "ویرایش متغیر محیطی", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index 09d140445e..c172dbf41d 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Exporter les valeurs secrètes", "env.export.export": "Exporter les DSL avec des valeurs secrètes", "env.export.ignore": "Exporter DSL", + "env.export.name": "Nom", + "env.export.secret": "Secret", "env.export.title": "Exporter des variables d'environnement secrètes?", + "env.export.value": "valeur", "env.modal.description": "Description", "env.modal.descriptionPlaceholder": "Décrivez la variable", "env.modal.editTitle": "Editer titre", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index fb9256536e..2c14a31f55 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "गुप्त मान निर्यात करें", "env.export.export": "गुप्त मानों के साथ DSL निर्यात करें", "env.export.ignore": "DSL निर्यात करें", + "env.export.name": "नाम", + "env.export.secret": "गुप्त", "env.export.title": "गुप्त पर्यावरण चर निर्यात करें?", + "env.export.value": "मान", "env.modal.description": "विवरण", "env.modal.descriptionPlaceholder": "चर का वर्णन करें", "env.modal.editTitle": "पर्यावरण चर संपादित करें", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index 76e80be7d7..87d0415793 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Mengekspor nilai rahasia", "env.export.export": "Mengekspor DSL dengan nilai rahasia", "env.export.ignore": "Ekspor DSL", + "env.export.name": "Nama", + "env.export.secret": "Rahasia", "env.export.title": "Mengekspor variabel lingkungan Rahasia?", + "env.export.value": "Nilai", "env.modal.description": "Deskripsi", "env.modal.descriptionPlaceholder": "Jelaskan variabel", "env.modal.editTitle": "Edit Variabel Lingkungan", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index 519d7d7e2a..d9e802c2b6 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Esporta valori segreti", "env.export.export": "Esporta DSL con valori segreti", "env.export.ignore": "Esporta DSL", + "env.export.name": "Nome", + "env.export.secret": "Segreto", "env.export.title": "Esportare variabili d'ambiente segrete?", + "env.export.value": "Valore", "env.modal.description": "Descrizione", "env.modal.descriptionPlaceholder": "Descrivi la variabile", "env.modal.editTitle": "Modifica Variabile d'Ambiente", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index acf43b8ce8..0242249d30 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "シークレット値を含む", "env.export.export": "シークレット値付きでエクスポート", "env.export.ignore": "DSL をエクスポート", + "env.export.name": "名前", + "env.export.secret": "シークレット", "env.export.title": "シークレット環境変数をエクスポートしますか?", + "env.export.value": "値", "env.modal.description": "説明", "env.modal.descriptionPlaceholder": "変数の説明を入力", "env.modal.editTitle": "環境変数を編集", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index 24e8e634d3..2709fa1917 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "비밀 값 내보내기", "env.export.export": "비밀 값이 포함된 DSL 내보내기", "env.export.ignore": "DSL 내보내기", + "env.export.name": "이름", + "env.export.secret": "비밀", "env.export.title": "비밀 환경 변수를 내보내시겠습니까?", + "env.export.value": "값", "env.modal.description": "설명", "env.modal.descriptionPlaceholder": "변수에 대해 설명하세요", "env.modal.editTitle": "환경 변수 편집", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index 891df72387..eb18daf9f7 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Export secret values", "env.export.export": "Export DSL with secret values ", "env.export.ignore": "Export DSL", + "env.export.name": "Naam", + "env.export.secret": "Geheim", "env.export.title": "Export Secret environment variables?", + "env.export.value": "Waarde", "env.modal.description": "Description", "env.modal.descriptionPlaceholder": "Describe the variable", "env.modal.editTitle": "Edit Environment Variable", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 8aad8e0b71..adb639f295 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Eksportuj tajne wartości", "env.export.export": "Eksportuj DSL z tajnymi wartościami", "env.export.ignore": "Eksportuj DSL", + "env.export.name": "Nazwa", + "env.export.secret": "Tajny", "env.export.title": "Eksportować tajne zmienne środowiskowe?", + "env.export.value": "Wartość", "env.modal.description": "Opis", "env.modal.descriptionPlaceholder": "Opisz zmienną", "env.modal.editTitle": "Edytuj Zmienną Środowiskową", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index dc986df82c..aebf281b34 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Exportar valores secretos", "env.export.export": "Exportar DSL com valores secretos", "env.export.ignore": "Exportar DSL", + "env.export.name": "Nome", + "env.export.secret": "Secreto", "env.export.title": "Exportar variáveis de ambiente secretas?", + "env.export.value": "Valor", "env.modal.description": "Descrição", "env.modal.descriptionPlaceholder": "Descreva a variável", "env.modal.editTitle": "Editar Variável de Ambiente", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index e34ba42007..ff21dbb9fc 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Exportă valori secrete", "env.export.export": "Exportă DSL cu valori secrete", "env.export.ignore": "Exportă DSL", + "env.export.name": "Nume", + "env.export.secret": "Secret", "env.export.title": "Exportă variabile de mediu secrete?", + "env.export.value": "Valoare", "env.modal.description": "Descriere", "env.modal.descriptionPlaceholder": "Descrieți variabila", "env.modal.editTitle": "Editează Variabilă de Mediu", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index 73cdad253a..9b302c19f6 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Экспортировать секретные значения", "env.export.export": "Экспортировать DSL с секретными значениями ", "env.export.ignore": "Экспортировать DSL", + "env.export.name": "Имя", + "env.export.secret": "Секрет", "env.export.title": "Экспортировать секретные переменные среды?", + "env.export.value": "Значение", "env.modal.description": "Описание", "env.modal.descriptionPlaceholder": "Опишите переменную", "env.modal.editTitle": "Редактировать переменную среды", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index a20a30753d..814fe5b117 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Izvozi tajne vrednosti", "env.export.export": "Izvozi DSL z skrivnimi vrednostmi", "env.export.ignore": "Izvoz DSL", + "env.export.name": "Ime", + "env.export.secret": "Skrivnost", "env.export.title": "Izvozi skrivne okoljske spremenljivke?", + "env.export.value": "Vrednost", "env.modal.description": "Opis", "env.modal.descriptionPlaceholder": "Opisujte spremenljivko", "env.modal.editTitle": "Uredi okoljsko spremenljivko", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index 7819e884c3..df656580ea 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "ส่งออกค่าข้อมูลลับ", "env.export.export": "ส่งออก DSL ด้วยค่าลับ", "env.export.ignore": "ส่งออก DSL", + "env.export.name": "ชื่อ", + "env.export.secret": "Secret", "env.export.title": "ส่งออกตัวแปรสภาพแวดล้อม Secret หรือไม่", + "env.export.value": "ค่า", "env.modal.description": "คำอธิบาย", "env.modal.descriptionPlaceholder": "อธิบายตัวแปร", "env.modal.editTitle": "แก้ไขตัวแปรสภาพแวดล้อม", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index 58e6afae14..847f3a61f4 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Gizli değerleri dışa aktar", "env.export.export": "Gizli değerlerle DSL'yi dışa aktar", "env.export.ignore": "DSL'yi dışa aktar", + "env.export.name": "Ad", + "env.export.secret": "Gizli", "env.export.title": "Gizli çevre değişkenleri dışa aktarılsın mı?", + "env.export.value": "Değer", "env.modal.description": "Açıklama", "env.modal.descriptionPlaceholder": "Değişkeni açıklayın", "env.modal.editTitle": "Çevre Değişkenini Düzenle", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index 4fa95f6d57..eaf7d551a7 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Експортувати секретні значення", "env.export.export": "Експортувати DSL з секретними значеннями", "env.export.ignore": "Експортувати DSL", + "env.export.name": "Назва", + "env.export.secret": "Секрет", "env.export.title": "Експортувати секретні змінні середовища?", + "env.export.value": "Значення", "env.modal.description": "Опис", "env.modal.descriptionPlaceholder": "Опишіть змінну", "env.modal.editTitle": "Редагувати змінну середовища", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index 3a8bdbaaf1..94a4dfd848 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "Xuất giá trị bí mật", "env.export.export": "Xuất DSL với giá trị bí mật", "env.export.ignore": "Xuất DSL", + "env.export.name": "Tên", + "env.export.secret": "Bí mật", "env.export.title": "Xuất biến môi trường bí mật?", + "env.export.value": "Giá trị", "env.modal.description": "Mô tả", "env.modal.descriptionPlaceholder": "Mô tả biến", "env.modal.editTitle": "Sửa Biến Môi Trường", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 29a1f06350..e6fc7d9ba9 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "导出 secret 值", "env.export.export": "导出包含 Secret 值的 DSL", "env.export.ignore": "导出 DSL", + "env.export.name": "名称", + "env.export.secret": "Secret", "env.export.title": "导出 Secret 类型环境变量?", + "env.export.value": "值", "env.modal.description": "描述", "env.modal.descriptionPlaceholder": "变量的描述", "env.modal.editTitle": "编辑环境变量", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index b739984977..b7e34018d4 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -287,7 +287,10 @@ "env.export.checkbox": "導出機密值", "env.export.export": "導出帶有機密值的 DSL", "env.export.ignore": "導出 DSL", + "env.export.name": "名稱", + "env.export.secret": "機密", "env.export.title": "導出機密環境變數?", + "env.export.value": "值", "env.modal.description": "描述", "env.modal.descriptionPlaceholder": "描述此變數", "env.modal.editTitle": "編輯環境變數", From 90f94be2b3b30f848b9225dadd8d149151fa4ff3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:26:57 +0000 Subject: [PATCH 07/30] chore(i18n): sync translations with en-US (#34338) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/i18n/ar-TN/common.json | 9 +++++++++ web/i18n/de-DE/common.json | 9 +++++++++ web/i18n/es-ES/common.json | 9 +++++++++ web/i18n/fa-IR/common.json | 9 +++++++++ web/i18n/fr-FR/common.json | 9 +++++++++ web/i18n/hi-IN/common.json | 9 +++++++++ web/i18n/id-ID/common.json | 9 +++++++++ web/i18n/it-IT/common.json | 9 +++++++++ web/i18n/ja-JP/common.json | 9 +++++++++ web/i18n/ko-KR/common.json | 9 +++++++++ web/i18n/nl-NL/common.json | 9 +++++++++ web/i18n/pl-PL/common.json | 9 +++++++++ web/i18n/pt-BR/common.json | 9 +++++++++ web/i18n/ro-RO/common.json | 9 +++++++++ web/i18n/ru-RU/common.json | 9 +++++++++ web/i18n/sl-SI/common.json | 9 +++++++++ web/i18n/th-TH/common.json | 9 +++++++++ web/i18n/tr-TR/common.json | 9 +++++++++ web/i18n/uk-UA/common.json | 9 +++++++++ web/i18n/vi-VN/common.json | 9 +++++++++ web/i18n/zh-Hans/common.json | 9 +++++++++ web/i18n/zh-Hant/common.json | 9 +++++++++ 22 files changed, 198 insertions(+) diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index 3bc7c05564..2d81e44a71 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -162,6 +162,15 @@ "environment.development": "تطوير", "environment.testing": "اختبار", "error": "خطأ", + "errorBoundary.componentStack": "مكدس المكون:", + "errorBoundary.details": "تفاصيل الخطأ (التطوير فقط)", + "errorBoundary.errorCount": "حدث هذا الخطأ {{count}} مرة", + "errorBoundary.fallbackTitle": "عذراً! حدث خطأ ما", + "errorBoundary.message": "حدث خطأ غير متوقع أثناء عرض هذا المكون.", + "errorBoundary.reloadPage": "إعادة تحميل الصفحة", + "errorBoundary.title": "حدث خطأ ما", + "errorBoundary.tryAgain": "حاول مجدداً", + "errorBoundary.tryAgainCompact": "حاول مجدداً", "errorMsg.fieldRequired": "{{field}} مطلوب", "errorMsg.urlError": "يجب أن يبدأ العنوان بـ http:// أو https://", "feedback.content": "محتوى التعليق", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 8639a24f3e..d33c1bcba1 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -162,6 +162,15 @@ "environment.development": "ENTWICKLUNG", "environment.testing": "TESTEN", "error": "Fehler", + "errorBoundary.componentStack": "Komponenten-Stack:", + "errorBoundary.details": "Fehlerdetails (Nur Entwicklung)", + "errorBoundary.errorCount": "Dieser Fehler ist {{count}} Mal aufgetreten", + "errorBoundary.fallbackTitle": "Hoppla! Etwas ist schiefgelaufen", + "errorBoundary.message": "Beim Rendern dieser Komponente ist ein unerwarteter Fehler aufgetreten.", + "errorBoundary.reloadPage": "Seite neu laden", + "errorBoundary.title": "Etwas ist schiefgelaufen", + "errorBoundary.tryAgain": "Erneut versuchen", + "errorBoundary.tryAgainCompact": "Erneut versuchen", "errorMsg.fieldRequired": "{{field}} ist erforderlich", "errorMsg.urlError": "Die URL sollte mit http:// oder https:// beginnen", "feedback.content": "Feedback-Inhalt", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index 1b97ce680d..38f9d10396 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -162,6 +162,15 @@ "environment.development": "DESARROLLO", "environment.testing": "PRUEBAS", "error": "Error", + "errorBoundary.componentStack": "Pila de Componentes:", + "errorBoundary.details": "Detalles del Error (Solo Desarrollo)", + "errorBoundary.errorCount": "Este error ha ocurrido {{count}} veces", + "errorBoundary.fallbackTitle": "¡Vaya! Algo salió mal", + "errorBoundary.message": "Ocurrió un error inesperado al renderizar este componente.", + "errorBoundary.reloadPage": "Recargar Página", + "errorBoundary.title": "Algo salió mal", + "errorBoundary.tryAgain": "Intentar de Nuevo", + "errorBoundary.tryAgainCompact": "Intentar de nuevo", "errorMsg.fieldRequired": "{{field}} es requerido", "errorMsg.urlError": "la URL debe comenzar con http:// o https://", "feedback.content": "Contenido de retroalimentación", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index d2b1e8158c..686a6a5997 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -162,6 +162,15 @@ "environment.development": "توسعه", "environment.testing": "آزمایشی", "error": "خطا", + "errorBoundary.componentStack": "پشته کامپوننت:", + "errorBoundary.details": "جزئیات خطا (فقط در محیط توسعه)", + "errorBoundary.errorCount": "این خطا {{count}} بار رخ داده است", + "errorBoundary.fallbackTitle": "اوه! مشکلی پیش آمد", + "errorBoundary.message": "هنگام رندر کردن این کامپوننت، یک خطای غیرمنتظره رخ داد.", + "errorBoundary.reloadPage": "بارگذاری مجدد صفحه", + "errorBoundary.title": "مشکلی پیش آمد", + "errorBoundary.tryAgain": "تلاش مجدد", + "errorBoundary.tryAgainCompact": "تلاش مجدد", "errorMsg.fieldRequired": "{{field}} الزامی است", "errorMsg.urlError": "آدرس باید با http:// یا https:// شروع شود", "feedback.content": "محتوای بازخورد", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 8710c04c44..a2bc856bf7 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -162,6 +162,15 @@ "environment.development": "DÉVELOPPEMENT", "environment.testing": "TESTER", "error": "Erreur", + "errorBoundary.componentStack": "Pile du Composant :", + "errorBoundary.details": "Détails de l'Erreur (Développement Uniquement)", + "errorBoundary.errorCount": "Cette erreur s'est produite {{count}} fois", + "errorBoundary.fallbackTitle": "Oups ! Quelque chose s'est mal passé", + "errorBoundary.message": "Une erreur inattendue s'est produite lors du rendu de ce composant.", + "errorBoundary.reloadPage": "Recharger la Page", + "errorBoundary.title": "Quelque chose s'est mal passé", + "errorBoundary.tryAgain": "Réessayer", + "errorBoundary.tryAgainCompact": "Réessayer", "errorMsg.fieldRequired": "{{field}} est obligatoire", "errorMsg.urlError": "L’URL doit commencer par http:// ou https://", "feedback.content": "Contenu des retours", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index e61c96ca45..bbdd619b12 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -162,6 +162,15 @@ "environment.development": "विकास", "environment.testing": "परीक्षण", "error": "त्रुटि", + "errorBoundary.componentStack": "कंपोनेंट स्टैक:", + "errorBoundary.details": "त्रुटि विवरण (केवल डेवलपमेंट)", + "errorBoundary.errorCount": "यह त्रुटि {{count}} बार हुई है", + "errorBoundary.fallbackTitle": "उफ़! कुछ गलत हो गया", + "errorBoundary.message": "इस कंपोनेंट को रेंडर करते समय एक अप्रत्याशित त्रुटि हुई।", + "errorBoundary.reloadPage": "पेज रीलोड करें", + "errorBoundary.title": "कुछ गलत हो गया", + "errorBoundary.tryAgain": "पुनः प्रयास करें", + "errorBoundary.tryAgainCompact": "पुनः प्रयास करें", "errorMsg.fieldRequired": "{{field}} आवश्यक है", "errorMsg.urlError": "url को http:// या https:// से शुरू होना चाहिए", "feedback.content": "प्रतिक्रिया सामग्री", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index 51cd429992..81245aec7b 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -162,6 +162,15 @@ "environment.development": "PENGEMBANGAN", "environment.testing": "PENGUJIAN", "error": "Kesalahan", + "errorBoundary.componentStack": "Tumpukan Komponen:", + "errorBoundary.details": "Detail Kesalahan (Hanya Pengembangan)", + "errorBoundary.errorCount": "Kesalahan ini telah terjadi {{count}} kali", + "errorBoundary.fallbackTitle": "Ups! Ada yang salah", + "errorBoundary.message": "Terjadi kesalahan tak terduga saat merender komponen ini.", + "errorBoundary.reloadPage": "Muat Ulang Halaman", + "errorBoundary.title": "Ada yang salah", + "errorBoundary.tryAgain": "Coba Lagi", + "errorBoundary.tryAgainCompact": "Coba lagi", "errorMsg.fieldRequired": "{{field}} wajib diisi", "errorMsg.urlError": "URL harus dimulai dengan http:// atau https://", "feedback.content": "Konten Umpan Balik", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index 283c090ea8..b389ed09ef 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -162,6 +162,15 @@ "environment.development": "SVILUPPO", "environment.testing": "TEST", "error": "Errore", + "errorBoundary.componentStack": "Stack del Componente:", + "errorBoundary.details": "Dettagli Errore (Solo Sviluppo)", + "errorBoundary.errorCount": "Questo errore si è verificato {{count}} volte", + "errorBoundary.fallbackTitle": "Ops! Qualcosa è andato storto", + "errorBoundary.message": "Si è verificato un errore imprevisto durante il rendering di questo componente.", + "errorBoundary.reloadPage": "Ricarica Pagina", + "errorBoundary.title": "Qualcosa è andato storto", + "errorBoundary.tryAgain": "Riprova", + "errorBoundary.tryAgainCompact": "Riprova", "errorMsg.fieldRequired": "{{field}} è obbligatorio", "errorMsg.urlError": "L'URL deve iniziare con http:// o https://", "feedback.content": "Contenuto del feedback", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index a65d8e933c..7b2e34e757 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -162,6 +162,15 @@ "environment.development": "開発", "environment.testing": "テスト", "error": "エラー", + "errorBoundary.componentStack": "コンポーネントスタック:", + "errorBoundary.details": "エラー詳細(開発環境のみ)", + "errorBoundary.errorCount": "このエラーは{{count}}回発生しました", + "errorBoundary.fallbackTitle": "おっと!問題が発生しました", + "errorBoundary.message": "このコンポーネントのレンダリング中に予期しないエラーが発生しました。", + "errorBoundary.reloadPage": "ページを再読み込み", + "errorBoundary.title": "問題が発生しました", + "errorBoundary.tryAgain": "再試行", + "errorBoundary.tryAgainCompact": "再試行", "errorMsg.fieldRequired": "{{field}}は必要です", "errorMsg.urlError": "URL は http:// または https:// で始まる必要があります", "feedback.content": "フィードバックコンテンツ", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index e50a7c2428..bdd08a7715 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -162,6 +162,15 @@ "environment.development": "개발", "environment.testing": "테스트", "error": "오류", + "errorBoundary.componentStack": "컴포넌트 스택:", + "errorBoundary.details": "오류 세부 정보 (개발 환경 전용)", + "errorBoundary.errorCount": "이 오류가 {{count}}번 발생했습니다", + "errorBoundary.fallbackTitle": "이런! 문제가 발생했습니다", + "errorBoundary.message": "이 컴포넌트를 렌더링하는 동안 예기치 않은 오류가 발생했습니다.", + "errorBoundary.reloadPage": "페이지 새로고침", + "errorBoundary.title": "문제가 발생했습니다", + "errorBoundary.tryAgain": "다시 시도", + "errorBoundary.tryAgainCompact": "다시 시도", "errorMsg.fieldRequired": "{{field}}는 필수입니다.", "errorMsg.urlError": "URL 은 http:// 또는 https:// 로 시작해야 합니다.", "feedback.content": "피드백 내용", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index fb1b332a0c..592b85dc63 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -162,6 +162,15 @@ "environment.development": "DEVELOPMENT", "environment.testing": "TESTING", "error": "Error", + "errorBoundary.componentStack": "Componentstack:", + "errorBoundary.details": "Foutdetails (Alleen Ontwikkeling)", + "errorBoundary.errorCount": "Deze fout is {{count}} keer opgetreden", + "errorBoundary.fallbackTitle": "Oeps! Er is iets fout gegaan", + "errorBoundary.message": "Er is een onverwachte fout opgetreden bij het renderen van dit component.", + "errorBoundary.reloadPage": "Pagina herladen", + "errorBoundary.title": "Er is iets fout gegaan", + "errorBoundary.tryAgain": "Opnieuw proberen", + "errorBoundary.tryAgainCompact": "Opnieuw proberen", "errorMsg.fieldRequired": "{{field}} is required", "errorMsg.urlError": "url should start with http:// or https://", "feedback.content": "Feedback Content", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index 130950a57c..5e693dd2f5 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -162,6 +162,15 @@ "environment.development": "ROZWOJOWA", "environment.testing": "TESTOWANIE", "error": "Błąd", + "errorBoundary.componentStack": "Stos komponentów:", + "errorBoundary.details": "Szczegóły błędu (tylko tryb deweloperski)", + "errorBoundary.errorCount": "Ten błąd wystąpił {{count}} razy", + "errorBoundary.fallbackTitle": "Ups! Coś poszło nie tak", + "errorBoundary.message": "Wystąpił nieoczekiwany błąd podczas renderowania tego komponentu.", + "errorBoundary.reloadPage": "Odśwież stronę", + "errorBoundary.title": "Coś poszło nie tak", + "errorBoundary.tryAgain": "Spróbuj ponownie", + "errorBoundary.tryAgainCompact": "Spróbuj ponownie", "errorMsg.fieldRequired": "{{field}} jest wymagane", "errorMsg.urlError": "Adres URL powinien zaczynać się od http:// lub https://", "feedback.content": "Treść opinii", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index 6840bb964b..2d40cf1ee7 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -162,6 +162,15 @@ "environment.development": "DESENVOLVIMENTO", "environment.testing": "TESTE", "error": "Erro", + "errorBoundary.componentStack": "Stack do Componente:", + "errorBoundary.details": "Detalhes do Erro (Somente Desenvolvimento)", + "errorBoundary.errorCount": "Este erro ocorreu {{count}} vezes", + "errorBoundary.fallbackTitle": "Ops! Algo deu errado", + "errorBoundary.message": "Ocorreu um erro inesperado ao renderizar este componente.", + "errorBoundary.reloadPage": "Recarregar Página", + "errorBoundary.title": "Algo deu errado", + "errorBoundary.tryAgain": "Tentar Novamente", + "errorBoundary.tryAgainCompact": "Tentar novamente", "errorMsg.fieldRequired": "{{field}} é obrigatório", "errorMsg.urlError": "URL deve começar com http:// ou https://", "feedback.content": "Conteúdo do feedback", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index 306439768b..84ae4cdce0 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -162,6 +162,15 @@ "environment.development": "DEZVOLTARE", "environment.testing": "TESTARE", "error": "Eroare", + "errorBoundary.componentStack": "Stiva componentelor:", + "errorBoundary.details": "Detalii eroare (Numai în dezvoltare)", + "errorBoundary.errorCount": "Această eroare a apărut de {{count}} ori", + "errorBoundary.fallbackTitle": "Ups! Ceva a mers prost", + "errorBoundary.message": "A apărut o eroare neașteptată la redarea acestei componente.", + "errorBoundary.reloadPage": "Reîncarcă pagina", + "errorBoundary.title": "Ceva a mers prost", + "errorBoundary.tryAgain": "Încearcă din nou", + "errorBoundary.tryAgainCompact": "Încearcă din nou", "errorMsg.fieldRequired": "{{field}} este obligatoriu", "errorMsg.urlError": "URL-ul ar trebui să înceapă cu http:// sau https://", "feedback.content": "Conținut de feedback", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index aec9a69483..ba6a3f6078 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -162,6 +162,15 @@ "environment.development": "РАЗРАБОТКА", "environment.testing": "ТЕСТИРОВАНИЕ", "error": "Ошибка", + "errorBoundary.componentStack": "Стек компонентов:", + "errorBoundary.details": "Детали ошибки (только разработка)", + "errorBoundary.errorCount": "Эта ошибка произошла {{count}} раз(а)", + "errorBoundary.fallbackTitle": "Упс! Что-то пошло не так", + "errorBoundary.message": "При рендеринге этого компонента произошла непредвиденная ошибка.", + "errorBoundary.reloadPage": "Перезагрузить страницу", + "errorBoundary.title": "Что-то пошло не так", + "errorBoundary.tryAgain": "Попробовать снова", + "errorBoundary.tryAgainCompact": "Попробовать снова", "errorMsg.fieldRequired": "{{field}} обязательно", "errorMsg.urlError": "URL должен начинаться с http:// или https://", "feedback.content": "Содержимое обратной связи", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index 6ec4fe430c..a0e7130176 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -162,6 +162,15 @@ "environment.development": "RAZVOJ", "environment.testing": "PREIZKUŠANJE", "error": "Napaka", + "errorBoundary.componentStack": "Sklad komponent:", + "errorBoundary.details": "Podrobnosti napake (samo razvojna okolja)", + "errorBoundary.errorCount": "Ta napaka se je pojavila {{count}} krat", + "errorBoundary.fallbackTitle": "Ojoj! Nekaj je šlo narobe", + "errorBoundary.message": "Med prikazovanjem te komponente je prišlo do nepričakovane napake.", + "errorBoundary.reloadPage": "Znova naloži stran", + "errorBoundary.title": "Nekaj je šlo narobe", + "errorBoundary.tryAgain": "Poskusi znova", + "errorBoundary.tryAgainCompact": "Poskusi znova", "errorMsg.fieldRequired": "{{field}} je obvezno", "errorMsg.urlError": "url mora začeti z http:// ali https://", "feedback.content": "Vsebina povratnih informacij", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index 6eed5eba93..eb45b7e796 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -162,6 +162,15 @@ "environment.development": "พัฒนาการ", "environment.testing": "การทดสอบ", "error": "ข้อผิดพลาด", + "errorBoundary.componentStack": "สแตกของคอมโพเนนต์:", + "errorBoundary.details": "รายละเอียดข้อผิดพลาด (สำหรับการพัฒนาเท่านั้น)", + "errorBoundary.errorCount": "ข้อผิดพลาดนี้เกิดขึ้น {{count}} ครั้ง", + "errorBoundary.fallbackTitle": "อุ๊ปส์! มีบางอย่างผิดพลาด", + "errorBoundary.message": "เกิดข้อผิดพลาดที่ไม่คาดคิดขณะแสดงผลคอมโพเนนต์นี้", + "errorBoundary.reloadPage": "โหลดหน้าใหม่", + "errorBoundary.title": "มีบางอย่างผิดพลาด", + "errorBoundary.tryAgain": "ลองอีกครั้ง", + "errorBoundary.tryAgainCompact": "ลองอีกครั้ง", "errorMsg.fieldRequired": "{{field}} เป็นสิ่งจําเป็น", "errorMsg.urlError": "url ควรขึ้นต้นด้วย http:// หรือ https://", "feedback.content": "เนื้อหาข้อเสนอแนะ", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 66e895fd2b..f8877e75ca 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -162,6 +162,15 @@ "environment.development": "GELİŞTİRME", "environment.testing": "TEST", "error": "Hata", + "errorBoundary.componentStack": "Bileşen Yığını:", + "errorBoundary.details": "Hata Ayrıntıları (Yalnızca Geliştirme)", + "errorBoundary.errorCount": "Bu hata {{count}} kez oluştu", + "errorBoundary.fallbackTitle": "Hay aksi! Bir şeyler ters gitti", + "errorBoundary.message": "Bu bileşen işlenirken beklenmedik bir hata oluştu.", + "errorBoundary.reloadPage": "Sayfayı Yenile", + "errorBoundary.title": "Bir şeyler ters gitti", + "errorBoundary.tryAgain": "Tekrar Dene", + "errorBoundary.tryAgainCompact": "Tekrar dene", "errorMsg.fieldRequired": "{{field}} gereklidir", "errorMsg.urlError": "URL http:// veya https:// ile başlamalıdır", "feedback.content": "Geri Bildirim İçeriği", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index 806cbede3d..2eb457c835 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -162,6 +162,15 @@ "environment.development": "РОЗРОБКА", "environment.testing": "ТЕСТУВАННЯ", "error": "Помилка", + "errorBoundary.componentStack": "Стек компонентів:", + "errorBoundary.details": "Деталі помилки (тільки розробка)", + "errorBoundary.errorCount": "Ця помилка сталася {{count}} раз(ів)", + "errorBoundary.fallbackTitle": "Ой! Щось пішло не так", + "errorBoundary.message": "Під час відображення цього компонента сталася непередбачена помилка.", + "errorBoundary.reloadPage": "Перезавантажити сторінку", + "errorBoundary.title": "Щось пішло не так", + "errorBoundary.tryAgain": "Спробувати знову", + "errorBoundary.tryAgainCompact": "Спробувати знову", "errorMsg.fieldRequired": "{{field}} є обов'язковим", "errorMsg.urlError": "URL-адреса повинна починатися з http:// або https://", "feedback.content": "Зміст відгуку", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index 820bfdfdab..13e74daccf 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -162,6 +162,15 @@ "environment.development": "DEVELOPMENT", "environment.testing": "TESTING", "error": "Lỗi", + "errorBoundary.componentStack": "Ngăn xếp thành phần:", + "errorBoundary.details": "Chi tiết lỗi (Chỉ dành cho phát triển)", + "errorBoundary.errorCount": "Lỗi này đã xảy ra {{count}} lần", + "errorBoundary.fallbackTitle": "Ôi! Đã xảy ra sự cố", + "errorBoundary.message": "Đã xảy ra lỗi không mong muốn khi hiển thị thành phần này.", + "errorBoundary.reloadPage": "Tải lại trang", + "errorBoundary.title": "Đã xảy ra sự cố", + "errorBoundary.tryAgain": "Thử lại", + "errorBoundary.tryAgainCompact": "Thử lại", "errorMsg.fieldRequired": "{{field}} là bắt buộc", "errorMsg.urlError": "URL phải bắt đầu bằng http:// hoặc https://", "feedback.content": "Nội dung phản hồi", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 9676b8efb2..3c406e8938 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -162,6 +162,15 @@ "environment.development": "开发环境", "environment.testing": "测试环境", "error": "错误", + "errorBoundary.componentStack": "组件堆栈:", + "errorBoundary.details": "错误详情(仅开发模式)", + "errorBoundary.errorCount": "此错误已发生 {{count}} 次", + "errorBoundary.fallbackTitle": "哎呀!出了点问题", + "errorBoundary.message": "渲染此组件时发生了意外错误。", + "errorBoundary.reloadPage": "重新加载页面", + "errorBoundary.title": "出了点问题", + "errorBoundary.tryAgain": "重试", + "errorBoundary.tryAgainCompact": "重试", "errorMsg.fieldRequired": "{{field}} 为必填项", "errorMsg.urlError": "url 应该以 http:// 或 https:// 开头", "feedback.content": "反馈内容", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 9317f68f82..6cabc3638f 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -162,6 +162,15 @@ "environment.development": "開發環境", "environment.testing": "測試環境", "error": "錯誤", + "errorBoundary.componentStack": "元件堆疊:", + "errorBoundary.details": "錯誤詳情(僅開發模式)", + "errorBoundary.errorCount": "此錯誤已發生 {{count}} 次", + "errorBoundary.fallbackTitle": "哎呀!出了點問題", + "errorBoundary.message": "渲染此元件時發生了意外錯誤。", + "errorBoundary.reloadPage": "重新載入頁面", + "errorBoundary.title": "出了點問題", + "errorBoundary.tryAgain": "重試", + "errorBoundary.tryAgainCompact": "重試", "errorMsg.fieldRequired": "{{field}} 為必填項", "errorMsg.urlError": "URL 應以 http:// 或 https:// 開頭", "feedback.content": "反饋內容", From b818cc07662adaec161367cab630c219ac6d99b5 Mon Sep 17 00:00:00 2001 From: Desel72 Date: Tue, 31 Mar 2026 16:06:42 +0300 Subject: [PATCH 08/30] test: migrate apikey controller tests to testcontainers (#34286) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../controllers/console/test_apikey.py | 153 ++++++++++++++++++ .../controllers/console/test_apikey.py | 139 ---------------- 2 files changed, 153 insertions(+), 139 deletions(-) create mode 100644 api/tests/test_containers_integration_tests/controllers/console/test_apikey.py delete mode 100644 api/tests/unit_tests/controllers/console/test_apikey.py diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py b/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py new file mode 100644 index 0000000000..7df63aae1a --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py @@ -0,0 +1,153 @@ +"""Integration tests for console API key endpoints using testcontainers.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from flask.testing import FlaskClient +from sqlalchemy import delete +from sqlalchemy.orm import Session + +from models.enums import ApiTokenType +from models.model import ApiToken, App, AppMode +from tests.test_containers_integration_tests.controllers.console.helpers import ( + authenticate_console_client, + create_console_account_and_tenant, + create_console_app, +) + + +@pytest.fixture +def setup_app( + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, +) -> tuple[FlaskClient, dict[str, str], App]: + """Create an authenticated client with an app for API key tests.""" + account, tenant = create_console_account_and_tenant(db_session_with_containers) + app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT) + headers = authenticate_console_client(test_client_with_containers, account) + return test_client_with_containers, headers, app + + +@pytest.fixture(autouse=True) +def cleanup_api_tokens(db_session_with_containers: Session): + """Remove API tokens created during each test.""" + yield + db_session_with_containers.execute(delete(ApiToken)) + db_session_with_containers.commit() + + +class TestAppApiKeyListResource: + """Tests for GET/POST /apps//api-keys.""" + + def test_get_empty_keys(self, setup_app: tuple[FlaskClient, dict[str, str], App]) -> None: + client, headers, app = setup_app + resp = client.get(f"/console/api/apps/{app.id}/api-keys", headers=headers) + assert resp.status_code == 200 + assert resp.json is not None + assert resp.json["data"] == [] + + def test_create_api_key(self, setup_app: tuple[FlaskClient, dict[str, str], App]) -> None: + client, headers, app = setup_app + resp = client.post(f"/console/api/apps/{app.id}/api-keys", headers=headers) + assert resp.status_code == 201 + data = resp.json + assert data is not None + assert data["token"].startswith("app-") + assert data["id"] is not None + + def test_get_keys_after_create(self, setup_app: tuple[FlaskClient, dict[str, str], App]) -> None: + client, headers, app = setup_app + client.post(f"/console/api/apps/{app.id}/api-keys", headers=headers) + client.post(f"/console/api/apps/{app.id}/api-keys", headers=headers) + + resp = client.get(f"/console/api/apps/{app.id}/api-keys", headers=headers) + assert resp.status_code == 200 + assert resp.json is not None + assert len(resp.json["data"]) == 2 + + def test_create_key_max_limit( + self, + setup_app: tuple[FlaskClient, dict[str, str], App], + db_session_with_containers: Session, + ) -> None: + client, headers, app = setup_app + # Create 10 keys (the max) + for _ in range(10): + client.post(f"/console/api/apps/{app.id}/api-keys", headers=headers) + + # 11th should fail + resp = client.post(f"/console/api/apps/{app.id}/api-keys", headers=headers) + assert resp.status_code == 400 + + def test_get_keys_for_nonexistent_app( + self, + setup_app: tuple[FlaskClient, dict[str, str], App], + ) -> None: + client, headers, _ = setup_app + resp = client.get( + "/console/api/apps/00000000-0000-0000-0000-000000000000/api-keys", + headers=headers, + ) + assert resp.status_code == 404 + + +class TestAppApiKeyResource: + """Tests for DELETE /apps//api-keys/.""" + + def test_delete_key_success(self, setup_app: tuple[FlaskClient, dict[str, str], App]) -> None: + client, headers, app = setup_app + create_resp = client.post(f"/console/api/apps/{app.id}/api-keys", headers=headers) + assert create_resp.json is not None + key_id = create_resp.json["id"] + + resp = client.delete(f"/console/api/apps/{app.id}/api-keys/{key_id}", headers=headers) + assert resp.status_code == 204 + + def test_delete_nonexistent_key(self, setup_app: tuple[FlaskClient, dict[str, str], App]) -> None: + client, headers, app = setup_app + resp = client.delete( + f"/console/api/apps/{app.id}/api-keys/00000000-0000-0000-0000-000000000000", + headers=headers, + ) + assert resp.status_code == 404 + + def test_delete_key_nonexistent_app( + self, + setup_app: tuple[FlaskClient, dict[str, str], App], + ) -> None: + client, headers, _ = setup_app + resp = client.delete( + "/console/api/apps/00000000-0000-0000-0000-000000000000/api-keys/00000000-0000-0000-0000-000000000000", + headers=headers, + ) + assert resp.status_code == 404 + + def test_delete_forbidden_for_non_admin( + self, + flask_app_with_containers, + ) -> None: + """A non-admin member cannot delete API keys via the controller permission check.""" + from werkzeug.exceptions import Forbidden + + from controllers.console.apikey import BaseApiKeyResource + + resource = BaseApiKeyResource() + resource.resource_type = ApiTokenType.APP + resource.resource_model = MagicMock() + resource.resource_id_field = "app_id" + + non_admin = MagicMock() + non_admin.is_admin_or_owner = False + + with ( + flask_app_with_containers.test_request_context("/"), + patch( + "controllers.console.apikey.current_account_with_tenant", + return_value=(non_admin, "tenant-id"), + ), + patch("controllers.console.apikey._get_resource"), + ): + with pytest.raises(Forbidden): + BaseApiKeyResource.delete(resource, "rid", "kid") diff --git a/api/tests/unit_tests/controllers/console/test_apikey.py b/api/tests/unit_tests/controllers/console/test_apikey.py deleted file mode 100644 index 2dff9c4037..0000000000 --- a/api/tests/unit_tests/controllers/console/test_apikey.py +++ /dev/null @@ -1,139 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from werkzeug.exceptions import Forbidden - -from controllers.console.apikey import ( - BaseApiKeyListResource, - BaseApiKeyResource, - _get_resource, -) -from models.enums import ApiTokenType - - -@pytest.fixture -def tenant_context_admin(): - with patch("controllers.console.apikey.current_account_with_tenant") as mock: - user = MagicMock() - user.is_admin_or_owner = True - mock.return_value = (user, "tenant-123") - yield mock - - -@pytest.fixture -def tenant_context_non_admin(): - with patch("controllers.console.apikey.current_account_with_tenant") as mock: - user = MagicMock() - user.is_admin_or_owner = False - mock.return_value = (user, "tenant-123") - yield mock - - -@pytest.fixture -def db_mock(): - with patch("controllers.console.apikey.db") as mock_db: - mock_db.session = MagicMock() - yield mock_db - - -@pytest.fixture(autouse=True) -def bypass_permissions(): - with patch( - "controllers.console.apikey.edit_permission_required", - lambda f: f, - ): - yield - - -class DummyApiKeyListResource(BaseApiKeyListResource): - resource_type = ApiTokenType.APP - resource_model = MagicMock() - resource_id_field = "app_id" - token_prefix = "app-" - - -class DummyApiKeyResource(BaseApiKeyResource): - resource_type = ApiTokenType.APP - resource_model = MagicMock() - resource_id_field = "app_id" - - -class TestGetResource: - def test_get_resource_success(self): - fake_resource = MagicMock() - - with ( - patch("controllers.console.apikey.select") as mock_select, - patch("controllers.console.apikey.Session") as mock_session, - patch("controllers.console.apikey.db") as mock_db, - ): - mock_db.engine = MagicMock() - mock_select.return_value.filter_by.return_value = MagicMock() - - session = mock_session.return_value.__enter__.return_value - session.execute.return_value.scalar_one_or_none.return_value = fake_resource - - result = _get_resource("rid", "tid", MagicMock) - assert result == fake_resource - - def test_get_resource_not_found(self): - with ( - patch("controllers.console.apikey.select") as mock_select, - patch("controllers.console.apikey.Session") as mock_session, - patch("controllers.console.apikey.db") as mock_db, - patch("controllers.console.apikey.flask_restx.abort") as abort, - ): - mock_db.engine = MagicMock() - mock_select.return_value.filter_by.return_value = MagicMock() - - session = mock_session.return_value.__enter__.return_value - session.execute.return_value.scalar_one_or_none.return_value = None - - _get_resource("rid", "tid", MagicMock) - - abort.assert_called_once() - - -class TestBaseApiKeyListResource: - def test_get_apikeys_success(self, tenant_context_admin, db_mock): - resource = DummyApiKeyListResource() - - with patch("controllers.console.apikey._get_resource"): - db_mock.session.scalars.return_value.all.return_value = [MagicMock(), MagicMock()] - - result = DummyApiKeyListResource.get.__wrapped__(resource, "resource-id") - assert "items" in result - - -class TestBaseApiKeyResource: - def test_delete_forbidden(self, tenant_context_non_admin, db_mock): - resource = DummyApiKeyResource() - - with patch("controllers.console.apikey._get_resource"): - with pytest.raises(Forbidden): - DummyApiKeyResource.delete(resource, "rid", "kid") - - def test_delete_key_not_found(self, tenant_context_admin, db_mock): - resource = DummyApiKeyResource() - db_mock.session.scalar.return_value = None - - with patch("controllers.console.apikey._get_resource"): - with pytest.raises(Exception) as exc_info: - DummyApiKeyResource.delete(resource, "rid", "kid") - - # flask_restx.abort raises HTTPException with message in data attribute - assert exc_info.value.data["message"] == "API key not found" - - def test_delete_success(self, tenant_context_admin, db_mock): - resource = DummyApiKeyResource() - db_mock.session.scalar.return_value = MagicMock() - - with ( - patch("controllers.console.apikey._get_resource"), - patch("controllers.console.apikey.ApiTokenCache.delete"), - ): - result, status = DummyApiKeyResource.delete(resource, "rid", "kid") - - assert status == 204 - assert result == {"result": "success"} - db_mock.session.commit.assert_called_once() From d9a0665b2c8bfee584c2caf59f0312d1e5ace4f1 Mon Sep 17 00:00:00 2001 From: Desel72 Date: Tue, 31 Mar 2026 16:09:18 +0300 Subject: [PATCH 09/30] refactor: use sessionmaker().begin() in console datasets controllers (#34283) --- .../console/datasets/data_source.py | 6 ++--- .../datasets/rag_pipeline/rag_pipeline.py | 4 +-- .../rag_pipeline/rag_pipeline_datasets.py | 4 +-- .../rag_pipeline_draft_variable.py | 8 +++--- .../rag_pipeline/rag_pipeline_import.py | 12 ++++----- .../rag_pipeline/rag_pipeline_workflow.py | 16 ++++-------- .../console/datasets/test_data_source.py | 26 +++++++++---------- 7 files changed, 34 insertions(+), 42 deletions(-) diff --git a/api/controllers/console/datasets/data_source.py b/api/controllers/console/datasets/data_source.py index daef4e005a..ac14349045 100644 --- a/api/controllers/console/datasets/data_source.py +++ b/api/controllers/console/datasets/data_source.py @@ -6,7 +6,7 @@ from flask import request from flask_restx import Resource, fields, marshal_with from pydantic import BaseModel, Field from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound from controllers.common.schema import get_or_create_model, register_schema_model @@ -159,7 +159,7 @@ class DataSourceApi(Resource): @account_initialization_required def patch(self, binding_id, action: Literal["enable", "disable"]): binding_id = str(binding_id) - with Session(db.engine) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: data_source_binding = session.execute( select(DataSourceOauthBinding).filter_by(id=binding_id) ).scalar_one_or_none() @@ -211,7 +211,7 @@ class DataSourceNotionListApi(Resource): if not credential: raise NotFound("Credential not found.") exist_page_ids = [] - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: # import notion in the exist dataset if query.dataset_id: dataset = DatasetService.get_dataset(query.dataset_id) diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py index 4f31093cfe..1758bad31d 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py @@ -3,7 +3,7 @@ import logging from flask import request from flask_restx import Resource from pydantic import BaseModel, Field -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from controllers.common.schema import register_schema_models from controllers.console import console_ns @@ -85,7 +85,7 @@ class CustomizedPipelineTemplateApi(Resource): @account_initialization_required @enterprise_license_required def post(self, template_id: str): - with Session(db.engine) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: template = ( session.query(PipelineCustomizedTemplate).where(PipelineCustomizedTemplate.id == template_id).first() ) diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_datasets.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_datasets.py index e65cb19b39..a6ca0689d0 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_datasets.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_datasets.py @@ -1,6 +1,6 @@ from flask_restx import Resource, marshal from pydantic import BaseModel -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden import services @@ -54,7 +54,7 @@ class CreateRagPipelineDatasetApi(Resource): yaml_content=payload.yaml_content, ) try: - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: rag_pipeline_dsl_service = RagPipelineDslService(session) import_info = rag_pipeline_dsl_service.create_rag_pipeline_dataset( tenant_id=current_tenant_id, diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py index f12cbd3495..d635dcb530 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py @@ -5,7 +5,7 @@ from flask import Response, request from flask_restx import Resource, marshal, marshal_with from graphon.variables.types import SegmentType from pydantic import BaseModel, Field -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden from controllers.common.schema import register_schema_models @@ -96,7 +96,7 @@ class RagPipelineVariableCollectionApi(Resource): raise DraftWorkflowNotExist() # fetch draft workflow by app_model - with Session(bind=db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: draft_var_srv = WorkflowDraftVariableService( session=session, ) @@ -143,7 +143,7 @@ class RagPipelineNodeVariableCollectionApi(Resource): @marshal_with(workflow_draft_variable_list_model) def get(self, pipeline: Pipeline, node_id: str): validate_node_id(node_id) - with Session(bind=db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: draft_var_srv = WorkflowDraftVariableService( session=session, ) @@ -289,7 +289,7 @@ class RagPipelineVariableResetApi(Resource): def _get_variable_list(pipeline: Pipeline, node_id) -> WorkflowDraftVariableList: - with Session(bind=db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: draft_var_srv = WorkflowDraftVariableService( session=session, ) diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py index af142b4646..732a6dc446 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py @@ -1,7 +1,7 @@ from flask import request from flask_restx import Resource, fields, marshal_with # type: ignore from pydantic import BaseModel, Field -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from controllers.common.schema import get_or_create_model, register_schema_models from controllers.console import console_ns @@ -68,7 +68,7 @@ class RagPipelineImportApi(Resource): payload = RagPipelineImportPayload.model_validate(console_ns.payload or {}) # Create service with session - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: import_service = RagPipelineDslService(session) # Import app account = current_user @@ -80,7 +80,6 @@ class RagPipelineImportApi(Resource): pipeline_id=payload.pipeline_id, dataset_name=payload.name, ) - session.commit() # Return appropriate status code based on result status = result.status @@ -102,12 +101,11 @@ class RagPipelineImportConfirmApi(Resource): current_user, _ = current_account_with_tenant() # Create service with session - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: import_service = RagPipelineDslService(session) # Confirm import account = current_user result = import_service.confirm_import(import_id=import_id, account=account) - session.commit() # Return appropriate status code based on result if result.status == ImportStatus.FAILED: @@ -124,7 +122,7 @@ class RagPipelineImportCheckDependenciesApi(Resource): @edit_permission_required @marshal_with(pipeline_import_check_dependencies_model) def get(self, pipeline: Pipeline): - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: import_service = RagPipelineDslService(session) result = import_service.check_dependencies(pipeline=pipeline) @@ -142,7 +140,7 @@ class RagPipelineExportApi(Resource): # Add include_secret params query = IncludeSecretQuery.model_validate(request.args.to_dict()) - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: export_service = RagPipelineDslService(session) result = export_service.export_rag_pipeline_dsl( pipeline=pipeline, include_secret=query.include_secret == "true" diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index 8efb59a8e9..e08cb155b6 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -6,7 +6,7 @@ from flask import abort, request from flask_restx import Resource, marshal_with # type: ignore from graphon.model_runtime.utils.encoders import jsonable_encoder from pydantic import BaseModel, Field -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services @@ -608,7 +608,7 @@ class PublishedRagPipelineApi(Resource): # The role of the current user in the ta table must be admin, owner, or editor current_user, _ = current_account_with_tenant() rag_pipeline_service = RagPipelineService() - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: pipeline = session.merge(pipeline) workflow = rag_pipeline_service.publish_workflow( session=session, @@ -620,8 +620,6 @@ class PublishedRagPipelineApi(Resource): session.add(pipeline) workflow_created_at = TimestampField().format(workflow.created_at) - session.commit() - return { "result": "success", "created_at": workflow_created_at, @@ -695,7 +693,7 @@ class PublishedAllRagPipelineApi(Resource): raise Forbidden() rag_pipeline_service = RagPipelineService() - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: workflows, has_more = rag_pipeline_service.get_all_published_workflow( session=session, pipeline=pipeline, @@ -767,7 +765,7 @@ class RagPipelineByIdApi(Resource): rag_pipeline_service = RagPipelineService() # Create a session and manage the transaction - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: workflow = rag_pipeline_service.update_workflow( session=session, workflow_id=workflow_id, @@ -779,9 +777,6 @@ class RagPipelineByIdApi(Resource): if not workflow: raise NotFound("Workflow not found") - # Commit the transaction in the controller - session.commit() - return workflow @setup_required @@ -798,14 +793,13 @@ class RagPipelineByIdApi(Resource): workflow_service = WorkflowService() - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: try: workflow_service.delete_workflow( session=session, workflow_id=workflow_id, tenant_id=pipeline.tenant_id, ) - session.commit() except WorkflowInUseError as e: abort(400, description=str(e)) except DraftWorkflowDeletionError as e: diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py index 1c07d4ca1c..1c4c6a899f 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py @@ -102,12 +102,12 @@ class TestDataSourceApi: with ( app.test_request_context("/"), - patch("controllers.console.datasets.data_source.Session") as mock_session_class, + patch("controllers.console.datasets.data_source.sessionmaker") as mock_session_class, patch("controllers.console.datasets.data_source.db.session.add"), patch("controllers.console.datasets.data_source.db.session.commit"), ): mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_session_class.return_value.begin.return_value.__enter__.return_value = mock_session mock_session.execute.return_value.scalar_one_or_none.return_value = binding response, status = method(api, "b1", "enable") @@ -123,12 +123,12 @@ class TestDataSourceApi: with ( app.test_request_context("/"), - patch("controllers.console.datasets.data_source.Session") as mock_session_class, + patch("controllers.console.datasets.data_source.sessionmaker") as mock_session_class, patch("controllers.console.datasets.data_source.db.session.add"), patch("controllers.console.datasets.data_source.db.session.commit"), ): mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_session_class.return_value.begin.return_value.__enter__.return_value = mock_session mock_session.execute.return_value.scalar_one_or_none.return_value = binding response, status = method(api, "b1", "disable") @@ -142,10 +142,10 @@ class TestDataSourceApi: with ( app.test_request_context("/"), - patch("controllers.console.datasets.data_source.Session") as mock_session_class, + patch("controllers.console.datasets.data_source.sessionmaker") as mock_session_class, ): mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_session_class.return_value.begin.return_value.__enter__.return_value = mock_session mock_session.execute.return_value.scalar_one_or_none.return_value = None with pytest.raises(NotFound): @@ -159,10 +159,10 @@ class TestDataSourceApi: with ( app.test_request_context("/"), - patch("controllers.console.datasets.data_source.Session") as mock_session_class, + patch("controllers.console.datasets.data_source.sessionmaker") as mock_session_class, ): mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_session_class.return_value.begin.return_value.__enter__.return_value = mock_session mock_session.execute.return_value.scalar_one_or_none.return_value = binding with pytest.raises(ValueError): @@ -176,10 +176,10 @@ class TestDataSourceApi: with ( app.test_request_context("/"), - patch("controllers.console.datasets.data_source.Session") as mock_session_class, + patch("controllers.console.datasets.data_source.sessionmaker") as mock_session_class, ): mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_session_class.return_value.begin.return_value.__enter__.return_value = mock_session mock_session.execute.return_value.scalar_one_or_none.return_value = binding with pytest.raises(ValueError): @@ -282,7 +282,7 @@ class TestDataSourceNotionListApi: "controllers.console.datasets.data_source.DatasetService.get_dataset", return_value=dataset, ), - patch("controllers.console.datasets.data_source.Session") as mock_session_class, + patch("controllers.console.datasets.data_source.sessionmaker") as mock_session_class, patch( "core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime", return_value=MagicMock( @@ -292,7 +292,7 @@ class TestDataSourceNotionListApi: ), ): mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_session_class.return_value.begin.return_value.__enter__.return_value = mock_session mock_session.scalars.return_value.all.return_value = [document] response, status = method(api) @@ -315,7 +315,7 @@ class TestDataSourceNotionListApi: "controllers.console.datasets.data_source.DatasetService.get_dataset", return_value=dataset, ), - patch("controllers.console.datasets.data_source.Session"), + patch("controllers.console.datasets.data_source.sessionmaker"), ): with pytest.raises(ValueError): method(api) From cf50d7c7b52449bd3c8ee40b8b0f80b3663b485e Mon Sep 17 00:00:00 2001 From: Desel72 Date: Tue, 31 Mar 2026 16:10:16 +0300 Subject: [PATCH 10/30] refactor: use sessionmaker().begin() in console app controllers (#34282) Co-authored-by: Asuka Minato --- api/controllers/console/app/app.py | 5 ++- api/controllers/console/app/app_import.py | 10 +++--- .../console/app/conversation_variables.py | 4 +-- api/controllers/console/app/workflow.py | 17 +++------- .../console/app/workflow_app_log.py | 6 ++-- .../console/app/workflow_draft_variable.py | 8 ++--- .../console/app/workflow_trigger.py | 11 +++---- .../controllers/console/app/test_app_apis.py | 33 +++++++++++++++---- 8 files changed, 51 insertions(+), 43 deletions(-) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 738e77b371..ec56cd3baa 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -9,7 +9,7 @@ from graphon.enums import WorkflowExecutionStatus from graphon.file import helpers as file_helpers from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_validator from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest from controllers.common.helpers import FileInfo @@ -642,7 +642,7 @@ class AppCopyApi(Resource): args = CopyAppPayload.model_validate(console_ns.payload or {}) - with Session(db.engine) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: import_service = AppDslService(session) yaml_content = import_service.export_dsl(app_model=app_model, include_secret=True) result = import_service.import_app( @@ -655,7 +655,6 @@ class AppCopyApi(Resource): icon=args.icon, icon_background=args.icon_background, ) - session.commit() # Inherit web app permission from original app if result.app_id and FeatureService.get_system_features().webapp_auth.enabled: diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index fdef54ba5a..16e1fa3245 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -1,6 +1,6 @@ from flask_restx import Resource, fields, marshal_with from pydantic import BaseModel, Field -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( @@ -71,7 +71,7 @@ class AppImportApi(Resource): args = AppImportPayload.model_validate(console_ns.payload) # Create service with session - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: import_service = AppDslService(session) # Import app account = current_user @@ -87,7 +87,6 @@ class AppImportApi(Resource): icon_background=args.icon_background, app_id=args.app_id, ) - session.commit() if result.app_id and FeatureService.get_system_features().webapp_auth.enabled: # update web app setting as private EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private") @@ -112,12 +111,11 @@ class AppImportConfirmApi(Resource): current_user, _ = current_account_with_tenant() # Create service with session - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: import_service = AppDslService(session) # Confirm import account = current_user result = import_service.confirm_import(import_id=import_id, account=account) - session.commit() # Return appropriate status code based on result if result.status == ImportStatus.FAILED: @@ -134,7 +132,7 @@ class AppImportCheckDependenciesApi(Resource): @marshal_with(app_import_check_dependencies_model) @edit_permission_required def get(self, app_model: App): - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: import_service = AppDslService(session) result = import_service.check_dependencies(app_model=app_model) diff --git a/api/controllers/console/app/conversation_variables.py b/api/controllers/console/app/conversation_variables.py index 368a6112ba..369c26a80c 100644 --- a/api/controllers/console/app/conversation_variables.py +++ b/api/controllers/console/app/conversation_variables.py @@ -2,7 +2,7 @@ from flask import request from flask_restx import Resource, fields, marshal_with from pydantic import BaseModel, Field from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from controllers.console import console_ns from controllers.console.app.wraps import get_app_model @@ -69,7 +69,7 @@ class ConversationVariablesApi(Resource): page_size = 100 stmt = stmt.limit(page_size).offset((page - 1) * page_size) - with Session(db.engine) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: rows = session.scalars(stmt).all() return { diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 1f5a84c0b2..6df8f7032e 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -10,7 +10,7 @@ from graphon.file import File from graphon.graph_engine.manager import GraphEngineManager from graphon.model_runtime.utils.encoders import jsonable_encoder from pydantic import BaseModel, Field, field_validator -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services @@ -840,7 +840,7 @@ class PublishedWorkflowApi(Resource): args = PublishWorkflowPayload.model_validate(console_ns.payload or {}) workflow_service = WorkflowService() - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: workflow = workflow_service.publish_workflow( session=session, app_model=app_model, @@ -858,8 +858,6 @@ class PublishedWorkflowApi(Resource): workflow_created_at = TimestampField().format(workflow.created_at) - session.commit() - return { "result": "success", "created_at": workflow_created_at, @@ -982,7 +980,7 @@ class PublishedAllWorkflowApi(Resource): raise Forbidden() workflow_service = WorkflowService() - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: workflows, has_more = workflow_service.get_all_published_workflow( session=session, app_model=app_model, @@ -1072,7 +1070,7 @@ class WorkflowByIdApi(Resource): workflow_service = WorkflowService() # Create a session and manage the transaction - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: workflow = workflow_service.update_workflow( session=session, workflow_id=workflow_id, @@ -1084,9 +1082,6 @@ class WorkflowByIdApi(Resource): if not workflow: raise NotFound("Workflow not found") - # Commit the transaction in the controller - session.commit() - return workflow @setup_required @@ -1101,13 +1096,11 @@ class WorkflowByIdApi(Resource): workflow_service = WorkflowService() # Create a session and manage the transaction - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: try: workflow_service.delete_workflow( session=session, workflow_id=workflow_id, tenant_id=app_model.tenant_id ) - # Commit the transaction in the controller - session.commit() except WorkflowInUseError as e: abort(400, description=str(e)) except DraftWorkflowDeletionError as e: diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py index f0e26c86a5..3b24c2a402 100644 --- a/api/controllers/console/app/workflow_app_log.py +++ b/api/controllers/console/app/workflow_app_log.py @@ -5,7 +5,7 @@ from flask import request from flask_restx import Resource, marshal_with from graphon.enums import WorkflowExecutionStatus from pydantic import BaseModel, Field, field_validator -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from controllers.console import console_ns from controllers.console.app.wraps import get_app_model @@ -87,7 +87,7 @@ class WorkflowAppLogApi(Resource): # get paginate workflow app logs workflow_app_service = WorkflowAppService() - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs( session=session, app_model=app_model, @@ -124,7 +124,7 @@ class WorkflowArchivedLogApi(Resource): args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore workflow_app_service = WorkflowAppService() - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_archive_logs( session=session, app_model=app_model, diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index 4052897e9a..35e2df847c 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -10,7 +10,7 @@ from graphon.variables.segment_group import SegmentGroup from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment from graphon.variables.types import SegmentType from pydantic import BaseModel, Field -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from controllers.console import console_ns from controllers.console.app.error import ( @@ -244,7 +244,7 @@ class WorkflowVariableCollectionApi(Resource): raise DraftWorkflowNotExist() # fetch draft workflow by app_model - with Session(bind=db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: draft_var_srv = WorkflowDraftVariableService( session=session, ) @@ -298,7 +298,7 @@ class NodeVariableCollectionApi(Resource): @marshal_with(workflow_draft_variable_list_model) 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: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: draft_var_srv = WorkflowDraftVariableService( session=session, ) @@ -465,7 +465,7 @@ class VariableResetApi(Resource): def _get_variable_list(app_model: App, node_id) -> WorkflowDraftVariableList: - with Session(bind=db.engine, expire_on_commit=False) as session: + with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: draft_var_srv = WorkflowDraftVariableService( session=session, ) diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py index 8236e766ae..aa37d24738 100644 --- a/api/controllers/console/app/workflow_trigger.py +++ b/api/controllers/console/app/workflow_trigger.py @@ -4,7 +4,7 @@ from flask import request from flask_restx import Resource, fields, marshal_with from pydantic import BaseModel from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound from configs import dify_config @@ -64,7 +64,7 @@ class WebhookTriggerApi(Resource): node_id = args.node_id - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: # Get webhook trigger for this app and node webhook_trigger = ( session.query(WorkflowWebhookTrigger) @@ -95,7 +95,7 @@ class AppTriggersApi(Resource): assert isinstance(current_user, Account) assert current_user.current_tenant_id is not None - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: # Get all triggers for this app using select API triggers = ( session.execute( @@ -137,7 +137,7 @@ class AppTriggerEnableApi(Resource): assert current_user.current_tenant_id is not None trigger_id = args.trigger_id - with Session(db.engine) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: # Find the trigger using select trigger = session.execute( select(AppTrigger).where( @@ -153,9 +153,6 @@ class AppTriggerEnableApi(Resource): # Update status based on enable_trigger boolean trigger.status = AppTriggerStatus.ENABLED if args.enable_trigger else AppTriggerStatus.DISABLED - session.commit() - session.refresh(trigger) - # Add computed icon field url_prefix = dify_config.CONSOLE_API_URL + "/console/api/workspaces/current/tool-provider/builtin/" if trigger.trigger_type == "trigger-plugin": diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py index fbaec069bb..0841217fcf 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py @@ -383,14 +383,21 @@ class TestWorkflowAppLogEndpoints: monkeypatch.setattr(workflow_app_log_module, "db", SimpleNamespace(engine=MagicMock())) - class DummySession: + class DummySessionCtx: def __enter__(self): return "session" def __exit__(self, exc_type, exc, tb): return False - monkeypatch.setattr(workflow_app_log_module, "Session", lambda *args, **kwargs: DummySession()) + class DummySessionMaker: + def __init__(self, *args, **kwargs): + pass + + def begin(self): + return DummySessionCtx() + + monkeypatch.setattr(workflow_app_log_module, "sessionmaker", DummySessionMaker) def fake_get_paginate(self, **_kwargs): return {"items": [], "total": 0} @@ -423,13 +430,20 @@ class TestWorkflowDraftVariableEndpoints: monkeypatch.setattr(workflow_draft_variable_module, "db", SimpleNamespace(engine=MagicMock())) monkeypatch.setattr(workflow_draft_variable_module, "current_user", SimpleNamespace(id="user-1")) - class DummySession: + class DummySessionCtx: def __enter__(self): return "session" def __exit__(self, exc_type, exc, tb): return False + class DummySessionMaker: + def __init__(self, *args, **kwargs): + pass + + def begin(self): + return DummySessionCtx() + class DummyDraftService: def __init__(self, session): self.session = session @@ -437,7 +451,7 @@ class TestWorkflowDraftVariableEndpoints: def list_variables_without_values(self, **_kwargs): return {"items": [], "total": 0} - monkeypatch.setattr(workflow_draft_variable_module, "Session", lambda *args, **kwargs: DummySession()) + monkeypatch.setattr(workflow_draft_variable_module, "sessionmaker", DummySessionMaker) class DummyWorkflowService: def is_workflow_exist(self, *args, **kwargs): @@ -543,14 +557,21 @@ class TestWorkflowTriggerEndpoints: session = MagicMock() session.query.return_value.where.return_value.first.return_value = trigger - class DummySession: + class DummySessionCtx: def __enter__(self): return session def __exit__(self, exc_type, exc, tb): return False - monkeypatch.setattr(workflow_trigger_module, "Session", lambda *_args, **_kwargs: DummySession()) + class DummySessionMaker: + def __init__(self, *args, **kwargs): + pass + + def begin(self): + return DummySessionCtx() + + monkeypatch.setattr(workflow_trigger_module, "sessionmaker", DummySessionMaker) with app.test_request_context("/?node_id=node-1"): result = method(app_model=SimpleNamespace(id="app-1")) From 2c8b47ce443d81222c6abf12c308e87d57a0c9ff Mon Sep 17 00:00:00 2001 From: Desel72 Date: Tue, 31 Mar 2026 17:26:37 +0300 Subject: [PATCH 11/30] refactor: use sessionmaker().begin() in web and mcp controllers (#34281) --- api/controllers/mcp/mcp.py | 10 ++++------ api/controllers/web/conversation.py | 4 ++-- api/controllers/web/forgot_password.py | 11 +++++------ api/controllers/web/wraps.py | 4 ++-- .../controllers/web/test_web_forgot_password.py | 13 ++++++------- 5 files changed, 19 insertions(+), 23 deletions(-) diff --git a/api/controllers/mcp/mcp.py b/api/controllers/mcp/mcp.py index 58ec76243b..3c59535a48 100644 --- a/api/controllers/mcp/mcp.py +++ b/api/controllers/mcp/mcp.py @@ -4,7 +4,7 @@ from flask import Response from flask_restx import Resource from graphon.variables.input_entities import VariableEntity from pydantic import BaseModel, Field, ValidationError -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from controllers.common.schema import register_schema_model from controllers.mcp import mcp_ns @@ -67,7 +67,7 @@ class MCPAppApi(Resource): request_id: Union[int, str] | None = args.id mcp_request = self._parse_mcp_request(args.model_dump(exclude_none=True)) - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: # Get MCP server and app mcp_server, app = self._get_mcp_server_and_app(server_code, session) self._validate_server_status(mcp_server) @@ -189,7 +189,7 @@ class MCPAppApi(Resource): def _retrieve_end_user(self, tenant_id: str, mcp_server_id: str) -> EndUser | None: """Get end user - manages its own database session""" - with Session(db.engine, expire_on_commit=False) as session, session.begin(): + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: return ( session.query(EndUser) .where(EndUser.tenant_id == tenant_id) @@ -229,9 +229,7 @@ class MCPAppApi(Resource): if not end_user and isinstance(mcp_request.root, mcp_types.InitializeRequest): client_info = mcp_request.root.params.clientInfo client_name = f"{client_info.name}@{client_info.version}" - # Commit the session before creating end user to avoid transaction conflicts - session.commit() - with Session(db.engine, expire_on_commit=False) as create_session, create_session.begin(): + with sessionmaker(db.engine, expire_on_commit=False).begin() as create_session: end_user = self._create_end_user(client_name, app.tenant_id, app.id, mcp_server.id, create_session) return handle_mcp_request(app, mcp_request, user_input_form, mcp_server, end_user, request_id) diff --git a/api/controllers/web/conversation.py b/api/controllers/web/conversation.py index e76649495a..d5baa5fb7d 100644 --- a/api/controllers/web/conversation.py +++ b/api/controllers/web/conversation.py @@ -2,7 +2,7 @@ from typing import Literal from flask import request from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound from controllers.common.schema import register_schema_models @@ -99,7 +99,7 @@ class ConversationListApi(WebApiResource): query = ConversationListQuery.model_validate(raw_args) try: - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: pagination = WebConversationService.pagination_by_last_id( session=session, app_model=app_model, diff --git a/api/controllers/web/forgot_password.py b/api/controllers/web/forgot_password.py index 91d206f727..d69571cc9c 100644 --- a/api/controllers/web/forgot_password.py +++ b/api/controllers/web/forgot_password.py @@ -4,7 +4,7 @@ import secrets from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, field_validator -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from controllers.common.schema import register_schema_models from controllers.console.auth.error import ( @@ -81,7 +81,7 @@ class ForgotPasswordSendEmailApi(Resource): else: language = "en-US" - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: account = AccountService.get_account_by_email_with_case_fallback(request_email, session=session) token = None if account is None: @@ -180,18 +180,17 @@ class ForgotPasswordResetApi(Resource): email = reset_data.get("email", "") - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: account = AccountService.get_account_by_email_with_case_fallback(email, session=session) if account: - self._update_existing_account(account, password_hashed, salt, session) + self._update_existing_account(account, password_hashed, salt) else: raise AuthenticationFailedError() return {"result": "success"} - def _update_existing_account(self, account: Account, password_hashed, salt, session): + def _update_existing_account(self, account: Account, password_hashed, salt): # Update existing account credentials account.password = base64.b64encode(password_hashed).decode() account.password_salt = base64.b64encode(salt).decode() - session.commit() diff --git a/api/controllers/web/wraps.py b/api/controllers/web/wraps.py index 152137f39c..654951a1aa 100644 --- a/api/controllers/web/wraps.py +++ b/api/controllers/web/wraps.py @@ -6,7 +6,7 @@ from typing import Concatenate, ParamSpec, TypeVar from flask import request from flask_restx import Resource from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, NotFound, Unauthorized from constants import HEADER_NAME_APP_CODE @@ -49,7 +49,7 @@ def decode_jwt_token(app_code: str | None = None, user_id: str | None = None): decoded = PassportService().verify(tk) app_code = decoded.get("app_code") app_id = decoded.get("app_id") - with Session(db.engine, expire_on_commit=False) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: app_model = session.scalar(select(App).where(App.id == app_id)) site = session.scalar(select(Site).where(Site.code == app_code)) if not app_model: diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py b/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py index 19057726c3..04ad143103 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py @@ -37,7 +37,7 @@ class TestForgotPasswordSendEmailApi: @patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback") @patch("controllers.web.forgot_password.AccountService.is_email_send_ip_limit", return_value=False) @patch("controllers.web.forgot_password.extract_remote_ip", return_value="127.0.0.1") - @patch("controllers.web.forgot_password.Session") + @patch("controllers.web.forgot_password.sessionmaker") def test_should_normalize_email_before_sending( self, mock_session_cls, @@ -51,7 +51,7 @@ class TestForgotPasswordSendEmailApi: mock_get_account.return_value = mock_account mock_send_mail.return_value = "token-123" mock_session = MagicMock() - mock_session_cls.return_value.__enter__.return_value = mock_session + mock_session_cls.return_value.begin.return_value.__enter__.return_value = mock_session with patch("controllers.web.forgot_password.db", SimpleNamespace(engine="engine")): with app.test_request_context( @@ -153,7 +153,7 @@ class TestForgotPasswordResetApi: @patch("controllers.web.forgot_password.ForgotPasswordResetApi._update_existing_account") @patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback") - @patch("controllers.web.forgot_password.Session") + @patch("controllers.web.forgot_password.sessionmaker") @patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token") @patch("controllers.web.forgot_password.AccountService.get_reset_password_data") def test_should_fetch_account_with_fallback( @@ -169,7 +169,7 @@ class TestForgotPasswordResetApi: mock_account = MagicMock() mock_get_account.return_value = mock_account mock_session = MagicMock() - mock_session_cls.return_value.__enter__.return_value = mock_session + mock_session_cls.return_value.begin.return_value.__enter__.return_value = mock_session with patch("controllers.web.forgot_password.db", SimpleNamespace(engine="engine")): with app.test_request_context( @@ -190,7 +190,7 @@ class TestForgotPasswordResetApi: @patch("controllers.web.forgot_password.hash_password", return_value=b"hashed-value") @patch("controllers.web.forgot_password.secrets.token_bytes", return_value=b"0123456789abcdef") - @patch("controllers.web.forgot_password.Session") + @patch("controllers.web.forgot_password.sessionmaker") @patch("controllers.web.forgot_password.AccountService.revoke_reset_password_token") @patch("controllers.web.forgot_password.AccountService.get_reset_password_data") @patch("controllers.web.forgot_password.AccountService.get_account_by_email_with_case_fallback") @@ -208,7 +208,7 @@ class TestForgotPasswordResetApi: account = MagicMock() mock_get_account.return_value = account mock_session = MagicMock() - mock_session_cls.return_value.__enter__.return_value = mock_session + mock_session_cls.return_value.begin.return_value.__enter__.return_value = mock_session with patch("controllers.web.forgot_password.db", SimpleNamespace(engine="engine")): with app.test_request_context( @@ -231,4 +231,3 @@ class TestForgotPasswordResetApi: assert account.password == expected_password expected_salt = base64.b64encode(b"0123456789abcdef").decode() assert account.password_salt == expected_salt - mock_session.commit.assert_called_once() From dbdbb098d5cc04e7e89880f1e2ad43bad3e7cd5f Mon Sep 17 00:00:00 2001 From: Desel72 Date: Tue, 31 Mar 2026 17:28:05 +0300 Subject: [PATCH 12/30] =?UTF-8?q?refactor:=20use=20sessionmaker().begin()?= =?UTF-8?q?=20in=20console=20workspace=20and=20misc=20co=E2=80=A6=20(#3428?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/controllers/console/apikey.py | 4 +-- .../console/explore/conversation.py | 4 +-- api/controllers/console/workspace/__init__.py | 4 +-- api/controllers/console/workspace/account.py | 4 +-- .../console/workspace/tool_providers.py | 26 +++++++++---------- .../console/workspace/trigger_providers.py | 5 ++-- .../console/workspace/test_tool_provider.py | 4 +-- .../workspace/test_trigger_providers.py | 8 +++--- 8 files changed, 29 insertions(+), 30 deletions(-) diff --git a/api/controllers/console/apikey.py b/api/controllers/console/apikey.py index 783cb5c444..772bb9d0f1 100644 --- a/api/controllers/console/apikey.py +++ b/api/controllers/console/apikey.py @@ -2,7 +2,7 @@ import flask_restx from flask_restx import Resource, fields, marshal_with from flask_restx._http import HTTPStatus from sqlalchemy import delete, func, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden from extensions.ext_database import db @@ -34,7 +34,7 @@ api_key_list_model = console_ns.model( def _get_resource(resource_id, tenant_id, resource_model): - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: resource = session.execute( select(resource_model).filter_by(id=resource_id, tenant_id=tenant_id) ).scalar_one_or_none() diff --git a/api/controllers/console/explore/conversation.py b/api/controllers/console/explore/conversation.py index 933c80f509..092f509f1c 100644 --- a/api/controllers/console/explore/conversation.py +++ b/api/controllers/console/explore/conversation.py @@ -2,7 +2,7 @@ from typing import Any from flask import request from pydantic import BaseModel, Field, TypeAdapter, model_validator -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import NotFound from controllers.common.schema import register_schema_models @@ -74,7 +74,7 @@ class ConversationListApi(InstalledAppResource): try: if not isinstance(current_user, Account): raise ValueError("current_user must be an Account instance") - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: pagination = WebConversationService.pagination_by_last_id( session=session, app_model=app_model, diff --git a/api/controllers/console/workspace/__init__.py b/api/controllers/console/workspace/__init__.py index 876e2301f2..9484cc773e 100644 --- a/api/controllers/console/workspace/__init__.py +++ b/api/controllers/console/workspace/__init__.py @@ -2,7 +2,7 @@ from collections.abc import Callable from functools import wraps from typing import ParamSpec, TypeVar -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden from extensions.ext_database import db @@ -24,7 +24,7 @@ def plugin_permission_required( user = current_user tenant_id = current_tenant_id - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: permission = ( session.query(TenantPluginPermission) .where( diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 6f93ff1e70..dcd4438b67 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -8,7 +8,7 @@ from flask import request from flask_restx import Resource, fields, marshal_with from pydantic import BaseModel, Field, field_validator, model_validator from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from configs import dify_config from constants.languages import supported_language @@ -562,7 +562,7 @@ class ChangeEmailSendEmailApi(Resource): user_email = current_user.email else: - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: account = AccountService.get_account_by_email_with_case_fallback(args.email, session=session) if account is None: raise AccountNotFound() diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 80216915cd..c9956501e2 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -7,7 +7,7 @@ from flask import make_response, redirect, request, send_file from flask_restx import Resource from graphon.model_runtime.utils.encoders import jsonable_encoder from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden from configs import dify_config @@ -1019,7 +1019,7 @@ class ToolProviderMCPApi(Resource): # Step 1: Get provider data for URL validation (short-lived session, no network I/O) validation_data = None - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) validation_data = service.get_provider_for_url_validation( tenant_id=current_tenant_id, provider_id=payload.provider_id @@ -1034,7 +1034,7 @@ class ToolProviderMCPApi(Resource): ) # Step 3: Perform database update in a transaction - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) service.update_provider( tenant_id=current_tenant_id, @@ -1061,7 +1061,7 @@ class ToolProviderMCPApi(Resource): payload = MCPProviderDeletePayload.model_validate(console_ns.payload or {}) _, current_tenant_id = current_account_with_tenant() - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) service.delete_provider(tenant_id=current_tenant_id, provider_id=payload.provider_id) @@ -1079,7 +1079,7 @@ class ToolMCPAuthApi(Resource): provider_id = payload.provider_id _, tenant_id = current_account_with_tenant() - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) db_provider = service.get_provider(provider_id=provider_id, tenant_id=tenant_id) if not db_provider: @@ -1100,7 +1100,7 @@ class ToolMCPAuthApi(Resource): sse_read_timeout=provider_entity.sse_read_timeout, ): # Update credentials in new transaction - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) service.update_provider_credentials( provider_id=provider_id, @@ -1118,17 +1118,17 @@ class ToolMCPAuthApi(Resource): resource_metadata_url=e.resource_metadata_url, scope_hint=e.scope_hint, ) - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) response = service.execute_auth_actions(auth_result) return response except MCPRefreshTokenError as e: - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id) raise ValueError(f"Failed to refresh token, please try to authorize again: {e}") from e except (MCPError, ValueError) as e: - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id) raise ValueError(f"Failed to connect to MCP server: {e}") from e @@ -1141,7 +1141,7 @@ class ToolMCPDetailApi(Resource): @account_initialization_required def get(self, provider_id): _, tenant_id = current_account_with_tenant() - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) provider = service.get_provider(provider_id=provider_id, tenant_id=tenant_id) return jsonable_encoder(ToolTransformService.mcp_provider_to_user_provider(provider, for_list=True)) @@ -1155,7 +1155,7 @@ class ToolMCPListAllApi(Resource): def get(self): _, tenant_id = current_account_with_tenant() - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) # Skip sensitive data decryption for list view to improve performance tools = service.list_providers(tenant_id=tenant_id, include_sensitive=False) @@ -1170,7 +1170,7 @@ class ToolMCPUpdateApi(Resource): @account_initialization_required def get(self, provider_id): _, tenant_id = current_account_with_tenant() - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) tools = service.list_provider_tools( tenant_id=tenant_id, @@ -1188,7 +1188,7 @@ class ToolMCPCallbackApi(Resource): authorization_code = query.code # Create service instance for handle_callback - with Session(db.engine) as session, session.begin(): + with sessionmaker(db.engine).begin() as session: mcp_service = MCPToolManageService(session=session) # handle_callback now returns state data and tokens state_data, tokens = handle_callback(state_key, authorization_code) diff --git a/api/controllers/console/workspace/trigger_providers.py b/api/controllers/console/workspace/trigger_providers.py index 76d64cb97c..7a28a09861 100644 --- a/api/controllers/console/workspace/trigger_providers.py +++ b/api/controllers/console/workspace/trigger_providers.py @@ -5,7 +5,7 @@ from flask import make_response, redirect, request from flask_restx import Resource from graphon.model_runtime.utils.encoders import jsonable_encoder from pydantic import BaseModel, model_validator -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden from configs import dify_config @@ -375,7 +375,7 @@ class TriggerSubscriptionDeleteApi(Resource): assert user.current_tenant_id is not None try: - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: # Delete trigger provider subscription TriggerProviderService.delete_trigger_provider( session=session, @@ -388,7 +388,6 @@ class TriggerSubscriptionDeleteApi(Resource): tenant_id=user.current_tenant_id, subscription_id=subscription_id, ) - session.commit() return {"result": "success"} except ValueError as e: raise BadRequest(str(e)) diff --git a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py index e36bd213d9..f2e7104b18 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py +++ b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py @@ -69,7 +69,7 @@ def client(flask_app_with_containers): return_value=(MagicMock(id="u1"), "t1"), autospec=True, ) -@patch("controllers.console.workspace.tool_providers.Session", autospec=True) +@patch("controllers.console.workspace.tool_providers.sessionmaker", autospec=True) @patch("controllers.console.workspace.tool_providers.MCPToolManageService._reconnect_with_url", autospec=True) @pytest.mark.usefixtures("_mock_cache", "_mock_user_tenant") def test_create_mcp_provider_populates_tools(mock_reconnect, mock_session, mock_current_account_with_tenant, client): @@ -88,7 +88,7 @@ def test_create_mcp_provider_populates_tools(mock_reconnect, mock_session, mock_ create_result.id = "provider-1" svc.create_provider.return_value = create_result svc.get_provider.return_value = MagicMock(id="provider-1", tenant_id="t1") # used by reload path - mock_session.return_value.__enter__.return_value = MagicMock() + mock_session.return_value.begin.return_value.__enter__.return_value = MagicMock() # Patch MCPToolManageService constructed inside controller with patch("controllers.console.workspace.tool_providers.MCPToolManageService", return_value=svc, autospec=True): payload = { diff --git a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py index b4d12bff62..ca8195af53 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py +++ b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py @@ -306,14 +306,14 @@ class TestTriggerSubscriptionCrud: app.test_request_context("/"), patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch("controllers.console.workspace.trigger_providers.db") as mock_db, - patch("controllers.console.workspace.trigger_providers.Session") as mock_session_cls, + patch("controllers.console.workspace.trigger_providers.sessionmaker") as mock_session_cls, patch("controllers.console.workspace.trigger_providers.TriggerProviderService.delete_trigger_provider"), patch( "controllers.console.workspace.trigger_providers.TriggerSubscriptionOperatorService.delete_plugin_trigger_by_subscription" ), ): mock_db.engine = MagicMock() - mock_session_cls.return_value.__enter__.return_value = mock_session + mock_session_cls.return_value.begin.return_value.__enter__.return_value = mock_session result = method(api, "sub1") @@ -327,14 +327,14 @@ class TestTriggerSubscriptionCrud: app.test_request_context("/"), patch("controllers.console.workspace.trigger_providers.current_user", mock_user()), patch("controllers.console.workspace.trigger_providers.db") as mock_db, - patch("controllers.console.workspace.trigger_providers.Session") as session_cls, + patch("controllers.console.workspace.trigger_providers.sessionmaker") as session_cls, patch( "controllers.console.workspace.trigger_providers.TriggerProviderService.delete_trigger_provider", side_effect=ValueError("bad"), ), ): mock_db.engine = MagicMock() - session_cls.return_value.__enter__.return_value = MagicMock() + session_cls.return_value.begin.return_value.__enter__.return_value = MagicMock() with pytest.raises(BadRequest): method(api, "sub1") From 19530e880ae7de50d733679d1b281a598e3115bc Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Wed, 1 Apr 2026 06:52:35 +0800 Subject: [PATCH 13/30] =?UTF-8?q?refactor(api):=20clean=20redundant=20type?= =?UTF-8?q?=20ignore=20in=20request=20query=20parsing=20=F0=9F=A4=96?= =?UTF-8?q?=F0=9F=A4=96=F0=9F=A4=96=20(#34350)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/controllers/console/billing/billing.py | 2 +- api/controllers/console/billing/compliance.py | 2 +- api/controllers/console/workspace/account.py | 2 +- api/controllers/console/workspace/model_providers.py | 4 ++-- api/controllers/console/workspace/workspace.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/controllers/console/billing/billing.py b/api/controllers/console/billing/billing.py index ac039f9c5d..23c01eedb1 100644 --- a/api/controllers/console/billing/billing.py +++ b/api/controllers/console/billing/billing.py @@ -36,7 +36,7 @@ class Subscription(Resource): @only_edition_cloud def get(self): current_user, current_tenant_id = current_account_with_tenant() - args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = SubscriptionQuery.model_validate(request.args.to_dict(flat=True)) BillingService.is_tenant_owner_or_admin(current_user) return BillingService.get_subscription(args.plan, args.interval, current_user.email, current_tenant_id) diff --git a/api/controllers/console/billing/compliance.py b/api/controllers/console/billing/compliance.py index afc5f92b68..b5a08e0791 100644 --- a/api/controllers/console/billing/compliance.py +++ b/api/controllers/console/billing/compliance.py @@ -31,7 +31,7 @@ class ComplianceApi(Resource): @only_edition_cloud def get(self): current_user, current_tenant_id = current_account_with_tenant() - args = ComplianceDownloadQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ComplianceDownloadQuery.model_validate(request.args.to_dict(flat=True)) ip_address = extract_remote_ip(request) device_info = request.headers.get("User-Agent", "Unknown device") diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index dcd4438b67..626d330e9d 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -519,7 +519,7 @@ class EducationAutoCompleteApi(Resource): @cloud_edition_billing_enabled @marshal_with(data_fields) def get(self): - payload = request.args.to_dict(flat=True) # type: ignore + payload = request.args.to_dict(flat=True) args = EducationAutocompleteQuery.model_validate(payload) return BillingService.EducationIdentity.autocomplete(args.keywords, args.page, args.limit) diff --git a/api/controllers/console/workspace/model_providers.py b/api/controllers/console/workspace/model_providers.py index 8e0aefc9e3..cbb9677309 100644 --- a/api/controllers/console/workspace/model_providers.py +++ b/api/controllers/console/workspace/model_providers.py @@ -99,7 +99,7 @@ class ModelProviderListApi(Resource): _, current_tenant_id = current_account_with_tenant() tenant_id = current_tenant_id - payload = request.args.to_dict(flat=True) # type: ignore + payload = request.args.to_dict(flat=True) args = ParserModelList.model_validate(payload) model_provider_service = ModelProviderService() @@ -118,7 +118,7 @@ class ModelProviderCredentialApi(Resource): _, current_tenant_id = current_account_with_tenant() tenant_id = current_tenant_id # if credential_id is not provided, return current used credential - payload = request.args.to_dict(flat=True) # type: ignore + payload = request.args.to_dict(flat=True) args = ParserCredentialId.model_validate(payload) model_provider_service = ModelProviderService() diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index 88fd2c010f..a06b4fd195 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -155,7 +155,7 @@ class WorkspaceListApi(Resource): @setup_required @admin_required def get(self): - payload = request.args.to_dict(flat=True) # type: ignore + payload = request.args.to_dict(flat=True) args = WorkspaceListQuery.model_validate(payload) stmt = select(Tenant).order_by(Tenant.created_at.desc()) From 57f358a96b68f72b490fb55342228af965a21685 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Wed, 1 Apr 2026 09:19:32 +0800 Subject: [PATCH 14/30] perf: use global httpx client instead of per request create new one (#34311) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/plugin/impl/base.py | 10 +++++-- .../vdb/tidb_on_qdrant/tidb_service.py | 25 ++++++++++++---- api/libs/oauth.py | 18 +++++++---- api/libs/oauth_data_source.py | 18 +++++++---- api/services/auth/jina.py | 8 ++++- api/services/auth/jina/jina.py | 8 ++++- api/services/billing_service.py | 8 ++++- api/services/website_service.py | 25 +++++++++++----- .../services/auth/test_auth_integration.py | 2 +- .../core/datasource/test_website_crawl.py | 7 +++-- .../core/plugin/impl/test_base_client_impl.py | 2 +- .../core/plugin/test_endpoint_client.py | 11 +++++++ .../core/plugin/test_plugin_runtime.py | 14 +++++++++ .../unit_tests/libs/test_oauth_clients.py | 18 +++++------ .../services/auth/test_jina_auth.py | 14 ++++----- .../auth/test_jina_auth_standalone_module.py | 8 ++--- .../services/test_billing_service.py | 2 +- .../test_datasource_provider_service.py | 3 ++ .../services/test_website_service.py | 30 ++++++++++++------- 19 files changed, 167 insertions(+), 64 deletions(-) diff --git a/api/core/plugin/impl/base.py b/api/core/plugin/impl/base.py index 2d0ab3fcd7..706ae248f0 100644 --- a/api/core/plugin/impl/base.py +++ b/api/core/plugin/impl/base.py @@ -17,6 +17,7 @@ from pydantic import BaseModel from yarl import URL from configs import dify_config +from core.helper.http_client_pooling import get_pooled_http_client from core.plugin.endpoint.exc import EndpointSetupFailedError from core.plugin.entities.plugin_daemon import PluginDaemonBasicResponse, PluginDaemonError, PluginDaemonInnerError from core.plugin.impl.exc import ( @@ -54,6 +55,11 @@ T = TypeVar("T", bound=(BaseModel | dict[str, Any] | list[Any] | bool | str)) logger = logging.getLogger(__name__) +_httpx_client: httpx.Client = get_pooled_http_client( + "plugin_daemon", + lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100), trust_env=False), +) + class BasePluginClient: def _request( @@ -84,7 +90,7 @@ class BasePluginClient: request_kwargs["content"] = prepared_data try: - response = httpx.request(**request_kwargs) + response = _httpx_client.request(**request_kwargs) except httpx.RequestError: logger.exception("Request to Plugin Daemon Service failed") raise PluginDaemonInnerError(code=-500, message="Request to Plugin Daemon Service failed") @@ -171,7 +177,7 @@ class BasePluginClient: stream_kwargs["content"] = prepared_data try: - with httpx.stream(**stream_kwargs) as response: + with _httpx_client.stream(**stream_kwargs) as response: for raw_line in response.iter_lines(): if not raw_line: continue diff --git a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py b/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py index 06b17b9e62..37114be6e7 100644 --- a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py +++ b/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py @@ -6,11 +6,18 @@ import httpx from httpx import DigestAuth from configs import dify_config +from core.helper.http_client_pooling import get_pooled_http_client from extensions.ext_database import db from extensions.ext_redis import redis_client from models.dataset import TidbAuthBinding from models.enums import TidbAuthBindingStatus +# Reuse a pooled HTTP client for all TiDB Cloud requests to minimize connection churn +_tidb_http_client: httpx.Client = get_pooled_http_client( + "tidb:cloud", + lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100)), +) + class TidbService: @staticmethod @@ -50,7 +57,9 @@ class TidbService: "rootPassword": password, } - response = httpx.post(f"{api_url}/clusters", json=cluster_data, auth=DigestAuth(public_key, private_key)) + response = _tidb_http_client.post( + f"{api_url}/clusters", json=cluster_data, auth=DigestAuth(public_key, private_key) + ) if response.status_code == 200: response_data = response.json() @@ -84,7 +93,9 @@ class TidbService: :return: The response from the API. """ - response = httpx.delete(f"{api_url}/clusters/{cluster_id}", auth=DigestAuth(public_key, private_key)) + response = _tidb_http_client.delete( + f"{api_url}/clusters/{cluster_id}", auth=DigestAuth(public_key, private_key) + ) if response.status_code == 200: return response.json() @@ -103,7 +114,7 @@ class TidbService: :return: The response from the API. """ - response = httpx.get(f"{api_url}/clusters/{cluster_id}", auth=DigestAuth(public_key, private_key)) + response = _tidb_http_client.get(f"{api_url}/clusters/{cluster_id}", auth=DigestAuth(public_key, private_key)) if response.status_code == 200: return response.json() @@ -128,7 +139,7 @@ class TidbService: body = {"password": new_password, "builtinRole": "role_admin", "customRoles": []} - response = httpx.patch( + response = _tidb_http_client.patch( f"{api_url}/clusters/{cluster_id}/sqlUsers/{account}", json=body, auth=DigestAuth(public_key, private_key), @@ -162,7 +173,9 @@ class TidbService: tidb_serverless_list_map = {item.cluster_id: item for item in tidb_serverless_list} cluster_ids = [item.cluster_id for item in tidb_serverless_list] params = {"clusterIds": cluster_ids, "view": "BASIC"} - response = httpx.get(f"{api_url}/clusters:batchGet", params=params, auth=DigestAuth(public_key, private_key)) + response = _tidb_http_client.get( + f"{api_url}/clusters:batchGet", params=params, auth=DigestAuth(public_key, private_key) + ) if response.status_code == 200: response_data = response.json() @@ -223,7 +236,7 @@ class TidbService: clusters.append(cluster_data) request_body = {"requests": clusters} - response = httpx.post( + response = _tidb_http_client.post( f"{api_url}/clusters:batchCreate", json=request_body, auth=DigestAuth(public_key, private_key) ) diff --git a/api/libs/oauth.py b/api/libs/oauth.py index 76e741301c..a2f1114033 100644 --- a/api/libs/oauth.py +++ b/api/libs/oauth.py @@ -7,6 +7,8 @@ from typing import NotRequired import httpx from pydantic import TypeAdapter, ValidationError +from core.helper.http_client_pooling import get_pooled_http_client + if sys.version_info >= (3, 12): from typing import TypedDict else: @@ -20,6 +22,12 @@ JsonObjectList = list[JsonObject] JSON_OBJECT_ADAPTER = TypeAdapter(JsonObject) JSON_OBJECT_LIST_ADAPTER = TypeAdapter(JsonObjectList) +# Reuse a pooled httpx.Client for OAuth flows (public endpoints, no SSRF proxy). +_http_client: httpx.Client = get_pooled_http_client( + "oauth:default", + lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100)), +) + class AccessTokenResponse(TypedDict, total=False): access_token: str @@ -115,7 +123,7 @@ class GitHubOAuth(OAuth): "redirect_uri": self.redirect_uri, } headers = {"Accept": "application/json"} - response = httpx.post(self._TOKEN_URL, data=data, headers=headers) + response = _http_client.post(self._TOKEN_URL, data=data, headers=headers) response_json = ACCESS_TOKEN_RESPONSE_ADAPTER.validate_python(_json_object(response)) access_token = response_json.get("access_token") @@ -127,7 +135,7 @@ class GitHubOAuth(OAuth): def get_raw_user_info(self, token: str) -> JsonObject: headers = {"Authorization": f"token {token}"} - response = httpx.get(self._USER_INFO_URL, headers=headers) + response = _http_client.get(self._USER_INFO_URL, headers=headers) response.raise_for_status() user_info = GITHUB_RAW_USER_INFO_ADAPTER.validate_python(_json_object(response)) @@ -147,7 +155,7 @@ class GitHubOAuth(OAuth): Returns an empty string when no usable email is found. """ try: - email_response = httpx.get(GitHubOAuth._EMAIL_INFO_URL, headers=headers) + email_response = _http_client.get(GitHubOAuth._EMAIL_INFO_URL, headers=headers) email_response.raise_for_status() email_records = GITHUB_EMAIL_RECORDS_ADAPTER.validate_python(_json_list(email_response)) except (httpx.HTTPStatusError, ValidationError): @@ -204,7 +212,7 @@ class GoogleOAuth(OAuth): "redirect_uri": self.redirect_uri, } headers = {"Accept": "application/json"} - response = httpx.post(self._TOKEN_URL, data=data, headers=headers) + response = _http_client.post(self._TOKEN_URL, data=data, headers=headers) response_json = ACCESS_TOKEN_RESPONSE_ADAPTER.validate_python(_json_object(response)) access_token = response_json.get("access_token") @@ -216,7 +224,7 @@ class GoogleOAuth(OAuth): def get_raw_user_info(self, token: str) -> JsonObject: headers = {"Authorization": f"Bearer {token}"} - response = httpx.get(self._USER_INFO_URL, headers=headers) + response = _http_client.get(self._USER_INFO_URL, headers=headers) response.raise_for_status() return _json_object(response) diff --git a/api/libs/oauth_data_source.py b/api/libs/oauth_data_source.py index d5dc35ac97..190558e1f3 100644 --- a/api/libs/oauth_data_source.py +++ b/api/libs/oauth_data_source.py @@ -7,6 +7,7 @@ from flask_login import current_user from pydantic import TypeAdapter from sqlalchemy import select +from core.helper.http_client_pooling import get_pooled_http_client from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from models.source import DataSourceOauthBinding @@ -38,6 +39,13 @@ NOTION_SOURCE_INFO_ADAPTER = TypeAdapter(NotionSourceInfo) NOTION_PAGE_SUMMARY_ADAPTER = TypeAdapter(NotionPageSummary) +# Reuse a small pooled client for OAuth data source flows. +_http_client: httpx.Client = get_pooled_http_client( + "oauth:notion", + lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100)), +) + + class OAuthDataSource: client_id: str client_secret: str @@ -75,7 +83,7 @@ class NotionOAuth(OAuthDataSource): data = {"code": code, "grant_type": "authorization_code", "redirect_uri": self.redirect_uri} headers = {"Accept": "application/json"} auth = (self.client_id, self.client_secret) - response = httpx.post(self._TOKEN_URL, data=data, auth=auth, headers=headers) + response = _http_client.post(self._TOKEN_URL, data=data, auth=auth, headers=headers) response_json = response.json() access_token = response_json.get("access_token") @@ -268,7 +276,7 @@ class NotionOAuth(OAuthDataSource): "Notion-Version": "2022-06-28", } - response = httpx.post(url=self._NOTION_PAGE_SEARCH, json=data, headers=headers) + response = _http_client.post(url=self._NOTION_PAGE_SEARCH, json=data, headers=headers) response_json = response.json() results.extend(response_json.get("results", [])) @@ -283,7 +291,7 @@ class NotionOAuth(OAuthDataSource): "Authorization": f"Bearer {access_token}", "Notion-Version": "2022-06-28", } - response = httpx.get(url=f"{self._NOTION_BLOCK_SEARCH}/{block_id}", headers=headers) + response = _http_client.get(url=f"{self._NOTION_BLOCK_SEARCH}/{block_id}", headers=headers) response_json = response.json() if response.status_code != 200: message = response_json.get("message", "unknown error") @@ -299,7 +307,7 @@ class NotionOAuth(OAuthDataSource): "Authorization": f"Bearer {access_token}", "Notion-Version": "2022-06-28", } - response = httpx.get(url=self._NOTION_BOT_USER, headers=headers) + response = _http_client.get(url=self._NOTION_BOT_USER, headers=headers) response_json = response.json() if "object" in response_json and response_json["object"] == "user": user_type = response_json["type"] @@ -323,7 +331,7 @@ class NotionOAuth(OAuthDataSource): "Authorization": f"Bearer {access_token}", "Notion-Version": "2022-06-28", } - response = httpx.post(url=self._NOTION_PAGE_SEARCH, json=data, headers=headers) + response = _http_client.post(url=self._NOTION_PAGE_SEARCH, json=data, headers=headers) response_json = response.json() results.extend(response_json.get("results", [])) diff --git a/api/services/auth/jina.py b/api/services/auth/jina.py index e5e2319ce1..e63c9a3a4d 100644 --- a/api/services/auth/jina.py +++ b/api/services/auth/jina.py @@ -2,8 +2,14 @@ import json import httpx +from core.helper.http_client_pooling import get_pooled_http_client from services.auth.api_key_auth_base import ApiKeyAuthBase, AuthCredentials +_http_client: httpx.Client = get_pooled_http_client( + "auth:jina_standalone", + lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100)), +) + class JinaAuth(ApiKeyAuthBase): def __init__(self, credentials: AuthCredentials): @@ -31,7 +37,7 @@ class JinaAuth(ApiKeyAuthBase): return {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"} def _post_request(self, url, data, headers): - return httpx.post(url, headers=headers, json=data) + return _http_client.post(url, headers=headers, json=data) def _handle_error(self, response): if response.status_code in {402, 409, 500}: diff --git a/api/services/auth/jina/jina.py b/api/services/auth/jina/jina.py index e5e2319ce1..8ea0b6cd69 100644 --- a/api/services/auth/jina/jina.py +++ b/api/services/auth/jina/jina.py @@ -2,8 +2,14 @@ import json import httpx +from core.helper.http_client_pooling import get_pooled_http_client from services.auth.api_key_auth_base import ApiKeyAuthBase, AuthCredentials +_http_client: httpx.Client = get_pooled_http_client( + "auth:jina", + lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100)), +) + class JinaAuth(ApiKeyAuthBase): def __init__(self, credentials: AuthCredentials): @@ -31,7 +37,7 @@ class JinaAuth(ApiKeyAuthBase): return {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"} def _post_request(self, url, data, headers): - return httpx.post(url, headers=headers, json=data) + return _http_client.post(url, headers=headers, json=data) def _handle_error(self, response): if response.status_code in {402, 409, 500}: diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 70d4ce1ee6..54c595e0cb 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -10,6 +10,7 @@ from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fix from typing_extensions import TypedDict from werkzeug.exceptions import InternalServerError +from core.helper.http_client_pooling import get_pooled_http_client from enums.cloud_plan import CloudPlan from extensions.ext_database import db from extensions.ext_redis import redis_client @@ -18,6 +19,11 @@ from models import Account, TenantAccountJoin, TenantAccountRole logger = logging.getLogger(__name__) +_http_client: httpx.Client = get_pooled_http_client( + "billing:default", + lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100)), +) + class SubscriptionPlan(TypedDict): """Tenant subscriptionplan information.""" @@ -131,7 +137,7 @@ class BillingService: headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key} url = f"{cls.base_url}{endpoint}" - response = httpx.request(method, url, json=json, params=params, headers=headers, follow_redirects=True) + response = _http_client.request(method, url, json=json, params=params, headers=headers, follow_redirects=True) if method == "GET" and response.status_code != httpx.codes.OK: raise ValueError("Unable to retrieve billing information. Please try again later or contact support.") if method == "PUT": diff --git a/api/services/website_service.py b/api/services/website_service.py index b2917ba152..6a521a9cc0 100644 --- a/api/services/website_service.py +++ b/api/services/website_service.py @@ -9,12 +9,23 @@ import httpx from flask_login import current_user from core.helper import encrypter +from core.helper.http_client_pooling import get_pooled_http_client from core.rag.extractor.firecrawl.firecrawl_app import CrawlStatusResponse, FirecrawlApp, FirecrawlDocumentData from core.rag.extractor.watercrawl.provider import WaterCrawlProvider from extensions.ext_redis import redis_client from extensions.ext_storage import storage from services.datasource_provider_service import DatasourceProviderService +# Reuse pooled HTTP clients to avoid creating new connections per request and ease testing. +_jina_http_client: httpx.Client = get_pooled_http_client( + "website:jinareader", + lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100)), +) +_adaptive_http_client: httpx.Client = get_pooled_http_client( + "website:adaptivecrawl", + lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100)), +) + @dataclass class CrawlOptions: @@ -225,7 +236,7 @@ class WebsiteService: @classmethod def _crawl_with_jinareader(cls, request: CrawlRequest, api_key: str) -> dict[str, Any]: if not request.options.crawl_sub_pages: - response = httpx.get( + response = _jina_http_client.get( f"https://r.jina.ai/{request.url}", headers={"Accept": "application/json", "Authorization": f"Bearer {api_key}"}, ) @@ -233,7 +244,7 @@ class WebsiteService: raise ValueError("Failed to crawl:") return {"status": "active", "data": response.json().get("data")} else: - response = httpx.post( + response = _adaptive_http_client.post( "https://adaptivecrawl-kir3wx7b3a-uc.a.run.app", json={ "url": request.url, @@ -296,7 +307,7 @@ class WebsiteService: @classmethod def _get_jinareader_status(cls, job_id: str, api_key: str) -> dict[str, Any]: - response = httpx.post( + response = _adaptive_http_client.post( "https://adaptivecrawlstatus-kir3wx7b3a-uc.a.run.app", headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}, json={"taskId": job_id}, @@ -312,7 +323,7 @@ class WebsiteService: } if crawl_status_data["status"] == "completed": - response = httpx.post( + response = _adaptive_http_client.post( "https://adaptivecrawlstatus-kir3wx7b3a-uc.a.run.app", headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}, json={"taskId": job_id, "urls": list(data.get("processed", {}).keys())}, @@ -374,7 +385,7 @@ class WebsiteService: @classmethod def _get_jinareader_url_data(cls, job_id: str, url: str, api_key: str) -> dict[str, Any] | None: if not job_id: - response = httpx.get( + response = _jina_http_client.get( f"https://r.jina.ai/{url}", headers={"Accept": "application/json", "Authorization": f"Bearer {api_key}"}, ) @@ -383,7 +394,7 @@ class WebsiteService: return dict(response.json().get("data", {})) else: # Get crawl status first - status_response = httpx.post( + status_response = _adaptive_http_client.post( "https://adaptivecrawlstatus-kir3wx7b3a-uc.a.run.app", headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}, json={"taskId": job_id}, @@ -393,7 +404,7 @@ class WebsiteService: raise ValueError("Crawl job is not completed") # Get processed data - data_response = httpx.post( + data_response = _adaptive_http_client.post( "https://adaptivecrawlstatus-kir3wx7b3a-uc.a.run.app", headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}, json={"taskId": job_id, "urls": list(status_data.get("processed", {}).keys())}, diff --git a/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py b/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py index dc4c0fda1d..f48c6da690 100644 --- a/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py +++ b/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py @@ -79,7 +79,7 @@ class TestAuthIntegration: @patch("services.auth.api_key_auth_service.encrypter.encrypt_token") @patch("services.auth.firecrawl.firecrawl.httpx.post") - @patch("services.auth.jina.jina.httpx.post") + @patch("services.auth.jina.jina._http_client.post") def test_multi_tenant_isolation( self, mock_jina_http, diff --git a/api/tests/unit_tests/core/datasource/test_website_crawl.py b/api/tests/unit_tests/core/datasource/test_website_crawl.py index 1d79db2640..53000881dd 100644 --- a/api/tests/unit_tests/core/datasource/test_website_crawl.py +++ b/api/tests/unit_tests/core/datasource/test_website_crawl.py @@ -560,7 +560,10 @@ class TestWebsiteService: mock_response = Mock() mock_response.json.return_value = {"code": 200, "data": {"taskId": "task-789"}} - mock_httpx_post = mocker.patch("services.website_service.httpx.post", return_value=mock_response) + mock_httpx_post = mocker.patch( + "services.website_service._adaptive_http_client.post", + return_value=mock_response, + ) from services.website_service import WebsiteCrawlApiRequest @@ -1340,7 +1343,7 @@ class TestProviderSpecificFeatures: "url": "https://example.com/page", }, } - mocker.patch("services.website_service.httpx.get", return_value=mock_response) + mocker.patch("services.website_service._jina_http_client.get", return_value=mock_response) from services.website_service import WebsiteCrawlApiRequest diff --git a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py index c216906d68..23894bd417 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py +++ b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py @@ -57,7 +57,7 @@ class TestBasePluginClientImpl: def test_stream_request_handles_data_lines_and_dict_payload(self, mocker): client = BasePluginClient() stream_mock = mocker.patch( - "core.plugin.impl.base.httpx.stream", + "httpx.Client.stream", return_value=_StreamContext([b"", b"data: hello", "world"]), ) diff --git a/api/tests/unit_tests/core/plugin/test_endpoint_client.py b/api/tests/unit_tests/core/plugin/test_endpoint_client.py index 48e30e9c2f..ff9deb918a 100644 --- a/api/tests/unit_tests/core/plugin/test_endpoint_client.py +++ b/api/tests/unit_tests/core/plugin/test_endpoint_client.py @@ -10,12 +10,23 @@ Tests follow the Arrange-Act-Assert pattern for clarity. from unittest.mock import MagicMock, patch +import httpx import pytest from core.plugin.impl.endpoint import PluginEndpointClient from core.plugin.impl.exc import PluginDaemonInternalServerError +@pytest.fixture(autouse=True) +def _patch_shared_httpx_client(): + """Patch module-level client methods to delegate to module httpx.request/stream.""" + with ( + patch("core.plugin.impl.base._httpx_client.request", side_effect=lambda **kw: httpx.request(**kw)), + patch("core.plugin.impl.base._httpx_client.stream", side_effect=lambda **kw: httpx.stream(**kw)), + ): + yield + + class TestPluginEndpointClientDelete: """Unit tests for PluginEndpointClient delete_endpoint operation. diff --git a/api/tests/unit_tests/core/plugin/test_plugin_runtime.py b/api/tests/unit_tests/core/plugin/test_plugin_runtime.py index 3063ca0197..a3b1e5f6b0 100644 --- a/api/tests/unit_tests/core/plugin/test_plugin_runtime.py +++ b/api/tests/unit_tests/core/plugin/test_plugin_runtime.py @@ -47,6 +47,20 @@ from core.plugin.impl.plugin import PluginInstaller from core.plugin.impl.tool import PluginToolManager +@pytest.fixture(autouse=True) +def _patch_shared_httpx_client(): + """Make BasePluginClient's module-level httpx client delegate to patched httpx.request/stream. + + After refactor, code uses core.plugin.impl.base._httpx_client directly. + Patch its request/stream to route through module-level httpx so existing mocks still apply. + """ + with ( + patch("core.plugin.impl.base._httpx_client.request", side_effect=lambda **kw: httpx.request(**kw)), + patch("core.plugin.impl.base._httpx_client.stream", side_effect=lambda **kw: httpx.stream(**kw)), + ): + yield + + class TestPluginRuntimeExecution: """Unit tests for plugin execution functionality. diff --git a/api/tests/unit_tests/libs/test_oauth_clients.py b/api/tests/unit_tests/libs/test_oauth_clients.py index ab468c8687..830284e697 100644 --- a/api/tests/unit_tests/libs/test_oauth_clients.py +++ b/api/tests/unit_tests/libs/test_oauth_clients.py @@ -68,7 +68,7 @@ class TestGitHubOAuth(BaseOAuthTest): ({}, None, True), ], ) - @patch("httpx.post", autospec=True) + @patch("libs.oauth._http_client.post", autospec=True) def test_should_retrieve_access_token( self, mock_post, oauth, mock_response, response_data, expected_token, should_raise ): @@ -109,7 +109,7 @@ class TestGitHubOAuth(BaseOAuthTest): ), ], ) - @patch("httpx.get", autospec=True) + @patch("libs.oauth._http_client.get", autospec=True) def test_should_retrieve_user_info_correctly(self, mock_get, oauth, user_data, email_data, expected_email): user_response = MagicMock() user_response.json.return_value = user_data @@ -127,7 +127,7 @@ class TestGitHubOAuth(BaseOAuthTest): # The profile email is absent/null, so /user/emails should be called assert mock_get.call_count == 2 - @patch("httpx.get", autospec=True) + @patch("libs.oauth._http_client.get", autospec=True) def test_should_skip_email_endpoint_when_profile_email_present(self, mock_get, oauth): """When the /user profile already contains an email, do not call /user/emails.""" user_response = MagicMock() @@ -162,7 +162,7 @@ class TestGitHubOAuth(BaseOAuthTest): ), ], ) - @patch("httpx.get", autospec=True) + @patch("libs.oauth._http_client.get", autospec=True) def test_should_use_noreply_email_when_no_usable_email(self, mock_get, oauth, user_data, email_data): user_response = MagicMock() user_response.json.return_value = user_data @@ -177,7 +177,7 @@ class TestGitHubOAuth(BaseOAuthTest): assert user_info.id == str(user_data["id"]) assert user_info.email == "12345@users.noreply.github.com" - @patch("httpx.get", autospec=True) + @patch("libs.oauth._http_client.get", autospec=True) def test_should_use_noreply_email_when_email_endpoint_fails(self, mock_get, oauth): user_response = MagicMock() user_response.json.return_value = {"id": 12345, "login": "testuser", "name": "Test User"} @@ -194,7 +194,7 @@ class TestGitHubOAuth(BaseOAuthTest): assert user_info.id == "12345" assert user_info.email == "12345@users.noreply.github.com" - @patch("httpx.get", autospec=True) + @patch("libs.oauth._http_client.get", autospec=True) def test_should_handle_network_errors(self, mock_get, oauth): mock_get.side_effect = httpx.RequestError("Network error") @@ -240,7 +240,7 @@ class TestGoogleOAuth(BaseOAuthTest): ({}, None, True), ], ) - @patch("httpx.post", autospec=True) + @patch("libs.oauth._http_client.post", autospec=True) def test_should_retrieve_access_token( self, mock_post, oauth, oauth_config, mock_response, response_data, expected_token, should_raise ): @@ -274,7 +274,7 @@ class TestGoogleOAuth(BaseOAuthTest): ({"sub": "123", "email": "test@example.com", "name": "Test User"}, ""), # Always returns empty string ], ) - @patch("httpx.get", autospec=True) + @patch("libs.oauth._http_client.get", autospec=True) def test_should_retrieve_user_info_correctly(self, mock_get, oauth, mock_response, user_data, expected_name): mock_response.json.return_value = user_data mock_get.return_value = mock_response @@ -295,7 +295,7 @@ class TestGoogleOAuth(BaseOAuthTest): httpx.TimeoutException, ], ) - @patch("httpx.get", autospec=True) + @patch("libs.oauth._http_client.get", autospec=True) def test_should_handle_http_errors(self, mock_get, oauth, exception_type): mock_response = MagicMock() mock_response.raise_for_status.side_effect = exception_type("Error") diff --git a/api/tests/unit_tests/services/auth/test_jina_auth.py b/api/tests/unit_tests/services/auth/test_jina_auth.py index 67f252390d..2c34d46f1e 100644 --- a/api/tests/unit_tests/services/auth/test_jina_auth.py +++ b/api/tests/unit_tests/services/auth/test_jina_auth.py @@ -35,7 +35,7 @@ class TestJinaAuth: JinaAuth(credentials) assert str(exc_info.value) == "No API key provided" - @patch("services.auth.jina.jina.httpx.post", autospec=True) + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_validate_valid_credentials_successfully(self, mock_post): """Test successful credential validation""" mock_response = MagicMock() @@ -53,7 +53,7 @@ class TestJinaAuth: json={"url": "https://example.com"}, ) - @patch("services.auth.jina.jina.httpx.post", autospec=True) + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_handle_http_402_error(self, mock_post): """Test handling of 402 Payment Required error""" mock_response = MagicMock() @@ -68,7 +68,7 @@ class TestJinaAuth: auth.validate_credentials() assert str(exc_info.value) == "Failed to authorize. Status code: 402. Error: Payment required" - @patch("services.auth.jina.jina.httpx.post", autospec=True) + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_handle_http_409_error(self, mock_post): """Test handling of 409 Conflict error""" mock_response = MagicMock() @@ -83,7 +83,7 @@ class TestJinaAuth: auth.validate_credentials() assert str(exc_info.value) == "Failed to authorize. Status code: 409. Error: Conflict error" - @patch("services.auth.jina.jina.httpx.post", autospec=True) + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_handle_http_500_error(self, mock_post): """Test handling of 500 Internal Server Error""" mock_response = MagicMock() @@ -98,7 +98,7 @@ class TestJinaAuth: auth.validate_credentials() assert str(exc_info.value) == "Failed to authorize. Status code: 500. Error: Internal server error" - @patch("services.auth.jina.jina.httpx.post", autospec=True) + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_handle_unexpected_error_with_text_response(self, mock_post): """Test handling of unexpected errors with text response""" mock_response = MagicMock() @@ -114,7 +114,7 @@ class TestJinaAuth: auth.validate_credentials() assert str(exc_info.value) == "Failed to authorize. Status code: 403. Error: Forbidden" - @patch("services.auth.jina.jina.httpx.post", autospec=True) + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_handle_unexpected_error_without_text(self, mock_post): """Test handling of unexpected errors without text response""" mock_response = MagicMock() @@ -130,7 +130,7 @@ class TestJinaAuth: auth.validate_credentials() assert str(exc_info.value) == "Unexpected error occurred while trying to authorize. Status code: 404" - @patch("services.auth.jina.jina.httpx.post", autospec=True) + @patch("services.auth.jina.jina._http_client.post", autospec=True) def test_should_handle_network_errors(self, mock_post): """Test handling of network connection errors""" mock_post.side_effect = httpx.ConnectError("Network error") diff --git a/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py b/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py index c2fcd71875..4b5a97bf3f 100644 --- a/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py +++ b/api/tests/unit_tests/services/auth/test_jina_auth_standalone_module.py @@ -60,7 +60,7 @@ def test_prepare_headers_includes_bearer_api_key(jina_module: ModuleType) -> Non def test_post_request_calls_httpx(jina_module: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None: auth = jina_module.JinaAuth(_credentials(api_key="k")) post_mock = MagicMock(name="httpx.post") - monkeypatch.setattr(jina_module.httpx, "post", post_mock) + monkeypatch.setattr(jina_module._http_client, "post", post_mock) auth._post_request("https://r.jina.ai", {"url": "https://example.com"}, {"h": "v"}) post_mock.assert_called_once_with("https://r.jina.ai", headers={"h": "v"}, json={"url": "https://example.com"}) @@ -72,7 +72,7 @@ def test_validate_credentials_success(jina_module: ModuleType, monkeypatch: pyte response = MagicMock() response.status_code = 200 post_mock = MagicMock(return_value=response) - monkeypatch.setattr(jina_module.httpx, "post", post_mock) + monkeypatch.setattr(jina_module._http_client, "post", post_mock) assert auth.validate_credentials() is True post_mock.assert_called_once_with( @@ -90,7 +90,7 @@ def test_validate_credentials_non_200_raises_via_handle_error( response = MagicMock() response.status_code = 402 response.json.return_value = {"error": "Payment required"} - monkeypatch.setattr(jina_module.httpx, "post", MagicMock(return_value=response)) + monkeypatch.setattr(jina_module._http_client, "post", MagicMock(return_value=response)) with pytest.raises(Exception, match="Status code: 402.*Payment required"): auth.validate_credentials() @@ -151,7 +151,7 @@ def test_validate_credentials_propagates_network_errors( jina_module: ModuleType, monkeypatch: pytest.MonkeyPatch ) -> None: auth = jina_module.JinaAuth(_credentials(api_key="k")) - monkeypatch.setattr(jina_module.httpx, "post", MagicMock(side_effect=httpx.ConnectError("boom"))) + monkeypatch.setattr(jina_module._http_client, "post", MagicMock(side_effect=httpx.ConnectError("boom"))) with pytest.raises(httpx.ConnectError, match="boom"): auth.validate_credentials() diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index 316381f0ca..b3d2e60802 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -38,7 +38,7 @@ class TestBillingServiceSendRequest: @pytest.fixture def mock_httpx_request(self): """Mock httpx.request for testing.""" - with patch("services.billing_service.httpx.request") as mock_request: + with patch("services.billing_service._http_client.request") as mock_request: yield mock_request @pytest.fixture diff --git a/api/tests/unit_tests/services/test_datasource_provider_service.py b/api/tests/unit_tests/services/test_datasource_provider_service.py index 3df7d500cf..da414816ff 100644 --- a/api/tests/unit_tests/services/test_datasource_provider_service.py +++ b/api/tests/unit_tests/services/test_datasource_provider_service.py @@ -1,5 +1,6 @@ from unittest.mock import MagicMock, patch +import httpx import pytest from graphon.model_runtime.entities.provider_entities import FormType from sqlalchemy.orm import Session @@ -71,6 +72,8 @@ class TestDatasourceProviderService: @pytest.fixture(autouse=True) def patch_externals(self): with ( + patch("core.plugin.impl.base._httpx_client.request", side_effect=lambda **kw: httpx.request(**kw)), + patch("core.plugin.impl.base._httpx_client.stream", side_effect=lambda **kw: httpx.stream(**kw)), patch("httpx.request") as mock_httpx, patch("services.datasource_provider_service.dify_config") as mock_cfg, patch("services.datasource_provider_service.encrypter") as mock_enc, diff --git a/api/tests/unit_tests/services/test_website_service.py b/api/tests/unit_tests/services/test_website_service.py index e973da7d56..b0ddc7388a 100644 --- a/api/tests/unit_tests/services/test_website_service.py +++ b/api/tests/unit_tests/services/test_website_service.py @@ -343,7 +343,7 @@ def test_crawl_with_watercrawl_passes_options_dict(monkeypatch: pytest.MonkeyPat def test_crawl_with_jinareader_single_page_success(monkeypatch: pytest.MonkeyPatch) -> None: get_mock = MagicMock(return_value=_DummyHttpxResponse({"code": 200, "data": {"title": "t"}})) - monkeypatch.setattr(website_service_module.httpx, "get", get_mock) + monkeypatch.setattr(website_service_module._jina_http_client, "get", get_mock) req = WebsiteCrawlApiRequest( provider="jinareader", url="https://example.com", options={"crawl_sub_pages": False} @@ -356,7 +356,11 @@ def test_crawl_with_jinareader_single_page_success(monkeypatch: pytest.MonkeyPat def test_crawl_with_jinareader_single_page_failure(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(website_service_module.httpx, "get", MagicMock(return_value=_DummyHttpxResponse({"code": 500}))) + monkeypatch.setattr( + website_service_module._jina_http_client, + "get", + MagicMock(return_value=_DummyHttpxResponse({"code": 500})), + ) req = WebsiteCrawlApiRequest( provider="jinareader", url="https://example.com", options={"crawl_sub_pages": False} ).to_crawl_request() @@ -368,7 +372,7 @@ def test_crawl_with_jinareader_single_page_failure(monkeypatch: pytest.MonkeyPat def test_crawl_with_jinareader_multi_page_success(monkeypatch: pytest.MonkeyPatch) -> None: post_mock = MagicMock(return_value=_DummyHttpxResponse({"code": 200, "data": {"taskId": "t1"}})) - monkeypatch.setattr(website_service_module.httpx, "post", post_mock) + monkeypatch.setattr(website_service_module._adaptive_http_client, "post", post_mock) req = WebsiteCrawlApiRequest( provider="jinareader", @@ -384,7 +388,7 @@ def test_crawl_with_jinareader_multi_page_success(monkeypatch: pytest.MonkeyPatc def test_crawl_with_jinareader_multi_page_failure(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( - website_service_module.httpx, "post", MagicMock(return_value=_DummyHttpxResponse({"code": 400})) + website_service_module._adaptive_http_client, "post", MagicMock(return_value=_DummyHttpxResponse({"code": 400})) ) req = WebsiteCrawlApiRequest( provider="jinareader", @@ -482,7 +486,7 @@ def test_get_jinareader_status_active(monkeypatch: pytest.MonkeyPatch) -> None: } ) ) - monkeypatch.setattr(website_service_module.httpx, "post", post_mock) + monkeypatch.setattr(website_service_module._adaptive_http_client, "post", post_mock) result = WebsiteService._get_jinareader_status("job-1", "k") assert result["status"] == "active" @@ -518,7 +522,7 @@ def test_get_jinareader_status_completed_formats_processed_items(monkeypatch: py } } post_mock = MagicMock(side_effect=[_DummyHttpxResponse(status_payload), _DummyHttpxResponse(processed_payload)]) - monkeypatch.setattr(website_service_module.httpx, "post", post_mock) + monkeypatch.setattr(website_service_module._adaptive_http_client, "post", post_mock) result = WebsiteService._get_jinareader_status("job-1", "k") assert result["status"] == "completed" @@ -619,7 +623,7 @@ def test_get_watercrawl_url_data_delegates(monkeypatch: pytest.MonkeyPatch) -> N def test_get_jinareader_url_data_without_job_id_success(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( - website_service_module.httpx, + website_service_module._jina_http_client, "get", MagicMock(return_value=_DummyHttpxResponse({"code": 200, "data": {"url": "u"}})), ) @@ -627,7 +631,11 @@ def test_get_jinareader_url_data_without_job_id_success(monkeypatch: pytest.Monk def test_get_jinareader_url_data_without_job_id_failure(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(website_service_module.httpx, "get", MagicMock(return_value=_DummyHttpxResponse({"code": 500}))) + monkeypatch.setattr( + website_service_module._jina_http_client, + "get", + MagicMock(return_value=_DummyHttpxResponse({"code": 500})), + ) with pytest.raises(ValueError, match="Failed to crawl$"): WebsiteService._get_jinareader_url_data("", "u", "k") @@ -637,7 +645,7 @@ def test_get_jinareader_url_data_with_job_id_completed_returns_matching_item(mon processed_payload = {"data": {"processed": {"u1": {"data": {"url": "u", "title": "t"}}}}} post_mock = MagicMock(side_effect=[_DummyHttpxResponse(status_payload), _DummyHttpxResponse(processed_payload)]) - monkeypatch.setattr(website_service_module.httpx, "post", post_mock) + monkeypatch.setattr(website_service_module._adaptive_http_client, "post", post_mock) assert WebsiteService._get_jinareader_url_data("job-1", "u", "k") == {"url": "u", "title": "t"} assert post_mock.call_count == 2 @@ -645,7 +653,7 @@ def test_get_jinareader_url_data_with_job_id_completed_returns_matching_item(mon def test_get_jinareader_url_data_with_job_id_not_completed_raises(monkeypatch: pytest.MonkeyPatch) -> None: post_mock = MagicMock(return_value=_DummyHttpxResponse({"data": {"status": "active"}})) - monkeypatch.setattr(website_service_module.httpx, "post", post_mock) + monkeypatch.setattr(website_service_module._adaptive_http_client, "post", post_mock) with pytest.raises(ValueError, match=r"Crawl job is no\s*t completed"): WebsiteService._get_jinareader_url_data("job-1", "u", "k") @@ -658,7 +666,7 @@ def test_get_jinareader_url_data_with_job_id_completed_but_not_found_returns_non processed_payload = {"data": {"processed": {"u1": {"data": {"url": "other"}}}}} post_mock = MagicMock(side_effect=[_DummyHttpxResponse(status_payload), _DummyHttpxResponse(processed_payload)]) - monkeypatch.setattr(website_service_module.httpx, "post", post_mock) + monkeypatch.setattr(website_service_module._adaptive_http_client, "post", post_mock) assert WebsiteService._get_jinareader_url_data("job-1", "u", "k") is None From d2baacdd4b7f3a717fa7e19820300d9ae63d4d5f Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Wed, 1 Apr 2026 09:31:42 +0800 Subject: [PATCH 15/30] feat(docker): add healthcheck for api, worker, and worker_beat services (#34345) Signed-off-by: majiayu000 <1835304752@qq.com> --- docker/docker-compose-template.yaml | 18 ++++++++++++++++++ docker/docker-compose.yaml | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index e55cf942c3..57584cb829 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -56,6 +56,12 @@ services: volumes: # Mount the storage directory to the container, for storing user files. - ./volumes/app/storage:/app/api/storage + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5001/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s networks: - ssrf_proxy_network - default @@ -95,6 +101,12 @@ services: volumes: # Mount the storage directory to the container, for storing user files. - ./volumes/app/storage:/app/api/storage + healthcheck: + test: ["CMD-SHELL", "celery -A celery_entrypoint.celery inspect ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s networks: - ssrf_proxy_network - default @@ -126,6 +138,12 @@ services: required: false redis: condition: service_started + healthcheck: + test: ["CMD-SHELL", "celery -A app.celery inspect ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s networks: - ssrf_proxy_network - default diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index ed68107f46..097fadc959 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -765,6 +765,12 @@ services: volumes: # Mount the storage directory to the container, for storing user files. - ./volumes/app/storage:/app/api/storage + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5001/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s networks: - ssrf_proxy_network - default @@ -804,6 +810,12 @@ services: volumes: # Mount the storage directory to the container, for storing user files. - ./volumes/app/storage:/app/api/storage + healthcheck: + test: ["CMD-SHELL", "celery -A celery_entrypoint.celery inspect ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s networks: - ssrf_proxy_network - default @@ -835,6 +847,12 @@ services: required: false redis: condition: service_started + healthcheck: + test: ["CMD-SHELL", "celery -A app.celery inspect ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s networks: - ssrf_proxy_network - default From 324b47507c0781567b8b441477aeb78216241719 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:50:02 +0800 Subject: [PATCH 16/30] refactor: enhance ELK layout handling (#34334) --- .../utils/__tests__/elk-layout.spec.ts | 275 ++++++++++++++++++ .../components/workflow/utils/elk-layout.ts | 135 +++++---- 2 files changed, 357 insertions(+), 53 deletions(-) diff --git a/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts b/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts index 1a3c52ec2d..54eb289abe 100644 --- a/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts +++ b/web/app/components/workflow/utils/__tests__/elk-layout.spec.ts @@ -486,6 +486,242 @@ describe('getLayoutByELK', () => { expect(hiNode.ports).toHaveLength(2) }) + it('should build ports for QuestionClassifier sorted by classes order', async () => { + const nodes = [ + makeWorkflowNode({ + id: 'qc-1', + data: { + type: BlockEnum.QuestionClassifier, + title: '', + desc: '', + classes: [{ id: 'cls-a', name: 'A' }, { id: 'cls-b', name: 'B' }, { id: 'cls-c', name: 'C' }], + }, + }), + makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'y', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'z', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e-c', source: 'qc-1', target: 'z', sourceHandle: 'cls-c' }), + makeWorkflowEdge({ id: 'e-a', source: 'qc-1', target: 'x', sourceHandle: 'cls-a' }), + makeWorkflowEdge({ id: 'e-b', source: 'qc-1', target: 'y', sourceHandle: 'cls-b' }), + ] + + await getLayoutByELK(nodes, edges) + const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')! + const portIds = qcNode.ports!.map((p: { id: string }) => p.id) + expect(portIds).toEqual([ + 'qc-1-out-cls-a', + 'qc-1-out-cls-b', + 'qc-1-out-cls-c', + ]) + }) + + it('should build ports for QuestionClassifier with single class', async () => { + const nodes = [ + makeWorkflowNode({ + id: 'qc-1', + data: { + type: BlockEnum.QuestionClassifier, + title: '', + desc: '', + classes: [{ id: 'cls-only', name: 'Only' }], + }, + }), + makeWorkflowNode({ id: 'x', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e1', source: 'qc-1', target: 'x', sourceHandle: 'cls-only' }), + ] + + await getLayoutByELK(nodes, edges) + const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')! + expect(qcNode.ports).toHaveLength(1) + expect(qcNode.ports![0].layoutOptions!['elk.port.side']).toBe('EAST') + }) + + it('should only create output (EAST) ports, not input (WEST) ports', async () => { + const nodes = [ + makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), + makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'c', data: { type: BlockEnum.End, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e1', source: 'a', target: 'b' }), + makeWorkflowEdge({ id: 'e2', source: 'b', target: 'c' }), + ] + + await getLayoutByELK(nodes, edges) + layoutCallArgs!.children!.forEach((child: ElkChild) => { + if (child.ports) { + child.ports.forEach((port) => { + expect(port.layoutOptions!['elk.port.side']).toBe('EAST') + }) + } + }) + const endNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'c')! + expect(endNode.ports).toBeUndefined() + }) + + it('should order children array by DFS following port order', async () => { + const nodes = [ + makeWorkflowNode({ + id: 'if-1', + data: { + type: BlockEnum.IfElse, + title: '', + desc: '', + cases: [{ case_id: 'case-a', logical_operator: 'and', conditions: [] }], + }, + }), + makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), + makeWorkflowNode({ id: 'branch-a', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'branch-else', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'end', data: { type: BlockEnum.End, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ source: 'start', target: 'if-1' }), + makeWorkflowEdge({ id: 'e-else', source: 'if-1', target: 'branch-else', sourceHandle: 'false' }), + makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'branch-a', sourceHandle: 'case-a' }), + makeWorkflowEdge({ source: 'branch-a', target: 'end' }), + makeWorkflowEdge({ source: 'branch-else', target: 'end' }), + ] + + await getLayoutByELK(nodes, edges) + const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id) + // DFS from start: start → if-1 → branch-a (case-a first) → end → branch-else + const idxA = childIds.indexOf('branch-a') + const idxElse = childIds.indexOf('branch-else') + expect(idxA).toBeLessThan(idxElse) + }) + + it('should order children by DFS across nested branching nodes', async () => { + const nodes = [ + makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'qc-1', + data: { + type: BlockEnum.QuestionClassifier, + title: '', + desc: '', + classes: [{ id: 'c1', name: 'C1' }, { id: 'c2', name: 'C2' }], + }, + }), + makeWorkflowNode({ id: 'upper', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'lower', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ source: 'start', target: 'qc-1' }), + makeWorkflowEdge({ id: 'e-c2', source: 'qc-1', target: 'lower', sourceHandle: 'c2' }), + makeWorkflowEdge({ id: 'e-c1', source: 'qc-1', target: 'upper', sourceHandle: 'c1' }), + ] + + await getLayoutByELK(nodes, edges) + const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id) + // DFS: start → qc-1 → upper (c1 first) → lower (c2 second) + expect(childIds.indexOf('upper')).toBeLessThan(childIds.indexOf('lower')) + }) + + it('should handle QuestionClassifier with no classes property', async () => { + const nodes = [ + makeWorkflowNode({ id: 'qc-1', data: { type: BlockEnum.QuestionClassifier, title: '', desc: '' } }), + makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e1', source: 'qc-1', target: 'b', sourceHandle: 'cls-1' }), + makeWorkflowEdge({ id: 'e2', source: 'qc-1', target: 'c', sourceHandle: 'cls-2' }), + ] + + await getLayoutByELK(nodes, edges) + const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')! + expect(qcNode.ports).toHaveLength(2) + }) + + it('should handle QuestionClassifier edges where handle not found in classes', async () => { + const nodes = [ + makeWorkflowNode({ + id: 'qc-1', + data: { type: BlockEnum.QuestionClassifier, title: '', desc: '', classes: [{ id: 'known', name: 'K' }] }, + }), + makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'c', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e1', source: 'qc-1', target: 'b', sourceHandle: 'unknown-1' }), + makeWorkflowEdge({ id: 'e2', source: 'qc-1', target: 'c', sourceHandle: 'unknown-2' }), + ] + + await getLayoutByELK(nodes, edges) + const qcNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-1')! + expect(qcNode.ports).toHaveLength(2) + }) + + it('should include disconnected nodes in the layout', async () => { + const nodes = [ + makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), + makeWorkflowNode({ id: 'connected', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'isolated', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ source: 'start', target: 'connected' }), + ] + + await getLayoutByELK(nodes, edges) + const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id) + expect(childIds).toContain('isolated') + expect(childIds).toHaveLength(3) + }) + + it('should build edges in DFS order matching port order', async () => { + const nodes = [ + makeWorkflowNode({ id: 'start', data: { type: BlockEnum.Start, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'if-1', + data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'case-a' }] }, + }), + makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ source: 'start', target: 'if-1' }), + makeWorkflowEdge({ id: 'e-else', source: 'if-1', target: 'b', sourceHandle: 'false' }), + makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'a', sourceHandle: 'case-a' }), + ] + + await getLayoutByELK(nodes, edges) + const elkEdges = layoutCallArgs!.edges as Array<{ sources: string[], targets: string[] }> + const ifEdges = elkEdges.filter(e => e.sources[0] === 'if-1') + expect(ifEdges[0].targets[0]).toBe('a') + expect(ifEdges[1].targets[0]).toBe('b') + }) + + it('should keep edges for components where every node has an incoming edge', async () => { + const nodes = [ + makeWorkflowNode({ + id: 'if-1', + data: { type: BlockEnum.IfElse, title: '', desc: '', cases: [{ case_id: 'case-a' }] }, + }), + makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ id: 'e-a', source: 'if-1', target: 'a', sourceHandle: 'case-a' }), + makeWorkflowEdge({ id: 'e-b', source: 'if-1', target: 'b', sourceHandle: 'false' }), + makeWorkflowEdge({ id: 'e-back', source: 'a', target: 'if-1' }), + ] + + await getLayoutByELK(nodes, edges) + + const elkEdges = layoutCallArgs!.edges as Array<{ sources: string[], targets: string[] }> + expect(elkEdges).toHaveLength(3) + expect(elkEdges).toEqual(expect.arrayContaining([ + expect.objectContaining({ sources: ['if-1'], targets: ['a'] }), + expect.objectContaining({ sources: ['if-1'], targets: ['b'] }), + expect.objectContaining({ sources: ['a'], targets: ['if-1'] }), + ])) + }) + it('should filter loop internal edges', async () => { const nodes = [ makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), @@ -650,6 +886,45 @@ describe('getLayoutForChildNodes', () => { expect(result!.nodes.size).toBe(2) }) + it('should build ports and DFS-order for branching nodes inside iteration', async () => { + const nodes = [ + makeWorkflowNode({ id: 'parent', data: { type: BlockEnum.Iteration, title: '', desc: '' } }), + makeWorkflowNode({ + id: 'iter-start', + type: CUSTOM_ITERATION_START_NODE, + parentId: 'parent', + data: { type: BlockEnum.IterationStart, title: '', desc: '' }, + }), + makeWorkflowNode({ + id: 'qc-child', + parentId: 'parent', + data: { + type: BlockEnum.QuestionClassifier, + title: '', + desc: '', + classes: [{ id: 'cls-1', name: 'C1' }, { id: 'cls-2', name: 'C2' }], + }, + }), + makeWorkflowNode({ id: 'upper', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + makeWorkflowNode({ id: 'lower', parentId: 'parent', data: { type: BlockEnum.Code, title: '', desc: '' } }), + ] + const edges = [ + makeWorkflowEdge({ source: 'iter-start', target: 'qc-child', data: { isInIteration: true, iteration_id: 'parent' } }), + makeWorkflowEdge({ id: 'e-c2', source: 'qc-child', target: 'lower', sourceHandle: 'cls-2', data: { isInIteration: true, iteration_id: 'parent' } }), + makeWorkflowEdge({ id: 'e-c1', source: 'qc-child', target: 'upper', sourceHandle: 'cls-1', data: { isInIteration: true, iteration_id: 'parent' } }), + ] + + await getLayoutForChildNodes('parent', nodes, edges) + + const qcElk = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'qc-child')! + expect(qcElk.ports).toHaveLength(2) + expect(qcElk.ports![0].id).toContain('cls-1') + expect(qcElk.ports![1].id).toContain('cls-2') + + const childIds = layoutCallArgs!.children!.map((c: ElkChild) => c.id) + expect(childIds.indexOf('upper')).toBeLessThan(childIds.indexOf('lower')) + }) + it('should return original layout when bounds are not finite', async () => { mockReturnOverride = (graph: ElkGraph) => ({ ...graph, diff --git a/web/app/components/workflow/utils/elk-layout.ts b/web/app/components/workflow/utils/elk-layout.ts index 9860bbc770..781416f3c4 100644 --- a/web/app/components/workflow/utils/elk-layout.ts +++ b/web/app/components/workflow/utils/elk-layout.ts @@ -1,6 +1,7 @@ import type { ElkNode, LayoutOptions } from 'elkjs/lib/elk-api' import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types' import type { CaseItem, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types' +import type { QuestionClassifierNodeType, Topic } from '@/app/components/workflow/nodes/question-classifier/types' import type { Edge, Node, @@ -37,13 +38,13 @@ const ROOT_LAYOUT_OPTIONS = { // === Port Configuration === 'elk.portConstraints': 'FIXED_ORDER', - 'elk.layered.considerModelOrder.strategy': 'PREFER_EDGES', + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true', - // === Node Placement - Best quality === - 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + // === Node Placement - Balanced centering === + 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', 'elk.layered.nodePlacement.favorStraightEdges': 'true', - 'elk.layered.nodePlacement.linearSegments.deflectionDampening': '0.5', - 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE', + 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', // === Edge Routing - Maximum quality === 'elk.edgeRouting': 'SPLINES', @@ -56,7 +57,7 @@ const ROOT_LAYOUT_OPTIONS = { 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED', 'elk.layered.crossingMinimization.greedySwitchHierarchical.type': 'TWO_SIDED', - 'elk.layered.crossingMinimization.semiInteractive': 'true', + 'elk.layered.crossingMinimization.semiInteractive': 'false', 'elk.layered.crossingMinimization.hierarchicalSweepiness': '0.9', // === Layering Strategy - Best quality === @@ -115,11 +116,15 @@ const CHILD_LAYOUT_OPTIONS = { 'elk.spacing.edgeLabel': '8', 'elk.spacing.portPort': '15', - // === Node Placement - Best quality === - 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', + // === Port Configuration === + 'elk.portConstraints': 'FIXED_ORDER', + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', + 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true', + + // === Node Placement - Balanced centering === + 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', 'elk.layered.nodePlacement.favorStraightEdges': 'true', - 'elk.layered.nodePlacement.linearSegments.deflectionDampening': '0.5', - 'elk.layered.nodePlacement.networkSimplex.nodeFlexibility': 'NODE_SIZE', + 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', // === Edge Routing - Maximum quality === 'elk.edgeRouting': 'SPLINES', @@ -129,7 +134,7 @@ const CHILD_LAYOUT_OPTIONS = { // === Crossing Minimization - Aggressive === 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', 'elk.layered.crossingMinimization.greedySwitch.type': 'TWO_SIDED', - 'elk.layered.crossingMinimization.semiInteractive': 'true', + 'elk.layered.crossingMinimization.semiInteractive': 'false', // === Layering Strategy === 'elk.layered.layering.strategy': 'NETWORK_SIMPLEX', @@ -197,12 +202,6 @@ type ElkEdgeShape = { targetPort?: string } -const toElkNode = (node: Node): ElkNodeShape => ({ - id: node.id, - width: node.width ?? DEFAULT_NODE_WIDTH, - height: node.height ?? DEFAULT_NODE_HEIGHT, -}) - let edgeCounter = 0 const nextEdgeId = () => `elk-edge-${edgeCounter++}` @@ -297,6 +296,24 @@ const sortIfElseOutEdges = (ifElseNode: Node, outEdges: Edge[]): Edge[] => { }) } +const sortQuestionClassifierOutEdges = (classifierNode: Node, outEdges: Edge[]): Edge[] => { + return [...outEdges].sort((edgeA, edgeB) => { + const handleA = edgeA.sourceHandle + const handleB = edgeB.sourceHandle + + if (handleA && handleB) { + const classes = (classifierNode.data as QuestionClassifierNodeType).classes || [] + const indexA = classes.findIndex((t: Topic) => t.id === handleA) + const indexB = classes.findIndex((t: Topic) => t.id === handleB) + + if (indexA !== -1 && indexB !== -1) + return indexA - indexB + } + + return 0 + }) +} + const sortHumanInputOutEdges = (humanInputNode: Node, outEdges: Edge[]): Edge[] => { return [...outEdges].sort((edgeA, edgeB) => { const handleA = edgeA.sourceHandle @@ -352,63 +369,45 @@ const normaliseBounds = (layout: LayoutResult): LayoutResult => { } } -export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): Promise => { - edgeCounter = 0 - const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE) - const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop)) - +/** + * Build ELK nodes with output ports (sorted for branching types) + * and edges ordered by a DFS traversal that follows port order. + */ +const buildPortAwareGraph = (nodes: Node[], edges: Edge[]) => { const outEdgesByNode = new Map() - const inEdgesByNode = new Map() edges.forEach((edge) => { if (!outEdgesByNode.has(edge.source)) outEdgesByNode.set(edge.source, []) outEdgesByNode.get(edge.source)!.push(edge) - if (!inEdgesByNode.has(edge.target)) - inEdgesByNode.set(edge.target, []) - inEdgesByNode.get(edge.target)!.push(edge) }) const elkNodes: ElkNodeShape[] = [] const elkEdges: ElkEdgeShape[] = [] const sourcePortMap = new Map() - const targetPortMap = new Map() const sortedOutEdgesByNode = new Map() nodes.forEach((node) => { - const inEdges = inEdgesByNode.get(node.id) || [] let outEdges = outEdgesByNode.get(node.id) || [] if (node.data.type === BlockEnum.IfElse) outEdges = sortIfElseOutEdges(node, outEdges) + else if (node.data.type === BlockEnum.QuestionClassifier) + outEdges = sortQuestionClassifierOutEdges(node, outEdges) else if (node.data.type === BlockEnum.HumanInput) outEdges = sortHumanInputOutEdges(node, outEdges) sortedOutEdgesByNode.set(node.id, outEdges) - const ports: ElkPortShape[] = [] - - inEdges.forEach((edge, index) => { - const portId = `${node.id}-in-${index}` - ports.push({ - id: portId, - layoutOptions: { - 'elk.port.side': 'WEST', - 'elk.port.index': String(index), - }, - }) - targetPortMap.set(edge.id, portId) - }) - - outEdges.forEach((edge, index) => { + const ports: ElkPortShape[] = outEdges.map((edge, index) => { const portId = `${node.id}-out-${edge.sourceHandle || index}` - ports.push({ + sourcePortMap.set(edge.id, portId) + return { id: portId, layoutOptions: { 'elk.port.side': 'EAST', 'elk.port.index': String(index), }, - }) - sourcePortMap.set(edge.id, portId) + } }) elkNodes.push({ @@ -422,19 +421,51 @@ export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): }) }) - // Build edges in sorted per-node order so PREFER_EDGES aligns with port order - nodes.forEach((node) => { - const outEdges = sortedOutEdgesByNode.get(node.id) || [] + // DFS in port order to determine the definitive vertical ordering of nodes. + // forceNodeModelOrder makes ELK respect the children-array order within each layer. + const nodeIdSet = new Set(nodes.map(n => n.id)) + const visited = new Set() + const orderedIds: string[] = [] + + const dfs = (id: string) => { + if (visited.has(id) || !nodeIdSet.has(id)) + return + visited.add(id) + orderedIds.push(id) + const outEdges = sortedOutEdgesByNode.get(id) || [] + outEdges.forEach(e => dfs(e.target)) + } + + nodes.forEach((n) => { + if (!edges.some(e => e.target === n.id)) + dfs(n.id) + }) + nodes.forEach(n => dfs(n.id)) + + const nodeOrder = new Map(orderedIds.map((id, i) => [id, i])) + elkNodes.sort((a, b) => (nodeOrder.get(a.id) ?? 0) - (nodeOrder.get(b.id) ?? 0)) + + orderedIds.forEach((id) => { + const outEdges = sortedOutEdgesByNode.get(id) || [] outEdges.forEach((edge) => { elkEdges.push(createEdge( edge.source, edge.target, sourcePortMap.get(edge.id), - targetPortMap.get(edge.id), )) }) }) + return { elkNodes, elkEdges } +} + +export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): Promise => { + edgeCounter = 0 + const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE) + const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop)) + + const { elkNodes, elkEdges } = buildPortAwareGraph(nodes, edges) + const graph = { id: 'workflow-root', layoutOptions: ROOT_LAYOUT_OPTIONS, @@ -443,7 +474,6 @@ export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): } const layoutedGraph = await elk.layout(graph) - // No need to filter dummy nodes anymore, as we're using ports const layout = collectLayout(layoutedGraph, () => true) return normaliseBounds(layout) } @@ -532,8 +562,7 @@ export const getLayoutForChildNodes = async ( || (edge.data?.isInLoop && edge.data?.loop_id === parentNodeId), ) - const elkNodes: ElkNodeShape[] = nodes.map(toElkNode) - const elkEdges: ElkEdgeShape[] = edges.map(edge => createEdge(edge.source, edge.target)) + const { elkNodes, elkEdges } = buildPortAwareGraph(nodes, edges) const graph = { id: parentNodeId, From 4bd388669aedc342ccc76ae7529785d615b10323 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:20:56 -0500 Subject: [PATCH 17/30] refactor: core/app pipeline, core/datasource, and core/indexing_runner (#34359) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../app/apps/pipeline/pipeline_generator.py | 2 +- api/core/app/apps/pipeline/pipeline_runner.py | 17 ++- .../datasource/datasource_file_manager.py | 8 +- api/core/indexing_runner.py | 105 ++++++++++-------- .../apps/pipeline/test_pipeline_generator.py | 2 +- .../app/apps/pipeline/test_pipeline_runner.py | 25 +---- .../test_datasource_file_manager.py | 50 +++------ .../core/rag/indexing/test_indexing_runner.py | 73 ++++++------ 8 files changed, 131 insertions(+), 151 deletions(-) diff --git a/api/core/app/apps/pipeline/pipeline_generator.py b/api/core/app/apps/pipeline/pipeline_generator.py index fa242003a2..9cc1a197d5 100644 --- a/api/core/app/apps/pipeline/pipeline_generator.py +++ b/api/core/app/apps/pipeline/pipeline_generator.py @@ -302,7 +302,7 @@ class PipelineGenerator(BaseAppGenerator): """ with preserve_flask_contexts(flask_app, context_vars=context): # init queue manager - workflow = db.session.query(Workflow).where(Workflow.id == workflow_id).first() + workflow = db.session.get(Workflow, workflow_id) if not workflow: raise ValueError(f"Workflow not found: {workflow_id}") queue_manager = PipelineQueueManager( diff --git a/api/core/app/apps/pipeline/pipeline_runner.py b/api/core/app/apps/pipeline/pipeline_runner.py index 4c188dac68..b4d2310da8 100644 --- a/api/core/app/apps/pipeline/pipeline_runner.py +++ b/api/core/app/apps/pipeline/pipeline_runner.py @@ -9,6 +9,7 @@ from graphon.graph_events import GraphEngineEvent, GraphRunFailedEvent from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variable_loader import VariableLoader from graphon.variables.variables import RAGPipelineVariable, RAGPipelineVariableInput +from sqlalchemy import select from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.apps.pipeline.pipeline_config_manager import PipelineConfig @@ -84,13 +85,13 @@ class PipelineRunner(WorkflowBasedAppRunner): user_id = None if invoke_from in {InvokeFrom.WEB_APP, InvokeFrom.SERVICE_API}: - end_user = db.session.query(EndUser).where(EndUser.id == self.application_generate_entity.user_id).first() + end_user = db.session.get(EndUser, self.application_generate_entity.user_id) if end_user: user_id = end_user.session_id else: user_id = self.application_generate_entity.user_id - pipeline = db.session.query(Pipeline).where(Pipeline.id == app_config.app_id).first() + pipeline = db.session.get(Pipeline, app_config.app_id) if not pipeline: raise ValueError("Pipeline not found") @@ -213,10 +214,10 @@ class PipelineRunner(WorkflowBasedAppRunner): Get workflow """ # fetch workflow by workflow_id - workflow = ( - db.session.query(Workflow) + workflow = db.session.scalar( + select(Workflow) .where(Workflow.tenant_id == pipeline.tenant_id, Workflow.app_id == pipeline.id, Workflow.id == workflow_id) - .first() + .limit(1) ) # return workflow @@ -297,10 +298,8 @@ class PipelineRunner(WorkflowBasedAppRunner): """ if isinstance(event, GraphRunFailedEvent): if document_id and dataset_id: - document = ( - db.session.query(Document) - .where(Document.id == document_id, Document.dataset_id == dataset_id) - .first() + document = db.session.scalar( + select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1) ) if document: document.indexing_status = "error" diff --git a/api/core/datasource/datasource_file_manager.py b/api/core/datasource/datasource_file_manager.py index fe40d8f0e5..492b507aa9 100644 --- a/api/core/datasource/datasource_file_manager.py +++ b/api/core/datasource/datasource_file_manager.py @@ -153,7 +153,7 @@ class DatasourceFileManager: :return: the binary of the file, mime type """ - upload_file: UploadFile | None = db.session.query(UploadFile).where(UploadFile.id == id).first() + upload_file: UploadFile | None = db.session.get(UploadFile, id) if not upload_file: return None @@ -171,7 +171,7 @@ class DatasourceFileManager: :return: the binary of the file, mime type """ - message_file: MessageFile | None = db.session.query(MessageFile).where(MessageFile.id == id).first() + message_file: MessageFile | None = db.session.get(MessageFile, id) # Check if message_file is not None if message_file is not None: @@ -185,7 +185,7 @@ class DatasourceFileManager: else: tool_file_id = None - tool_file: ToolFile | None = db.session.query(ToolFile).where(ToolFile.id == tool_file_id).first() + tool_file: ToolFile | None = db.session.get(ToolFile, tool_file_id) if not tool_file: return None @@ -203,7 +203,7 @@ class DatasourceFileManager: :return: the binary of the file, mime type """ - upload_file: UploadFile | None = db.session.query(UploadFile).where(UploadFile.id == upload_file_id).first() + upload_file: UploadFile | None = db.session.get(UploadFile, upload_file_id) if not upload_file: return None, None diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index 3ec17bc986..b8d5ca2f50 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -10,7 +10,7 @@ from typing import Any from flask import Flask, current_app from graphon.model_runtime.entities.model_entities import ModelType -from sqlalchemy import select +from sqlalchemy import delete, func, select, update from sqlalchemy.orm.exc import ObjectDeletedError from configs import dify_config @@ -78,7 +78,7 @@ class IndexingRunner: continue # get dataset - dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first() + dataset = db.session.get(Dataset, requeried_document.dataset_id) if not dataset: raise ValueError("no dataset found") @@ -95,7 +95,7 @@ class IndexingRunner: text_docs = self._extract(index_processor, requeried_document, processing_rule.to_dict()) # transform - current_user = db.session.query(Account).filter_by(id=requeried_document.created_by).first() + current_user = db.session.get(Account, requeried_document.created_by) if not current_user: raise ValueError("no current user found") current_user.set_tenant_id(dataset.tenant_id) @@ -137,23 +137,24 @@ class IndexingRunner: return # get dataset - dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first() + dataset = db.session.get(Dataset, requeried_document.dataset_id) if not dataset: raise ValueError("no dataset found") # get exist document_segment list and delete - document_segments = ( - db.session.query(DocumentSegment) - .filter_by(dataset_id=dataset.id, document_id=requeried_document.id) - .all() - ) + document_segments = db.session.scalars( + select(DocumentSegment).where( + DocumentSegment.dataset_id == dataset.id, + DocumentSegment.document_id == requeried_document.id, + ) + ).all() for document_segment in document_segments: db.session.delete(document_segment) if requeried_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX: # delete child chunks - db.session.query(ChildChunk).where(ChildChunk.segment_id == document_segment.id).delete() + db.session.execute(delete(ChildChunk).where(ChildChunk.segment_id == document_segment.id)) db.session.commit() # get the process rule stmt = select(DatasetProcessRule).where(DatasetProcessRule.id == requeried_document.dataset_process_rule_id) @@ -167,7 +168,7 @@ class IndexingRunner: text_docs = self._extract(index_processor, requeried_document, processing_rule.to_dict()) # transform - current_user = db.session.query(Account).filter_by(id=requeried_document.created_by).first() + current_user = db.session.get(Account, requeried_document.created_by) if not current_user: raise ValueError("no current user found") current_user.set_tenant_id(dataset.tenant_id) @@ -207,17 +208,18 @@ class IndexingRunner: return # get dataset - dataset = db.session.query(Dataset).filter_by(id=requeried_document.dataset_id).first() + dataset = db.session.get(Dataset, requeried_document.dataset_id) if not dataset: raise ValueError("no dataset found") # get exist document_segment list and delete - document_segments = ( - db.session.query(DocumentSegment) - .filter_by(dataset_id=dataset.id, document_id=requeried_document.id) - .all() - ) + document_segments = db.session.scalars( + select(DocumentSegment).where( + DocumentSegment.dataset_id == dataset.id, + DocumentSegment.document_id == requeried_document.id, + ) + ).all() documents = [] if document_segments: @@ -289,7 +291,7 @@ class IndexingRunner: embedding_model_instance = None if dataset_id: - dataset = db.session.query(Dataset).filter_by(id=dataset_id).first() + dataset = db.session.get(Dataset, dataset_id) if not dataset: raise ValueError("Dataset not found.") if IndexTechniqueType.HIGH_QUALITY in {dataset.indexing_technique, indexing_technique}: @@ -652,24 +654,26 @@ class IndexingRunner: @staticmethod def _process_keyword_index(flask_app, dataset_id, document_id, documents): with flask_app.app_context(): - dataset = db.session.query(Dataset).filter_by(id=dataset_id).first() + dataset = db.session.get(Dataset, dataset_id) if not dataset: raise ValueError("no dataset found") keyword = Keyword(dataset) keyword.create(documents) if dataset.indexing_technique != IndexTechniqueType.HIGH_QUALITY: document_ids = [document.metadata["doc_id"] for document in documents] - db.session.query(DocumentSegment).where( - DocumentSegment.document_id == document_id, - DocumentSegment.dataset_id == dataset_id, - DocumentSegment.index_node_id.in_(document_ids), - DocumentSegment.status == SegmentStatus.INDEXING, - ).update( - { - DocumentSegment.status: SegmentStatus.COMPLETED, - DocumentSegment.enabled: True, - DocumentSegment.completed_at: naive_utc_now(), - } + db.session.execute( + update(DocumentSegment) + .where( + DocumentSegment.document_id == document_id, + DocumentSegment.dataset_id == dataset_id, + DocumentSegment.index_node_id.in_(document_ids), + DocumentSegment.status == SegmentStatus.INDEXING, + ) + .values( + status=SegmentStatus.COMPLETED, + enabled=True, + completed_at=naive_utc_now(), + ) ) db.session.commit() @@ -703,17 +707,19 @@ class IndexingRunner: ) document_ids = [document.metadata["doc_id"] for document in chunk_documents] - db.session.query(DocumentSegment).where( - DocumentSegment.document_id == dataset_document.id, - DocumentSegment.dataset_id == dataset.id, - DocumentSegment.index_node_id.in_(document_ids), - DocumentSegment.status == SegmentStatus.INDEXING, - ).update( - { - DocumentSegment.status: SegmentStatus.COMPLETED, - DocumentSegment.enabled: True, - DocumentSegment.completed_at: naive_utc_now(), - } + db.session.execute( + update(DocumentSegment) + .where( + DocumentSegment.document_id == dataset_document.id, + DocumentSegment.dataset_id == dataset.id, + DocumentSegment.index_node_id.in_(document_ids), + DocumentSegment.status == SegmentStatus.INDEXING, + ) + .values( + status=SegmentStatus.COMPLETED, + enabled=True, + completed_at=naive_utc_now(), + ) ) db.session.commit() @@ -734,10 +740,17 @@ class IndexingRunner: """ Update the document indexing status. """ - count = db.session.query(DatasetDocument).filter_by(id=document_id, is_paused=True).count() + count = ( + db.session.scalar( + select(func.count()) + .select_from(DatasetDocument) + .where(DatasetDocument.id == document_id, DatasetDocument.is_paused == True) + ) + or 0 + ) if count > 0: raise DocumentIsPausedError() - document = db.session.query(DatasetDocument).filter_by(id=document_id).first() + document = db.session.get(DatasetDocument, document_id) if not document: raise DocumentIsDeletedPausedError() @@ -745,7 +758,7 @@ class IndexingRunner: if extra_update_params: update_params.update(extra_update_params) - db.session.query(DatasetDocument).filter_by(id=document_id).update(update_params) # type: ignore + db.session.execute(update(DatasetDocument).where(DatasetDocument.id == document_id).values(update_params)) # type: ignore db.session.commit() @staticmethod @@ -753,7 +766,9 @@ class IndexingRunner: """ Update the document segment by document id. """ - db.session.query(DocumentSegment).filter_by(document_id=dataset_document_id).update(update_params) + db.session.execute( + update(DocumentSegment).where(DocumentSegment.document_id == dataset_document_id).values(update_params) + ) db.session.commit() def _transform( diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py index 06face41fe..0047f6659d 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py @@ -345,7 +345,7 @@ def test_generate_raises_when_workflow_not_found(generator, mocker): mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve) session = MagicMock() - session.query.return_value.where.return_value.first.return_value = None + session.get.return_value = None mocker.patch.object(module.db, "session", session) with pytest.raises(ValueError): diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py index ab70996f0a..c8ae288e6f 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py @@ -80,9 +80,7 @@ def test_get_workflow_returns_workflow(mocker, runner): pipeline = MagicMock(tenant_id="tenant", id="pipe") workflow = MagicMock(id="wf") - query = MagicMock() - query.where.return_value.first.return_value = workflow - mocker.patch.object(module.db, "session", MagicMock(query=MagicMock(return_value=query))) + mocker.patch.object(module.db, "session", MagicMock(scalar=MagicMock(return_value=workflow))) result = runner.get_workflow(pipeline=pipeline, workflow_id="wf") @@ -115,11 +113,8 @@ def test_init_rag_pipeline_graph_not_found(mocker, runner): def test_update_document_status_on_failure(mocker, runner): document = MagicMock() - query = MagicMock() - query.where.return_value.first.return_value = document - session = MagicMock() - session.query.return_value = query + session.scalar.return_value = document mocker.patch.object(module.db, "session", session) event = GraphRunFailedEvent(error="boom") @@ -189,14 +184,10 @@ def test_run_single_iteration_path(mocker): app_generate_entity.single_iteration_run = MagicMock() pipeline = MagicMock(id="pipe") - query_pipeline = MagicMock() - query_pipeline.where.return_value.first.return_value = pipeline - - query_end_user = MagicMock() - query_end_user.where.return_value.first.return_value = MagicMock(session_id="sess") + end_user = MagicMock(session_id="sess") session = MagicMock() - session.query.side_effect = [query_end_user, query_pipeline] + session.get.side_effect = [end_user, pipeline] mocker.patch.object(module.db, "session", session) runner = PipelineRunner( @@ -241,14 +232,10 @@ def test_run_normal_path_builds_graph(mocker): app_generate_entity = _build_app_generate_entity() pipeline = MagicMock(id="pipe") - query_pipeline = MagicMock() - query_pipeline.where.return_value.first.return_value = pipeline - - query_end_user = MagicMock() - query_end_user.where.return_value.first.return_value = MagicMock(session_id="sess") + end_user = MagicMock(session_id="sess") session = MagicMock() - session.query.side_effect = [query_end_user, query_pipeline] + session.get.side_effect = [end_user, pipeline] mocker.patch.object(module.db, "session", session) workflow = MagicMock( diff --git a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py index 7cd1fdf06b..4f39d38831 100644 --- a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py +++ b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py @@ -287,9 +287,7 @@ class TestDatasourceFileManager: mock_upload_file.key = "some_key" mock_upload_file.mime_type = "image/png" - mock_query = mock_db.session.query.return_value - mock_where = mock_query.where.return_value - mock_where.first.return_value = mock_upload_file + mock_db.session.get.return_value = mock_upload_file mock_storage.load_once.return_value = b"file content" @@ -300,7 +298,7 @@ class TestDatasourceFileManager: assert result == (b"file content", "image/png") # Case: Not found - mock_where.first.return_value = None + mock_db.session.get.return_value = None assert DatasourceFileManager.get_file_binary("unknown") is None @patch("core.datasource.datasource_file_manager.db") @@ -314,16 +312,14 @@ class TestDatasourceFileManager: mock_tool_file.file_key = "tool_key" mock_tool_file.mimetype = "image/png" - # Mock query sequence - def mock_query(model): - m = MagicMock() + def mock_get(model, id): if model == MessageFile: - m.where.return_value.first.return_value = mock_message_file + return mock_message_file elif model == ToolFile: - m.where.return_value.first.return_value = mock_tool_file - return m + return mock_tool_file + return None - mock_db.session.query.side_effect = mock_query + mock_db.session.get.side_effect = mock_get mock_storage.load_once.return_value = b"tool content" # Execute @@ -344,15 +340,12 @@ class TestDatasourceFileManager: mock_tool_file.file_key = "tk" mock_tool_file.mimetype = "image/png" - def mock_query(model): - m = MagicMock() + def mock_get(model, id): if model == MessageFile: - m.where.return_value.first.return_value = mock_message_file - else: - m.where.return_value.first.return_value = mock_tool_file - return m + return mock_message_file + return mock_tool_file - mock_db.session.query.side_effect = mock_query + mock_db.session.get.side_effect = mock_get mock_storage.load_once.return_value = b"bits" result = DatasourceFileManager.get_file_binary_by_message_file_id("m") @@ -361,27 +354,20 @@ class TestDatasourceFileManager: @patch("core.datasource.datasource_file_manager.db") @patch("core.datasource.datasource_file_manager.storage") def test_get_file_binary_by_message_file_id_failures(self, mock_storage, mock_db): - # Setup common mock - mock_query_obj = MagicMock() - mock_db.session.query.return_value = mock_query_obj - mock_query_obj.where.return_value.first.return_value = None - # Case 1: Message file not found + mock_db.session.get.return_value = None assert DatasourceFileManager.get_file_binary_by_message_file_id("none") is None # Case 2: Message file found but tool file not found mock_message_file = MagicMock(spec=MessageFile) mock_message_file.url = None - def mock_query_v2(model): - m = MagicMock() + def mock_get_v2(model, id): if model == MessageFile: - m.where.return_value.first.return_value = mock_message_file - else: - m.where.return_value.first.return_value = None - return m + return mock_message_file + return None - mock_db.session.query.side_effect = mock_query_v2 + mock_db.session.get.side_effect = mock_get_v2 assert DatasourceFileManager.get_file_binary_by_message_file_id("msg_id") is None @patch("core.datasource.datasource_file_manager.db") @@ -392,7 +378,7 @@ class TestDatasourceFileManager: mock_upload_file.key = "upload_key" mock_upload_file.mime_type = "text/plain" - mock_db.session.query.return_value.where.return_value.first.return_value = mock_upload_file + mock_db.session.get.return_value = mock_upload_file mock_storage.load_stream.return_value = iter([b"chunk1", b"chunk2"]) @@ -404,7 +390,7 @@ class TestDatasourceFileManager: assert list(stream) == [b"chunk1", b"chunk2"] # Case: Not found - mock_db.session.query.return_value.where.return_value.first.return_value = None + mock_db.session.get.return_value = None stream, mimetype = DatasourceFileManager.get_file_generator_by_upload_file_id("none") assert stream is None assert mimetype is None diff --git a/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py b/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py index 450e716636..641c5d9ba0 100644 --- a/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py +++ b/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py @@ -795,33 +795,21 @@ class TestIndexingRunnerRun: doc = sample_dataset_documents[0] # Mock database queries - mock_dependencies["db"].session.get.return_value = doc - mock_dataset = Mock(spec=Dataset) mock_dataset.id = doc.dataset_id mock_dataset.tenant_id = doc.tenant_id mock_dataset.indexing_technique = IndexTechniqueType.ECONOMY - mock_dependencies["db"].session.query.return_value.filter_by.return_value.first.return_value = mock_dataset + + mock_current_user = MagicMock() + mock_current_user.set_tenant_id = MagicMock() + + get_dispatch = {"Document": doc, "Dataset": mock_dataset, "Account": mock_current_user} + mock_dependencies["db"].session.get.side_effect = lambda model, id: get_dispatch.get(model.__name__) mock_process_rule = Mock(spec=DatasetProcessRule) mock_process_rule.to_dict.return_value = {"mode": "automatic", "rules": {}} mock_dependencies["db"].session.scalar.return_value = mock_process_rule - # Mock current_user (Account) for _transform - mock_current_user = MagicMock() - mock_current_user.set_tenant_id = MagicMock() - - # Setup db.session.query to return different results based on the model - def mock_query_side_effect(model): - mock_query_result = MagicMock() - if model.__name__ == "Dataset": - mock_query_result.filter_by.return_value.first.return_value = mock_dataset - elif model.__name__ == "Account": - mock_query_result.filter_by.return_value.first.return_value = mock_current_user - return mock_query_result - - mock_dependencies["db"].session.query.side_effect = mock_query_side_effect - # Mock processor mock_processor = MagicMock() mock_dependencies["factory"].return_value.init_index_processor.return_value = mock_processor @@ -891,10 +879,11 @@ class TestIndexingRunnerRun: doc = sample_dataset_documents[0] # Mock database - mock_dependencies["db"].session.get.return_value = doc - mock_dataset = Mock(spec=Dataset) - mock_dependencies["db"].session.query.return_value.filter_by.return_value.first.return_value = mock_dataset + mock_dataset.tenant_id = doc.tenant_id + + get_dispatch = {"Document": doc, "Dataset": mock_dataset} + mock_dependencies["db"].session.get.side_effect = lambda model, id: get_dispatch.get(model.__name__) mock_process_rule = Mock(spec=DatasetProcessRule) mock_process_rule.to_dict.return_value = {"mode": "automatic", "rules": {}} @@ -917,11 +906,12 @@ class TestIndexingRunnerRun: runner = IndexingRunner() doc = sample_dataset_documents[0] - # Mock database to raise ObjectDeletedError - mock_dependencies["db"].session.get.return_value = doc - + # Mock database mock_dataset = Mock(spec=Dataset) - mock_dependencies["db"].session.query.return_value.filter_by.return_value.first.return_value = mock_dataset + mock_dataset.tenant_id = doc.tenant_id + + get_dispatch = {"Document": doc, "Dataset": mock_dataset} + mock_dependencies["db"].session.get.side_effect = lambda model, id: get_dispatch.get(model.__name__) mock_process_rule = Mock(spec=DatasetProcessRule) mock_process_rule.to_dict.return_value = {"mode": "automatic", "rules": {}} @@ -945,17 +935,21 @@ class TestIndexingRunnerRun: docs = sample_dataset_documents # Mock database - def get_side_effect(model_class, doc_id): - for doc in docs: - if doc.id == doc_id: - return doc - return None - - mock_dependencies["db"].session.get.side_effect = get_side_effect - mock_dataset = Mock(spec=Dataset) mock_dataset.indexing_technique = IndexTechniqueType.ECONOMY - mock_dependencies["db"].session.query.return_value.filter_by.return_value.first.return_value = mock_dataset + mock_current_user = MagicMock() + mock_current_user.set_tenant_id = MagicMock() + + doc_map = {doc.id: doc for doc in docs} + model_dispatch = {"Dataset": mock_dataset, "Account": mock_current_user} + + def get_side_effect(model_class, id): + name = model_class.__name__ + if name == "Document": + return doc_map.get(id) + return model_dispatch.get(name) + + mock_dependencies["db"].session.get.side_effect = get_side_effect mock_process_rule = Mock(spec=DatasetProcessRule) mock_process_rule.to_dict.return_value = {"mode": "automatic", "rules": {}} @@ -1035,9 +1029,8 @@ class TestIndexingRunnerRetryLogic: mock_document = Mock(spec=DatasetDocument) mock_document.id = document_id - mock_dependencies["db"].session.query.return_value.filter_by.return_value.count.return_value = 0 - mock_dependencies["db"].session.query.return_value.filter_by.return_value.first.return_value = mock_document - mock_dependencies["db"].session.query.return_value.filter_by.return_value.update.return_value = None + mock_dependencies["db"].session.scalar.return_value = 0 + mock_dependencies["db"].session.get.return_value = mock_document # Act IndexingRunner._update_document_index_status( @@ -1053,7 +1046,7 @@ class TestIndexingRunnerRetryLogic: """Test document status update when document is paused.""" # Arrange document_id = str(uuid.uuid4()) - mock_dependencies["db"].session.query.return_value.filter_by.return_value.count.return_value = 1 + mock_dependencies["db"].session.scalar.return_value = 1 # Act & Assert with pytest.raises(DocumentIsPausedError): @@ -1063,8 +1056,8 @@ class TestIndexingRunnerRetryLogic: """Test document status update when document is deleted.""" # Arrange document_id = str(uuid.uuid4()) - mock_dependencies["db"].session.query.return_value.filter_by.return_value.count.return_value = 0 - mock_dependencies["db"].session.query.return_value.filter_by.return_value.first.return_value = None + mock_dependencies["db"].session.scalar.return_value = 0 + mock_dependencies["db"].session.get.return_value = None # Act & Assert with pytest.raises(DocumentIsDeletedPausedError): From 42d7623cc6e8b38aa0913d1e006f22205b12a962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Wed, 1 Apr 2026 10:32:01 +0800 Subject: [PATCH 18/30] fix: Variable Aggregator cannot click group swich (#34361) --- .../__tests__/use-config.spec.tsx | 30 +++++++++++++++ .../variable-assigner/use-config.helpers.ts | 23 +++++++++++- .../nodes/variable-assigner/use-config.ts | 37 +++++++++++++------ 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx index 1137f20a0c..cb8c2db52f 100644 --- a/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx @@ -91,6 +91,15 @@ const createPayload = (overrides: Partial = {}): Varia ...overrides, }) +const createPayloadWithoutAdvancedSettings = (): VariableAssignerNodeType => { + const payload = createPayload() as Omit & { + advanced_settings?: VariableAssignerNodeType['advanced_settings'] + } + delete payload.advanced_settings + + return payload as VariableAssignerNodeType +} + describe('useConfig', () => { beforeEach(() => { vi.clearAllMocks() @@ -252,4 +261,25 @@ describe('useConfig', () => { advanced_settings: expect.objectContaining({ group_enabled: false }), })) }) + + it('should not throw when enabling groups with missing advanced settings', () => { + const { result } = renderHook(() => useConfig('assigner-node', createPayloadWithoutAdvancedSettings())) + + expect(() => { + result.current.handleGroupEnabledChange(true) + }).not.toThrow() + + expect(mockHandleOutVarRenameChange).toHaveBeenCalledWith( + 'assigner-node', + ['assigner-node', 'output'], + ['assigner-node', 'Group1', 'output'], + ) + expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({ + advanced_settings: expect.objectContaining({ + group_enabled: true, + groups: [expect.objectContaining({ group_name: 'Group1' })], + }), + })) + expect(mockDeleteNodeInspectorVars).toHaveBeenCalledWith('assigner-node') + }) }) diff --git a/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts b/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts index 31300557b2..2cc91c65ac 100644 --- a/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts +++ b/web/app/components/workflow/nodes/variable-assigner/use-config.helpers.ts @@ -26,7 +26,13 @@ export const updateNestedVarGroupItem = ( groupId: string, payload: VarGroupItem, ) => produce(inputs, (draft) => { + if (!draft.advanced_settings) + return + const index = draft.advanced_settings.groups.findIndex(item => item.groupId === groupId) + if (index < 0) + return + draft.advanced_settings.groups[index] = { ...draft.advanced_settings.groups[index], ...payload, @@ -37,6 +43,11 @@ export const removeGroupByIndex = ( inputs: VariableAssignerNodeType, index: number, ) => produce(inputs, (draft) => { + if (!draft.advanced_settings) + return + if (index < 0 || index >= draft.advanced_settings.groups.length) + return + draft.advanced_settings.groups.splice(index, 1) }) @@ -70,7 +81,8 @@ export const toggleGroupEnabled = ({ export const addGroup = (inputs: VariableAssignerNodeType) => { let maxInGroupName = 1 - inputs.advanced_settings.groups.forEach((item) => { + const groups = inputs.advanced_settings?.groups ?? [] + groups.forEach((item) => { const match = /(\d+)$/.exec(item.group_name) if (match) { const num = Number.parseInt(match[1], 10) @@ -80,6 +92,9 @@ export const addGroup = (inputs: VariableAssignerNodeType) => { }) return produce(inputs, (draft) => { + if (!draft.advanced_settings) + draft.advanced_settings = { group_enabled: false, groups: [] } + draft.advanced_settings.groups.push({ output_type: VarType.any, variables: [], @@ -94,6 +109,12 @@ export const renameGroup = ( groupId: string, name: string, ) => produce(inputs, (draft) => { + if (!draft.advanced_settings) + return + const index = draft.advanced_settings.groups.findIndex(item => item.groupId === groupId) + if (index < 0) + return + draft.advanced_settings.groups[index].group_name = name }) diff --git a/web/app/components/workflow/nodes/variable-assigner/use-config.ts b/web/app/components/workflow/nodes/variable-assigner/use-config.ts index 6d4b27e50b..cecf185d4f 100644 --- a/web/app/components/workflow/nodes/variable-assigner/use-config.ts +++ b/web/app/components/workflow/nodes/variable-assigner/use-config.ts @@ -54,10 +54,15 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { const [removedGroupIndex, setRemovedGroupIndex] = useState(-1) const handleGroupRemoved = useCallback((groupId: string) => { return () => { - const index = inputs.advanced_settings.groups.findIndex(item => item.groupId === groupId) - if (isVarUsedInNodes([id, inputs.advanced_settings.groups[index].group_name, 'output'])) { + const groups = inputs.advanced_settings?.groups ?? [] + const index = groups.findIndex(item => item.groupId === groupId) + if (index < 0) + return + + const groupName = groups[index].group_name + if (isVarUsedInNodes([id, groupName, 'output'])) { showRemoveVarConfirm() - setRemovedVars([[id, inputs.advanced_settings.groups[index].group_name, 'output']]) + setRemovedVars([[id, groupName, 'output']]) setRemoveType('group') setRemovedGroupIndex(index) return @@ -67,13 +72,15 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { }, [id, inputs, isVarUsedInNodes, setInputs, showRemoveVarConfirm]) const handleGroupEnabledChange = useCallback((enabled: boolean) => { - if (enabled && inputs.advanced_settings.groups.length === 0) { + const groups = inputs.advanced_settings?.groups ?? [] + + if (enabled && groups.length === 0) { handleOutVarRenameChange(id, [id, 'output'], [id, 'Group1', 'output']) } - if (!enabled && inputs.advanced_settings.groups.length > 0) { - if (inputs.advanced_settings.groups.length > 1) { - const useVars = inputs.advanced_settings.groups.filter((item, index) => index > 0 && isVarUsedInNodes([id, item.group_name, 'output'])) + if (!enabled && groups.length > 0) { + if (groups.length > 1) { + const useVars = groups.filter((item, index) => index > 0 && isVarUsedInNodes([id, item.group_name, 'output'])) if (useVars.length > 0) { showRemoveVarConfirm() setRemovedVars(useVars.map(item => [id, item.group_name, 'output'])) @@ -82,7 +89,7 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { } } - handleOutVarRenameChange(id, [id, inputs.advanced_settings.groups[0].group_name, 'output'], [id, 'output']) + handleOutVarRenameChange(id, [id, groups[0].group_name, 'output'], [id, 'output']) } setInputs(toggleGroupEnabled({ inputs, enabled })) @@ -110,11 +117,16 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { const handleVarGroupNameChange = useCallback((groupId: string) => { return (name: string) => { - const index = inputs.advanced_settings.groups.findIndex(item => item.groupId === groupId) - handleOutVarRenameChange(id, [id, inputs.advanced_settings.groups[index].group_name, 'output'], [id, name, 'output']) + const groups = inputs.advanced_settings?.groups ?? [] + const index = groups.findIndex(item => item.groupId === groupId) + if (index < 0) + return + + const oldName = groups[index].group_name + handleOutVarRenameChange(id, [id, oldName, 'output'], [id, name, 'output']) setInputs(renameGroup(inputs, groupId, name)) if (!(id in oldNameRef.current)) - oldNameRef.current[id] = inputs.advanced_settings.groups[index].group_name + oldNameRef.current[id] = oldName renameInspectNameWithDebounce(id, name) } }, [handleOutVarRenameChange, id, inputs, renameInspectNameWithDebounce, setInputs]) @@ -125,7 +137,8 @@ const useConfig = (id: string, payload: VariableAssignerNodeType) => { }) hideRemoveVarConfirm() if (removeType === 'group') { - setInputs(removeGroupByIndex(inputs, removedGroupIndex)) + if (removedGroupIndex >= 0) + setInputs(removeGroupByIndex(inputs, removedGroupIndex)) } else { // removeType === 'enableChanged' to enabled From beda78e91129874d7a2b5377f71f2cc2ca6dc1bb Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Wed, 1 Apr 2026 06:00:05 +0200 Subject: [PATCH 19/30] refactor: select in 13 small service files (#34371) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/services/audio_service.py | 2 +- api/services/billing_service.py | 7 +++-- api/services/conversation_service.py | 12 ++++---- api/services/credit_pool_service.py | 14 ++++----- .../enterprise/account_deletion_sync.py | 5 +++- .../rag_pipeline/pipeline_generate_service.py | 2 +- .../customized/customized_retrieval.py | 12 ++++---- .../database/database_retrieval.py | 11 +++---- .../database/database_retrieval.py | 8 ++--- api/services/web_conversation_service.py | 12 ++++---- api/services/webapp_auth_service.py | 5 ++-- api/services/workflow/workflow_converter.py | 7 +++-- api/services/workspace_service.py | 7 +++-- .../unit_tests/services/test_audio_service.py | 21 ++++--------- .../services/test_billing_service.py | 30 ++++--------------- .../services/test_conversation_service.py | 19 ++++-------- 16 files changed, 72 insertions(+), 102 deletions(-) diff --git a/api/services/audio_service.py b/api/services/audio_service.py index 90e72d5f34..1c7027efb4 100644 --- a/api/services/audio_service.py +++ b/api/services/audio_service.py @@ -132,7 +132,7 @@ class AudioService: uuid.UUID(message_id) except ValueError: return None - message = db.session.query(Message).where(Message.id == message_id).first() + message = db.session.get(Message, message_id) if message is None: return None if message.answer == "" and message.status in {MessageStatus.NORMAL, MessageStatus.PAUSED}: diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 54c595e0cb..9970b2e604 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -6,6 +6,7 @@ from typing import Literal import httpx from pydantic import TypeAdapter +from sqlalchemy import select from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed from typing_extensions import TypedDict from werkzeug.exceptions import InternalServerError @@ -158,10 +159,10 @@ class BillingService: def is_tenant_owner_or_admin(current_user: Account): tenant_id = current_user.current_tenant_id - join: TenantAccountJoin | None = ( - db.session.query(TenantAccountJoin) + join: TenantAccountJoin | None = db.session.scalar( + select(TenantAccountJoin) .where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == current_user.id) - .first() + .limit(1) ) if not join: diff --git a/api/services/conversation_service.py b/api/services/conversation_service.py index ba1e7bb826..95482a2235 100644 --- a/api/services/conversation_service.py +++ b/api/services/conversation_service.py @@ -137,11 +137,11 @@ class ConversationService: @classmethod def auto_generate_name(cls, app_model: App, conversation: Conversation): # get conversation first message - message = ( - db.session.query(Message) + message = db.session.scalar( + select(Message) .where(Message.app_id == app_model.id, Message.conversation_id == conversation.id) .order_by(Message.created_at.asc()) - .first() + .limit(1) ) if not message: @@ -160,8 +160,8 @@ class ConversationService: @classmethod def get_conversation(cls, app_model: App, conversation_id: str, user: Union[Account, EndUser] | None): - conversation = ( - db.session.query(Conversation) + conversation = db.session.scalar( + select(Conversation) .where( Conversation.id == conversation_id, Conversation.app_id == app_model.id, @@ -170,7 +170,7 @@ class ConversationService: Conversation.from_account_id == (user.id if isinstance(user, Account) else None), Conversation.is_deleted == False, ) - .first() + .limit(1) ) if not conversation: diff --git a/api/services/credit_pool_service.py b/api/services/credit_pool_service.py index 2894826935..7826695366 100644 --- a/api/services/credit_pool_service.py +++ b/api/services/credit_pool_service.py @@ -1,6 +1,6 @@ import logging -from sqlalchemy import update +from sqlalchemy import select, update from sqlalchemy.orm import Session from configs import dify_config @@ -29,13 +29,13 @@ class CreditPoolService: @classmethod def get_pool(cls, tenant_id: str, pool_type: str = "trial") -> TenantCreditPool | None: """get tenant credit pool""" - return ( - db.session.query(TenantCreditPool) - .filter_by( - tenant_id=tenant_id, - pool_type=pool_type, + return db.session.scalar( + select(TenantCreditPool) + .where( + TenantCreditPool.tenant_id == tenant_id, + TenantCreditPool.pool_type == pool_type, ) - .first() + .limit(1) ) @classmethod diff --git a/api/services/enterprise/account_deletion_sync.py b/api/services/enterprise/account_deletion_sync.py index c7ff42894d..b5107fb0f6 100644 --- a/api/services/enterprise/account_deletion_sync.py +++ b/api/services/enterprise/account_deletion_sync.py @@ -4,6 +4,7 @@ import uuid from datetime import UTC, datetime from redis import RedisError +from sqlalchemy import select from configs import dify_config from extensions.ext_database import db @@ -104,7 +105,9 @@ def sync_account_deletion(account_id: str, *, source: str) -> bool: return True # Fetch all workspaces the account belongs to - workspace_joins = db.session.query(TenantAccountJoin).filter_by(account_id=account_id).all() + workspace_joins = db.session.scalars( + select(TenantAccountJoin).where(TenantAccountJoin.account_id == account_id) + ).all() # Queue sync task for each workspace success = True diff --git a/api/services/rag_pipeline/pipeline_generate_service.py b/api/services/rag_pipeline/pipeline_generate_service.py index 07e1b8f20e..10e89b1dba 100644 --- a/api/services/rag_pipeline/pipeline_generate_service.py +++ b/api/services/rag_pipeline/pipeline_generate_service.py @@ -110,7 +110,7 @@ class PipelineGenerateService: Update document status to waiting :param document_id: document id """ - document = db.session.query(Document).where(Document.id == document_id).first() + document = db.session.get(Document, document_id) if document: document.indexing_status = IndexingStatus.WAITING db.session.add(document) diff --git a/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py b/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py index 4ac2e0792b..2ee871a266 100644 --- a/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/customized/customized_retrieval.py @@ -1,4 +1,5 @@ import yaml +from sqlalchemy import select from extensions.ext_database import db from libs.login import current_account_with_tenant @@ -32,12 +33,11 @@ class CustomizedPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): :param language: language :return: """ - pipeline_customized_templates = ( - db.session.query(PipelineCustomizedTemplate) + pipeline_customized_templates = db.session.scalars( + select(PipelineCustomizedTemplate) .where(PipelineCustomizedTemplate.tenant_id == tenant_id, PipelineCustomizedTemplate.language == language) .order_by(PipelineCustomizedTemplate.position.asc(), PipelineCustomizedTemplate.created_at.desc()) - .all() - ) + ).all() recommended_pipelines_results = [] for pipeline_customized_template in pipeline_customized_templates: recommended_pipeline_result = { @@ -59,9 +59,7 @@ class CustomizedPipelineTemplateRetrieval(PipelineTemplateRetrievalBase): :param template_id: Template ID :return: """ - pipeline_template = ( - db.session.query(PipelineCustomizedTemplate).where(PipelineCustomizedTemplate.id == template_id).first() - ) + pipeline_template = db.session.get(PipelineCustomizedTemplate, template_id) if not pipeline_template: return None diff --git a/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py b/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py index 908f9a2684..43b21a7b32 100644 --- a/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/database/database_retrieval.py @@ -1,4 +1,5 @@ import yaml +from sqlalchemy import select from extensions.ext_database import db from models.dataset import PipelineBuiltInTemplate @@ -30,8 +31,10 @@ class DatabasePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): :return: """ - pipeline_built_in_templates: list[PipelineBuiltInTemplate] = ( - db.session.query(PipelineBuiltInTemplate).where(PipelineBuiltInTemplate.language == language).all() + pipeline_built_in_templates = list( + db.session.scalars( + select(PipelineBuiltInTemplate).where(PipelineBuiltInTemplate.language == language) + ).all() ) recommended_pipelines_results = [] @@ -58,9 +61,7 @@ class DatabasePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): :return: """ # is in public recommended list - pipeline_template = ( - db.session.query(PipelineBuiltInTemplate).where(PipelineBuiltInTemplate.id == template_id).first() - ) + pipeline_template = db.session.get(PipelineBuiltInTemplate, template_id) if not pipeline_template: return None diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py index d0c49325dc..6fb90d356d 100644 --- a/api/services/recommend_app/database/database_retrieval.py +++ b/api/services/recommend_app/database/database_retrieval.py @@ -77,17 +77,15 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): :return: """ # is in public recommended list - recommended_app = ( - db.session.query(RecommendedApp) - .where(RecommendedApp.is_listed == True, RecommendedApp.app_id == app_id) - .first() + recommended_app = db.session.scalar( + select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.app_id == app_id).limit(1) ) if not recommended_app: return None # get app detail - app_model = db.session.query(App).where(App.id == app_id).first() + app_model = db.session.get(App, app_id) if not app_model or not app_model.is_public: return None diff --git a/api/services/web_conversation_service.py b/api/services/web_conversation_service.py index e028e3e5e3..5ef9e9be61 100644 --- a/api/services/web_conversation_service.py +++ b/api/services/web_conversation_service.py @@ -64,15 +64,15 @@ class WebConversationService: def pin(cls, app_model: App, conversation_id: str, user: Union[Account, EndUser] | None): if not user: return - pinned_conversation = ( - db.session.query(PinnedConversation) + pinned_conversation = db.session.scalar( + select(PinnedConversation) .where( PinnedConversation.app_id == app_model.id, PinnedConversation.conversation_id == conversation_id, PinnedConversation.created_by_role == ("account" if isinstance(user, Account) else "end_user"), PinnedConversation.created_by == user.id, ) - .first() + .limit(1) ) if pinned_conversation: @@ -96,15 +96,15 @@ class WebConversationService: def unpin(cls, app_model: App, conversation_id: str, user: Union[Account, EndUser] | None): if not user: return - pinned_conversation = ( - db.session.query(PinnedConversation) + pinned_conversation = db.session.scalar( + select(PinnedConversation) .where( PinnedConversation.app_id == app_model.id, PinnedConversation.conversation_id == conversation_id, PinnedConversation.created_by_role == ("account" if isinstance(user, Account) else "end_user"), PinnedConversation.created_by == user.id, ) - .first() + .limit(1) ) if not pinned_conversation: diff --git a/api/services/webapp_auth_service.py b/api/services/webapp_auth_service.py index 5ca0b63001..eaea79af2f 100644 --- a/api/services/webapp_auth_service.py +++ b/api/services/webapp_auth_service.py @@ -3,6 +3,7 @@ import secrets from datetime import UTC, datetime, timedelta from typing import Any +from sqlalchemy import select from werkzeug.exceptions import NotFound, Unauthorized from configs import dify_config @@ -92,10 +93,10 @@ class WebAppAuthService: @classmethod def create_end_user(cls, app_code, email) -> EndUser: - site = db.session.query(Site).where(Site.code == app_code).first() + site = db.session.scalar(select(Site).where(Site.code == app_code).limit(1)) if not site: raise NotFound("Site not found.") - app_model = db.session.query(App).where(App.id == site.app_id).first() + app_model = db.session.get(App, site.app_id) if not app_model: raise NotFound("App not found.") end_user = EndUser( diff --git a/api/services/workflow/workflow_converter.py b/api/services/workflow/workflow_converter.py index 31367f72fa..399c82849f 100644 --- a/api/services/workflow/workflow_converter.py +++ b/api/services/workflow/workflow_converter.py @@ -6,6 +6,7 @@ from graphon.model_runtime.entities.llm_entities import LLMMode from graphon.model_runtime.utils.encoders import jsonable_encoder from graphon.nodes import BuiltinNodeTypes from graphon.variables.input_entities import VariableEntity +from sqlalchemy import select from typing_extensions import TypedDict from core.app.app_config.entities import ( @@ -648,10 +649,10 @@ class WorkflowConverter: :param api_based_extension_id: api based extension id :return: """ - api_based_extension = ( - db.session.query(APIBasedExtension) + api_based_extension = db.session.scalar( + select(APIBasedExtension) .where(APIBasedExtension.tenant_id == tenant_id, APIBasedExtension.id == api_based_extension_id) - .first() + .limit(1) ) if not api_based_extension: diff --git a/api/services/workspace_service.py b/api/services/workspace_service.py index 84a8b03329..eb4671cfaa 100644 --- a/api/services/workspace_service.py +++ b/api/services/workspace_service.py @@ -1,4 +1,5 @@ from flask_login import current_user +from sqlalchemy import select from configs import dify_config from enums.cloud_plan import CloudPlan @@ -24,10 +25,10 @@ class WorkspaceService: } # Get role of user - tenant_account_join = ( - db.session.query(TenantAccountJoin) + tenant_account_join = db.session.scalar( + select(TenantAccountJoin) .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == current_user.id) - .first() + .limit(1) ) assert tenant_account_join is not None, "TenantAccountJoin not found" tenant_info["role"] = tenant_account_join.role diff --git a/api/tests/unit_tests/services/test_audio_service.py b/api/tests/unit_tests/services/test_audio_service.py index 175fd3ee01..cede6671ce 100644 --- a/api/tests/unit_tests/services/test_audio_service.py +++ b/api/tests/unit_tests/services/test_audio_service.py @@ -421,11 +421,8 @@ class TestAudioServiceTTS: answer="Message answer text", ) - # Mock database query - mock_query = MagicMock() - mock_db_session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.first.return_value = message + # Mock database lookup + mock_db_session.get.return_value = message # Mock ModelManager mock_model_manager = mock_model_manager_class.return_value @@ -568,11 +565,8 @@ class TestAudioServiceTTS: # Arrange app = factory.create_app_mock() - # Mock database query returning None - mock_query = MagicMock() - mock_db_session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.first.return_value = None + # Mock database lookup returning None + mock_db_session.get.return_value = None # Act result = AudioService.transcript_tts( @@ -594,11 +588,8 @@ class TestAudioServiceTTS: status=MessageStatus.NORMAL, ) - # Mock database query - mock_query = MagicMock() - mock_db_session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.first.return_value = message + # Mock database lookup + mock_db_session.get.return_value = message # Act result = AudioService.transcript_tts( diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index b3d2e60802..168ab6cf0d 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -865,16 +865,11 @@ class TestBillingServiceAccountManagement: mock_join = MagicMock(spec=TenantAccountJoin) mock_join.role = TenantAccountRole.OWNER - mock_query = MagicMock() - mock_query.where.return_value.first.return_value = mock_join - mock_db_session.query.return_value = mock_query + mock_db_session.scalar.return_value = mock_join # Act - should not raise exception BillingService.is_tenant_owner_or_admin(current_user) - # Assert - mock_db_session.query.assert_called_once() - def test_is_tenant_owner_or_admin_admin(self, mock_db_session): """Test tenant owner/admin check for admin role.""" # Arrange @@ -885,16 +880,11 @@ class TestBillingServiceAccountManagement: mock_join = MagicMock(spec=TenantAccountJoin) mock_join.role = TenantAccountRole.ADMIN - mock_query = MagicMock() - mock_query.where.return_value.first.return_value = mock_join - mock_db_session.query.return_value = mock_query + mock_db_session.scalar.return_value = mock_join # Act - should not raise exception BillingService.is_tenant_owner_or_admin(current_user) - # Assert - mock_db_session.query.assert_called_once() - def test_is_tenant_owner_or_admin_normal_user_raises_error(self, mock_db_session): """Test tenant owner/admin check raises error for normal user.""" # Arrange @@ -905,9 +895,7 @@ class TestBillingServiceAccountManagement: mock_join = MagicMock(spec=TenantAccountJoin) mock_join.role = TenantAccountRole.NORMAL - mock_query = MagicMock() - mock_query.where.return_value.first.return_value = mock_join - mock_db_session.query.return_value = mock_query + mock_db_session.scalar.return_value = mock_join # Act & Assert with pytest.raises(ValueError) as exc_info: @@ -921,9 +909,7 @@ class TestBillingServiceAccountManagement: current_user.id = "account-123" current_user.current_tenant_id = "tenant-456" - mock_query = MagicMock() - mock_query.where.return_value.first.return_value = None - mock_db_session.query.return_value = mock_query + mock_db_session.scalar.return_value = None # Act & Assert with pytest.raises(ValueError) as exc_info: @@ -1135,9 +1121,7 @@ class TestBillingServiceEdgeCases: mock_join.role = TenantAccountRole.EDITOR # Editor is not privileged with patch("services.billing_service.db.session") as mock_session: - mock_query = MagicMock() - mock_query.where.return_value.first.return_value = mock_join - mock_session.query.return_value = mock_query + mock_session.scalar.return_value = mock_join # Act & Assert with pytest.raises(ValueError) as exc_info: @@ -1155,9 +1139,7 @@ class TestBillingServiceEdgeCases: mock_join.role = TenantAccountRole.DATASET_OPERATOR # Dataset operator is not privileged with patch("services.billing_service.db.session") as mock_session: - mock_query = MagicMock() - mock_query.where.return_value.first.return_value = mock_join - mock_session.query.return_value = mock_query + mock_session.scalar.return_value = mock_join # Act & Assert with pytest.raises(ValueError) as exc_info: diff --git a/api/tests/unit_tests/services/test_conversation_service.py b/api/tests/unit_tests/services/test_conversation_service.py index 1bf4c0e172..a4359f00b8 100644 --- a/api/tests/unit_tests/services/test_conversation_service.py +++ b/api/tests/unit_tests/services/test_conversation_service.py @@ -355,15 +355,13 @@ class TestConversationServiceGetConversation: from_account_id=user.id, from_source=ConversationFromSource.CONSOLE ) - mock_query = mock_db_session.query.return_value - mock_query.where.return_value.first.return_value = conversation + mock_db_session.scalar.return_value = conversation # Act result = ConversationService.get_conversation(app_model, "conv-123", user) # Assert assert result == conversation - mock_db_session.query.assert_called_once_with(Conversation) @patch("services.conversation_service.db.session") def test_get_conversation_success_with_end_user(self, mock_db_session): @@ -379,8 +377,7 @@ class TestConversationServiceGetConversation: from_end_user_id=user.id, from_source=ConversationFromSource.API ) - mock_query = mock_db_session.query.return_value - mock_query.where.return_value.first.return_value = conversation + mock_db_session.scalar.return_value = conversation # Act result = ConversationService.get_conversation(app_model, "conv-123", user) @@ -399,8 +396,7 @@ class TestConversationServiceGetConversation: app_model = ConversationServiceTestDataFactory.create_app_mock() user = ConversationServiceTestDataFactory.create_account_mock() - mock_query = mock_db_session.query.return_value - mock_query.where.return_value.first.return_value = None + mock_db_session.scalar.return_value = None # Act & Assert with pytest.raises(ConversationNotExistsError): @@ -489,8 +485,7 @@ class TestConversationServiceAutoGenerateName: ) # Mock database query to return message - mock_query = mock_db_session.query.return_value - mock_query.where.return_value.order_by.return_value.first.return_value = message + mock_db_session.scalar.return_value = message # Mock LLM generator mock_llm_generator.generate_conversation_name.return_value = "Generated Name" @@ -518,8 +513,7 @@ class TestConversationServiceAutoGenerateName: conversation = ConversationServiceTestDataFactory.create_conversation_mock() # Mock database query to return None - mock_query = mock_db_session.query.return_value - mock_query.where.return_value.order_by.return_value.first.return_value = None + mock_db_session.scalar.return_value = None # Act & Assert with pytest.raises(MessageNotExistsError): @@ -541,8 +535,7 @@ class TestConversationServiceAutoGenerateName: ) # Mock database query to return message - mock_query = mock_db_session.query.return_value - mock_query.where.return_value.order_by.return_value.first.return_value = message + mock_db_session.scalar.return_value = message # Mock LLM generator to raise exception mock_llm_generator.generate_conversation_name.side_effect = Exception("LLM Error") From 09ee8ea1f535fc86a41e8370ef520abbe10ac54f Mon Sep 17 00:00:00 2001 From: Full Stack Engineer <66432853+EndlessLucky@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:22:23 -0400 Subject: [PATCH 20/30] fix: support qa_preview shape in IndexProcessor preview formatting (#34151) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com> --- api/core/rag/index_processor/index_processor.py | 9 ++++++++- .../core/rag/indexing/test_index_processor.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 api/tests/unit_tests/core/rag/indexing/test_index_processor.py diff --git a/api/core/rag/index_processor/index_processor.py b/api/core/rag/index_processor/index_processor.py index a6d1db214b..825ae01226 100644 --- a/api/core/rag/index_processor/index_processor.py +++ b/api/core/rag/index_processor/index_processor.py @@ -35,7 +35,10 @@ class IndexProcessor: if "parent_mode" in preview: data.parent_mode = preview["parent_mode"] - for item in preview["preview"]: + # Different index processors return different preview shapes: + # - paragraph/parent-child processors: {"preview": [...]} + # - QA processor: {"qa_preview": [...]} (no "preview" key) + for item in preview.get("preview", []): if "content" in item and "child_chunks" in item: data.preview.append( PreviewItem(content=item["content"], child_chunks=item["child_chunks"], summary=None) @@ -44,6 +47,10 @@ class IndexProcessor: data.qa_preview.append(QaPreview(question=item["question"], answer=item["answer"])) elif "content" in item: data.preview.append(PreviewItem(content=item["content"], child_chunks=None, summary=None)) + + for item in preview.get("qa_preview", []): + if "question" in item and "answer" in item: + data.qa_preview.append(QaPreview(question=item["question"], answer=item["answer"])) return data def index_and_clean( diff --git a/api/tests/unit_tests/core/rag/indexing/test_index_processor.py b/api/tests/unit_tests/core/rag/indexing/test_index_processor.py new file mode 100644 index 0000000000..a3f284955b --- /dev/null +++ b/api/tests/unit_tests/core/rag/indexing/test_index_processor.py @@ -0,0 +1,15 @@ +from core.rag.index_processor.index_processor import IndexProcessor + + +class TestIndexProcessor: + def test_format_preview_supports_qa_preview_shape(self) -> None: + preview = IndexProcessor().format_preview( + "qa_model", + {"qa_chunks": [{"question": "Q1", "answer": "A1"}]}, + ) + + assert preview.chunk_structure == "qa_model" + assert preview.total_segments == 1 + assert len(preview.qa_preview) == 1 + assert preview.qa_preview[0].question == "Q1" + assert preview.qa_preview[0].answer == "A1" From c51cd42cb4e21320664b6d0e9efcf2ecbd1ddec5 Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:41:44 -0400 Subject: [PATCH 21/30] refactor(api): replace json.loads with Pydantic validation in controllers and infra layers (#34277) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/workflow.py | 12 ++--- .../rag_pipeline/rag_pipeline_workflow.py | 23 ++-------- .../arize_phoenix_trace.py | 3 +- api/core/ops/mlflow_trace/mlflow_trace.py | 10 ++--- api/core/ops/ops_trace_manager.py | 23 +++++++--- api/core/ops/utils.py | 3 ++ .../alibabacloud_mysql_vector.py | 15 +++---- .../analyticdb/analyticdb_vector_openapi.py | 5 ++- .../rag/datasource/vdb/baidu/baidu_vector.py | 13 ++---- .../vdb/clickzetta/clickzetta_vector.py | 32 ++++++------- api/core/rag/datasource/vdb/field.py | 20 +++++++++ .../vdb/hologres/hologres_vector.py | 7 ++- .../rag/datasource/vdb/iris/iris_vector.py | 5 ++- .../vdb/matrixone/matrixone_vector.py | 7 +-- .../vdb/oceanbase/oceanbase_vector.py | 5 ++- .../vdb/tablestore/tablestore_vector.py | 9 ++-- .../datasource/vdb/tencent/tencent_vector.py | 12 +++-- .../datasource/vdb/tidb_vector/tidb_vector.py | 4 +- .../vdb/vikingdb/vikingdb_vector.py | 7 ++- ...tore_workflow_node_execution_repository.py | 9 ++-- .../clickzetta_volume/file_lifecycle.py | 8 +++- .../storage/google_cloud_storage.py | 7 ++- .../core/rag/datasource/vdb/test_field.py | 45 +++++++++++++++++++ 23 files changed, 170 insertions(+), 114 deletions(-) create mode 100644 api/tests/unit_tests/core/rag/datasource/vdb/test_field.py diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 6df8f7032e..dcd24d2200 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -9,7 +9,7 @@ from graphon.enums import NodeType from graphon.file import File from graphon.graph_engine.manager import GraphEngineManager from graphon.model_runtime.utils.encoders import jsonable_encoder -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, ValidationError, field_validator from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound @@ -268,22 +268,18 @@ class DraftWorkflowApi(Resource): content_type = request.headers.get("Content-Type", "") - payload_data: dict[str, Any] | None = None if "application/json" in content_type: payload_data = request.get_json(silent=True) if not isinstance(payload_data, dict): return {"message": "Invalid JSON data"}, 400 + args_model = SyncDraftWorkflowPayload.model_validate(payload_data) elif "text/plain" in content_type: try: - payload_data = json.loads(request.data.decode("utf-8")) - except json.JSONDecodeError: - return {"message": "Invalid JSON data"}, 400 - if not isinstance(payload_data, dict): + args_model = SyncDraftWorkflowPayload.model_validate_json(request.data) + except (ValueError, ValidationError): return {"message": "Invalid JSON data"}, 400 else: abort(415) - - args_model = SyncDraftWorkflowPayload.model_validate(payload_data) args = args_model.model_dump() workflow_service = WorkflowService() diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index e08cb155b6..4251e7ebac 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -5,7 +5,7 @@ from typing import Any, Literal, cast from flask import abort, request from flask_restx import Resource, marshal_with # type: ignore from graphon.model_runtime.utils.encoders import jsonable_encoder -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound @@ -186,29 +186,14 @@ class DraftRagPipelineApi(Resource): if "application/json" in content_type: payload_dict = console_ns.payload or {} + payload = DraftWorkflowSyncPayload.model_validate(payload_dict) elif "text/plain" in content_type: try: - data = json.loads(request.data.decode("utf-8")) - if "graph" not in data or "features" not in data: - raise ValueError("graph or features not found in data") - - if not isinstance(data.get("graph"), dict): - raise ValueError("graph is not a dict") - - payload_dict = { - "graph": data.get("graph"), - "features": data.get("features"), - "hash": data.get("hash"), - "environment_variables": data.get("environment_variables"), - "conversation_variables": data.get("conversation_variables"), - "rag_pipeline_variables": data.get("rag_pipeline_variables"), - } - except json.JSONDecodeError: + payload = DraftWorkflowSyncPayload.model_validate_json(request.data) + except (ValueError, ValidationError): return {"message": "Invalid JSON data"}, 400 else: abort(415) - - payload = DraftWorkflowSyncPayload.model_validate(payload_dict) rag_pipeline_service = RagPipelineService() try: diff --git a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py index 902f58e6b7..66933cea28 100644 --- a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py +++ b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py @@ -38,6 +38,7 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) +from core.ops.utils import JSON_DICT_ADAPTER from core.repositories import DifyCoreRepositoryFactory from extensions.ext_database import db from models.model import EndUser, MessageFile @@ -469,7 +470,7 @@ class ArizePhoenixDataTrace(BaseTraceInstance): llm_attributes[SpanAttributes.LLM_PROVIDER] = trace_info.message_data.model_provider if trace_info.message_data and trace_info.message_data.message_metadata: - metadata_dict = json.loads(trace_info.message_data.message_metadata) + metadata_dict = JSON_DICT_ADAPTER.validate_json(trace_info.message_data.message_metadata) if model_params := metadata_dict.get("model_parameters"): llm_attributes[SpanAttributes.LLM_INVOCATION_PARAMETERS] = json.dumps(model_params) diff --git a/api/core/ops/mlflow_trace/mlflow_trace.py b/api/core/ops/mlflow_trace/mlflow_trace.py index 946d3cdd47..3d8c1dd038 100644 --- a/api/core/ops/mlflow_trace/mlflow_trace.py +++ b/api/core/ops/mlflow_trace/mlflow_trace.py @@ -1,4 +1,3 @@ -import json import logging import os from datetime import datetime, timedelta @@ -25,6 +24,7 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) +from core.ops.utils import JSON_DICT_ADAPTER from extensions.ext_database import db from models import EndUser from models.workflow import WorkflowNodeExecutionModel @@ -153,7 +153,7 @@ class MLflowDataTrace(BaseTraceInstance): inputs = node.process_data # contains request URL if not inputs: - inputs = json.loads(node.inputs) if node.inputs else {} + inputs = JSON_DICT_ADAPTER.validate_json(node.inputs) if node.inputs else {} node_span = start_span_no_context( name=node.title, @@ -180,7 +180,7 @@ class MLflowDataTrace(BaseTraceInstance): # End node span finished_at = node.created_at + timedelta(seconds=node.elapsed_time) - outputs = json.loads(node.outputs) if node.outputs else {} + outputs = JSON_DICT_ADAPTER.validate_json(node.outputs) if node.outputs else {} if node.node_type == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: outputs = self._parse_knowledge_retrieval_outputs(outputs) elif node.node_type == BuiltinNodeTypes.LLM: @@ -216,8 +216,8 @@ class MLflowDataTrace(BaseTraceInstance): return {}, {} try: - data = json.loads(node.process_data) - except (json.JSONDecodeError, TypeError): + data = JSON_DICT_ADAPTER.validate_json(node.process_data) + except (ValueError, TypeError): return {}, {} inputs = self._parse_prompts(data.get("prompts")) diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index 9c36d57c6f..c689a86614 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -11,8 +11,10 @@ from uuid import UUID, uuid4 from cachetools import LRUCache from flask import current_app +from pydantic import TypeAdapter from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker +from typing_extensions import TypedDict from core.helper.encrypter import batch_decrypt_token, encrypt_token, obfuscated_token from core.ops.entities.config_entity import ( @@ -33,7 +35,7 @@ from core.ops.entities.trace_entity import ( WorkflowNodeTraceInfo, WorkflowTraceInfo, ) -from core.ops.utils import get_message_data +from core.ops.utils import JSON_DICT_ADAPTER, get_message_data from extensions.ext_database import db from extensions.ext_storage import storage from models.account import Tenant @@ -50,6 +52,14 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +class _AppTracingConfig(TypedDict, total=False): + enabled: bool + tracing_provider: str | None + + +_app_tracing_config_adapter: TypeAdapter[_AppTracingConfig] = TypeAdapter(_AppTracingConfig) + + def _lookup_app_and_workspace_names(app_id: str | None, tenant_id: str | None) -> tuple[str, str]: """Return (app_name, workspace_name) for the given IDs. Falls back to empty strings.""" app_name = "" @@ -468,7 +478,7 @@ class OpsTraceManager: if app is None: return None - app_ops_trace_config = json.loads(app.tracing) if app.tracing else None + app_ops_trace_config = _app_tracing_config_adapter.validate_json(app.tracing) if app.tracing else None if app_ops_trace_config is None: return None if not app_ops_trace_config.get("enabled"): @@ -560,7 +570,7 @@ class OpsTraceManager: raise ValueError("App not found") if not app.tracing: return {"enabled": False, "tracing_provider": None} - app_trace_config = json.loads(app.tracing) + app_trace_config = _app_tracing_config_adapter.validate_json(app.tracing) return app_trace_config @staticmethod @@ -636,7 +646,6 @@ class TraceTask: carries ``total_tokens``. Projects only the ``outputs`` column to avoid loading large JSON blobs unnecessarily. """ - import json from models.workflow import WorkflowNodeExecutionModel @@ -658,7 +667,7 @@ class TraceTask: if not raw: continue try: - outputs = json.loads(raw) if isinstance(raw, str) else raw + outputs = JSON_DICT_ADAPTER.validate_json(raw) if isinstance(raw, str) else raw except (ValueError, TypeError): continue if not isinstance(outputs, dict): @@ -1420,7 +1429,7 @@ class TraceTask: return {} try: - metadata = json.loads(message_data.message_metadata) + metadata = JSON_DICT_ADAPTER.validate_json(message_data.message_metadata) usage = metadata.get("usage", {}) time_to_first_token = usage.get("time_to_first_token") time_to_generate = usage.get("time_to_generate") @@ -1430,7 +1439,7 @@ class TraceTask: "llm_streaming_time_to_generate": time_to_generate, "is_streaming_request": time_to_first_token is not None, } - except (json.JSONDecodeError, AttributeError): + except (ValueError, AttributeError): return {} diff --git a/api/core/ops/utils.py b/api/core/ops/utils.py index 8b9a2e424a..a6f10c09ac 100644 --- a/api/core/ops/utils.py +++ b/api/core/ops/utils.py @@ -3,11 +3,14 @@ from datetime import datetime from typing import Any, Union from urllib.parse import urlparse +from pydantic import TypeAdapter from sqlalchemy import select from models.engine import db from models.model import Message +JSON_DICT_ADAPTER: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any]) + def filter_none_values(data: dict[str, Any]) -> dict[str, Any]: new_data = {} diff --git a/api/core/rag/datasource/vdb/alibabacloud_mysql/alibabacloud_mysql_vector.py b/api/core/rag/datasource/vdb/alibabacloud_mysql/alibabacloud_mysql_vector.py index fdb5ffebfc..6e76827a42 100644 --- a/api/core/rag/datasource/vdb/alibabacloud_mysql/alibabacloud_mysql_vector.py +++ b/api/core/rag/datasource/vdb/alibabacloud_mysql/alibabacloud_mysql_vector.py @@ -10,6 +10,7 @@ from mysql.connector import Error as MySQLError from pydantic import BaseModel, model_validator from configs import dify_config +from core.rag.datasource.vdb.field import parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -178,9 +179,7 @@ class AlibabaCloudMySQLVector(BaseVector): cur.execute(f"SELECT meta, text FROM {self.table_name} WHERE id IN ({placeholders})", ids) docs = [] for record in cur: - metadata = record["meta"] - if isinstance(metadata, str): - metadata = json.loads(metadata) + metadata = parse_metadata_json(record["meta"]) docs.append(Document(page_content=record["text"], metadata=metadata)) return docs @@ -263,15 +262,13 @@ class AlibabaCloudMySQLVector(BaseVector): # similarity = 1 / (1 + distance) similarity = 1.0 / (1.0 + distance) - metadata = record["meta"] - if isinstance(metadata, str): - metadata = json.loads(metadata) + metadata = parse_metadata_json(record["meta"]) metadata["score"] = similarity metadata["distance"] = distance if similarity >= score_threshold: docs.append(Document(page_content=record["text"], metadata=metadata)) - except (ValueError, json.JSONDecodeError) as e: + except (ValueError, TypeError) as e: logger.warning("Error processing search result: %s", e) continue @@ -306,9 +303,7 @@ class AlibabaCloudMySQLVector(BaseVector): ) docs = [] for record in cur: - metadata = record["meta"] - if isinstance(metadata, str): - metadata = json.loads(metadata) + metadata = parse_metadata_json(record["meta"]) metadata["score"] = float(record["score"]) docs.append(Document(page_content=record["text"], metadata=metadata)) return docs diff --git a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py b/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py index 702200e0ac..ce626bbd7e 100644 --- a/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py +++ b/api/core/rag/datasource/vdb/analyticdb/analyticdb_vector_openapi.py @@ -8,6 +8,7 @@ _import_err_msg = ( "please run `pip install alibabacloud_gpdb20160503 alibabacloud_tea_openapi`" ) +from core.rag.datasource.vdb.field import parse_metadata_json from core.rag.models.document import Document from extensions.ext_redis import redis_client @@ -257,7 +258,7 @@ class AnalyticdbVectorOpenAPI: documents = [] for match in response.body.matches.match: if match.score >= score_threshold: - metadata = json.loads(match.metadata.get("metadata_")) + metadata = parse_metadata_json(match.metadata.get("metadata_")) metadata["score"] = match.score doc = Document( page_content=match.metadata.get("page_content"), @@ -294,7 +295,7 @@ class AnalyticdbVectorOpenAPI: documents = [] for match in response.body.matches.match: if match.score >= score_threshold: - metadata = json.loads(match.metadata.get("metadata_")) + metadata = parse_metadata_json(match.metadata.get("metadata_")) metadata["score"] = match.score doc = Document( page_content=match.metadata.get("page_content"), diff --git a/api/core/rag/datasource/vdb/baidu/baidu_vector.py b/api/core/rag/datasource/vdb/baidu/baidu_vector.py index 9f5842e449..3173920c9c 100644 --- a/api/core/rag/datasource/vdb/baidu/baidu_vector.py +++ b/api/core/rag/datasource/vdb/baidu/baidu_vector.py @@ -29,6 +29,7 @@ from pymochow.model.table import AnnSearch, BM25SearchRequest, HNSWSearchParams, from configs import dify_config from core.rag.datasource.vdb.field import Field as VDBField +from core.rag.datasource.vdb.field import parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -173,15 +174,9 @@ class BaiduVector(BaseVector): score = row.get("score", 0.0) meta = row_data.get(VDBField.METADATA_KEY, {}) - # Handle both JSON string and dict formats for backward compatibility - if isinstance(meta, str): - try: - import json - - meta = json.loads(meta) - except (json.JSONDecodeError, TypeError): - meta = {} - elif not isinstance(meta, dict): + try: + meta = parse_metadata_json(meta) + except (ValueError, TypeError): meta = {} if score >= score_threshold: diff --git a/api/core/rag/datasource/vdb/clickzetta/clickzetta_vector.py b/api/core/rag/datasource/vdb/clickzetta/clickzetta_vector.py index 8e8120fc10..a4dddc68f0 100644 --- a/api/core/rag/datasource/vdb/clickzetta/clickzetta_vector.py +++ b/api/core/rag/datasource/vdb/clickzetta/clickzetta_vector.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from clickzetta.connector.v0.connection import Connection # type: ignore from configs import dify_config -from core.rag.datasource.vdb.field import Field +from core.rag.datasource.vdb.field import Field, parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.embedding.embedding_base import Embeddings @@ -357,18 +357,19 @@ class ClickzettaVector(BaseVector): """ try: if raw_metadata: - metadata = json.loads(raw_metadata) + # First parse may yield a string (double-encoded JSON) so use json.loads + first_pass = json.loads(raw_metadata) # Handle double-encoded JSON - if isinstance(metadata, str): - metadata = json.loads(metadata) - - # Ensure we have a dict - if not isinstance(metadata, dict): + if isinstance(first_pass, str): + metadata = parse_metadata_json(first_pass) + elif isinstance(first_pass, dict): + metadata = first_pass + else: metadata = {} else: metadata = {} - except (json.JSONDecodeError, TypeError): + except (json.JSONDecodeError, ValueError, TypeError): logger.exception("JSON parsing failed for metadata") # Fallback: extract document_id with regex doc_id_match = re.search(r'"document_id":\s*"([^"]+)"', raw_metadata or "") @@ -930,17 +931,18 @@ class ClickzettaVector(BaseVector): # Parse metadata from JSON string (may be double-encoded) try: if row[2]: - metadata = json.loads(row[2]) + # First parse may yield a string (double-encoded JSON) + first_pass = json.loads(row[2]) - # If result is a string, it's double-encoded JSON - parse again - if isinstance(metadata, str): - metadata = json.loads(metadata) - - if not isinstance(metadata, dict): + if isinstance(first_pass, str): + metadata = parse_metadata_json(first_pass) + elif isinstance(first_pass, dict): + metadata = first_pass + else: metadata = {} else: metadata = {} - except (json.JSONDecodeError, TypeError): + except (json.JSONDecodeError, ValueError, TypeError): logger.exception("JSON parsing failed") # Fallback: extract document_id with regex diff --git a/api/core/rag/datasource/vdb/field.py b/api/core/rag/datasource/vdb/field.py index 8fc94be360..5a0fabc572 100644 --- a/api/core/rag/datasource/vdb/field.py +++ b/api/core/rag/datasource/vdb/field.py @@ -1,4 +1,24 @@ from enum import StrEnum, auto +from typing import Any + +from pydantic import TypeAdapter + +_metadata_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any]) + + +def parse_metadata_json(raw: Any) -> dict[str, Any]: + """Parse metadata from a JSON string or pass through an existing dict. + + Many VDB drivers return metadata as either a JSON string or an already- + decoded dict depending on the column type and driver version. + """ + if raw is None or raw in ("", b""): + return {} + if isinstance(raw, dict): + return raw + if not isinstance(raw, (str, bytes, bytearray)): + return {} + return _metadata_adapter.validate_json(raw) class Field(StrEnum): diff --git a/api/core/rag/datasource/vdb/hologres/hologres_vector.py b/api/core/rag/datasource/vdb/hologres/hologres_vector.py index 36b259e494..13d48b5668 100644 --- a/api/core/rag/datasource/vdb/hologres/hologres_vector.py +++ b/api/core/rag/datasource/vdb/hologres/hologres_vector.py @@ -9,6 +9,7 @@ from psycopg import sql as psql from pydantic import BaseModel, model_validator from configs import dify_config +from core.rag.datasource.vdb.field import parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -217,8 +218,7 @@ class HologresVector(BaseVector): text = row[2] meta = row[3] - if isinstance(meta, str): - meta = json.loads(meta) + meta = parse_metadata_json(meta) # Convert distance to similarity score (consistent with pgvector) score = 1 - distance @@ -265,8 +265,7 @@ class HologresVector(BaseVector): meta = row[2] score = row[-1] # score is the last column from return_score - if isinstance(meta, str): - meta = json.loads(meta) + meta = parse_metadata_json(meta) meta["score"] = score docs.append(Document(page_content=text, metadata=meta)) diff --git a/api/core/rag/datasource/vdb/iris/iris_vector.py b/api/core/rag/datasource/vdb/iris/iris_vector.py index 50bb2429ec..aae445e6ff 100644 --- a/api/core/rag/datasource/vdb/iris/iris_vector.py +++ b/api/core/rag/datasource/vdb/iris/iris_vector.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any from configs import dify_config from configs.middleware.vdb.iris_config import IrisVectorConfig +from core.rag.datasource.vdb.field import parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -269,7 +270,7 @@ class IrisVector(BaseVector): if len(row) >= 4: text, meta_str, score = row[1], row[2], float(row[3]) if score >= score_threshold: - metadata = json.loads(meta_str) if meta_str else {} + metadata = parse_metadata_json(meta_str) metadata["score"] = score docs.append(Document(page_content=text, metadata=metadata)) return docs @@ -384,7 +385,7 @@ class IrisVector(BaseVector): meta_str = row[2] score_value = row[3] - metadata = json.loads(meta_str) if meta_str else {} + metadata = parse_metadata_json(meta_str) # Add score to metadata for hybrid search compatibility score = float(score_value) if score_value is not None else 0.0 metadata["score"] = score diff --git a/api/core/rag/datasource/vdb/matrixone/matrixone_vector.py b/api/core/rag/datasource/vdb/matrixone/matrixone_vector.py index 14955c8d7c..09ef498715 100644 --- a/api/core/rag/datasource/vdb/matrixone/matrixone_vector.py +++ b/api/core/rag/datasource/vdb/matrixone/matrixone_vector.py @@ -9,6 +9,7 @@ from mo_vector.client import MoVectorClient # type: ignore from pydantic import BaseModel, model_validator from configs import dify_config +from core.rag.datasource.vdb.field import parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -196,11 +197,7 @@ class MatrixoneVector(BaseVector): docs = [] for result in results: - metadata = result.metadata - if isinstance(metadata, str): - import json - - metadata = json.loads(metadata) + metadata = parse_metadata_json(result.metadata) score = 1 - result.distance if score >= score_threshold: metadata["score"] = score diff --git a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py b/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py index 86c1e65f47..82f419871c 100644 --- a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py +++ b/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py @@ -10,6 +10,7 @@ from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.exc import SQLAlchemyError from configs import dify_config +from core.rag.datasource.vdb.field import parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -366,8 +367,8 @@ class OceanBaseVector(BaseVector): # Parse metadata JSON try: - metadata = json.loads(metadata_str) if isinstance(metadata_str, str) else metadata_str - except json.JSONDecodeError: + metadata = parse_metadata_json(metadata_str) + except (ValueError, TypeError): logger.warning("Invalid JSON metadata: %s", metadata_str) metadata = {} diff --git a/api/core/rag/datasource/vdb/tablestore/tablestore_vector.py b/api/core/rag/datasource/vdb/tablestore/tablestore_vector.py index f2156afa59..4a734232ec 100644 --- a/api/core/rag/datasource/vdb/tablestore/tablestore_vector.py +++ b/api/core/rag/datasource/vdb/tablestore/tablestore_vector.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, model_validator from tablestore import BatchGetRowRequest, TableInBatchGetRowItem from configs import dify_config -from core.rag.datasource.vdb.field import Field +from core.rag.datasource.vdb.field import Field, parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -73,7 +73,8 @@ class TableStoreVector(BaseVector): for item in table_result: if item.is_ok and item.row: kv = {k: v for k, v, _ in item.row.attribute_columns} - docs.append(Document(page_content=kv[Field.CONTENT_KEY], metadata=json.loads(kv[Field.METADATA_KEY]))) + metadata = parse_metadata_json(kv[Field.METADATA_KEY]) + docs.append(Document(page_content=kv[Field.CONTENT_KEY], metadata=metadata)) return docs def get_type(self) -> str: @@ -311,7 +312,7 @@ class TableStoreVector(BaseVector): metadata_str = ots_column_map.get(Field.METADATA_KEY) vector = json.loads(vector_str) if vector_str else None - metadata = json.loads(metadata_str) if metadata_str else {} + metadata = parse_metadata_json(metadata_str) metadata["score"] = search_hit.score @@ -371,7 +372,7 @@ class TableStoreVector(BaseVector): ots_column_map[col[0]] = col[1] metadata_str = ots_column_map.get(Field.METADATA_KEY) - metadata = json.loads(metadata_str) if metadata_str else {} + metadata = parse_metadata_json(metadata_str) vector_str = ots_column_map.get(Field.VECTOR) vector = json.loads(vector_str) if vector_str else None diff --git a/api/core/rag/datasource/vdb/tencent/tencent_vector.py b/api/core/rag/datasource/vdb/tencent/tencent_vector.py index 291d047c04..829db9db20 100644 --- a/api/core/rag/datasource/vdb/tencent/tencent_vector.py +++ b/api/core/rag/datasource/vdb/tencent/tencent_vector.py @@ -11,6 +11,7 @@ from tcvectordb.model import index as vdb_index # type: ignore from tcvectordb.model.document import AnnSearch, Filter, KeywordSearch, WeightedRerank # type: ignore from configs import dify_config +from core.rag.datasource.vdb.field import parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -286,13 +287,10 @@ class TencentVector(BaseVector): return docs for result in res[0]: - meta = result.get(self.field_metadata) - if isinstance(meta, str): - # Compatible with version 1.1.3 and below. - meta = json.loads(meta) - score = 1 - result.get("score", 0.0) - else: - score = result.get("score", 0.0) + raw_meta = result.get(self.field_metadata) + # Compatible with version 1.1.3 and below: str means old driver. + score = (1 - result.get("score", 0.0)) if isinstance(raw_meta, str) else result.get("score", 0.0) + meta = parse_metadata_json(raw_meta) if score >= score_threshold: meta["score"] = score doc = Document(page_content=result.get(self.field_text), metadata=meta) diff --git a/api/core/rag/datasource/vdb/tidb_vector/tidb_vector.py b/api/core/rag/datasource/vdb/tidb_vector/tidb_vector.py index 27ae038a06..c948917374 100644 --- a/api/core/rag/datasource/vdb/tidb_vector/tidb_vector.py +++ b/api/core/rag/datasource/vdb/tidb_vector/tidb_vector.py @@ -9,7 +9,7 @@ from sqlalchemy import text as sql_text from sqlalchemy.orm import Session, declarative_base from configs import dify_config -from core.rag.datasource.vdb.field import Field +from core.rag.datasource.vdb.field import Field, parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -228,7 +228,7 @@ class TiDBVector(BaseVector): ) results = [(row[0], row[1], row[2]) for row in res] for meta, text, distance in results: - metadata = json.loads(meta) + metadata = parse_metadata_json(meta) metadata["score"] = 1 - distance docs.append(Document(page_content=text, metadata=metadata)) return docs diff --git a/api/core/rag/datasource/vdb/vikingdb/vikingdb_vector.py b/api/core/rag/datasource/vdb/vikingdb/vikingdb_vector.py index e5feecf2bc..83fd3626d9 100644 --- a/api/core/rag/datasource/vdb/vikingdb/vikingdb_vector.py +++ b/api/core/rag/datasource/vdb/vikingdb/vikingdb_vector.py @@ -15,6 +15,7 @@ from volcengine.viking_db import ( # type: ignore from configs import dify_config from core.rag.datasource.vdb.field import Field as vdb_Field +from core.rag.datasource.vdb.field import parse_metadata_json from core.rag.datasource.vdb.vector_base import BaseVector from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory from core.rag.datasource.vdb.vector_type import VectorType @@ -163,7 +164,7 @@ class VikingDBVector(BaseVector): for result in results: metadata = result.fields.get(vdb_Field.METADATA_KEY) if metadata is not None: - metadata = json.loads(metadata) + metadata = parse_metadata_json(metadata) if metadata.get(key) == value: ids.append(result.id) return ids @@ -189,9 +190,7 @@ class VikingDBVector(BaseVector): docs = [] for result in results: - metadata = result.fields.get(vdb_Field.METADATA_KEY) - if metadata is not None: - metadata = json.loads(metadata) + metadata = parse_metadata_json(result.fields.get(vdb_Field.METADATA_KEY)) if result.score >= score_threshold: metadata["score"] = result.score doc = Document(page_content=result.fields.get(vdb_Field.CONTENT_KEY), metadata=metadata) diff --git a/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py b/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py index b725436681..0e9a19b821 100644 --- a/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py +++ b/api/extensions/logstore/repositories/logstore_workflow_node_execution_repository.py @@ -20,6 +20,7 @@ from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter from sqlalchemy.engine import Engine from sqlalchemy.orm import sessionmaker +from core.ops.utils import JSON_DICT_ADAPTER from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository from core.repositories.factory import OrderConfig, WorkflowNodeExecutionRepository from extensions.logstore.aliyun_logstore import AliyunLogStore @@ -48,10 +49,10 @@ def _dict_to_workflow_node_execution(data: dict[str, Any]) -> WorkflowNodeExecut """ logger.debug("_dict_to_workflow_node_execution: data keys=%s", list(data.keys())[:5]) # Parse JSON fields - inputs = json.loads(data.get("inputs", "{}")) - process_data = json.loads(data.get("process_data", "{}")) - outputs = json.loads(data.get("outputs", "{}")) - metadata = json.loads(data.get("execution_metadata", "{}")) + inputs = JSON_DICT_ADAPTER.validate_json(data.get("inputs") or "{}") + process_data = JSON_DICT_ADAPTER.validate_json(data.get("process_data") or "{}") + outputs = JSON_DICT_ADAPTER.validate_json(data.get("outputs") or "{}") + metadata = JSON_DICT_ADAPTER.validate_json(data.get("execution_metadata") or "{}") # Convert metadata to domain enum keys domain_metadata = {} diff --git a/api/extensions/storage/clickzetta_volume/file_lifecycle.py b/api/extensions/storage/clickzetta_volume/file_lifecycle.py index 1d9911465b..483bd6bbf6 100644 --- a/api/extensions/storage/clickzetta_volume/file_lifecycle.py +++ b/api/extensions/storage/clickzetta_volume/file_lifecycle.py @@ -15,8 +15,12 @@ from datetime import datetime from enum import StrEnum, auto from typing import Any +from pydantic import TypeAdapter + logger = logging.getLogger(__name__) +_metadata_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any]) + class FileStatus(StrEnum): """File status enumeration""" @@ -455,8 +459,8 @@ class FileLifecycleManager: try: if self._storage.exists(self._metadata_file): metadata_content = self._storage.load_once(self._metadata_file) - result = json.loads(metadata_content.decode("utf-8")) - return dict(result) if result else {} + result = _metadata_adapter.validate_json(metadata_content) + return result or {} else: return {} except Exception as e: diff --git a/api/extensions/storage/google_cloud_storage.py b/api/extensions/storage/google_cloud_storage.py index 4ad7e2d159..00f7289aa4 100644 --- a/api/extensions/storage/google_cloud_storage.py +++ b/api/extensions/storage/google_cloud_storage.py @@ -1,13 +1,16 @@ import base64 import io -import json from collections.abc import Generator +from typing import Any from google.cloud import storage as google_cloud_storage # type: ignore +from pydantic import TypeAdapter from configs import dify_config from extensions.storage.base_storage import BaseStorage +_service_account_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any]) + class GoogleCloudStorage(BaseStorage): """Implementation for Google Cloud storage.""" @@ -21,7 +24,7 @@ class GoogleCloudStorage(BaseStorage): if service_account_json_str: service_account_json = base64.b64decode(service_account_json_str).decode("utf-8") # convert str to object - service_account_obj = json.loads(service_account_json) + service_account_obj = _service_account_adapter.validate_json(service_account_json) self.client = google_cloud_storage.Client.from_service_account_info(service_account_obj) else: self.client = google_cloud_storage.Client() diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/test_field.py b/api/tests/unit_tests/core/rag/datasource/vdb/test_field.py new file mode 100644 index 0000000000..d68c93b021 --- /dev/null +++ b/api/tests/unit_tests/core/rag/datasource/vdb/test_field.py @@ -0,0 +1,45 @@ +import pytest + +from core.rag.datasource.vdb.field import parse_metadata_json + + +class TestParseMetadataJson: + def test_none_returns_empty_dict(self): + assert parse_metadata_json(None) == {} + + def test_empty_string_returns_empty_dict(self): + assert parse_metadata_json("") == {} + + def test_valid_json_string(self): + result = parse_metadata_json('{"doc_id": "abc", "score": 0.9}') + assert result == {"doc_id": "abc", "score": 0.9} + + def test_dict_passthrough(self): + original = {"doc_id": "abc", "document_id": "123"} + result = parse_metadata_json(original) + assert result == original + + def test_empty_json_object(self): + assert parse_metadata_json("{}") == {} + + def test_invalid_json_raises_value_error(self): + with pytest.raises(ValueError): + parse_metadata_json("{invalid json") + + def test_nested_metadata(self): + result = parse_metadata_json('{"doc_id": "1", "extra": {"nested": true}}') + assert result["extra"]["nested"] is True + + def test_non_str_non_dict_returns_empty_dict(self): + assert parse_metadata_json(123) == {} + assert parse_metadata_json([1, 2]) == {} + + def test_bytes_input(self): + result = parse_metadata_json(b'{"key": "value"}') + assert result == {"key": "value"} + + def test_empty_bytes_returns_empty_dict(self): + assert parse_metadata_json(b"") == {} + + def test_empty_bytearray_returns_empty_dict(self): + assert parse_metadata_json(bytearray(b"")) == {} From b23ea0397a756d7b6f267c5789a292eabbb1c502 Mon Sep 17 00:00:00 2001 From: jimmyzhuu Date: Wed, 1 Apr 2026 14:16:09 +0800 Subject: [PATCH 22/30] fix: apply Baidu Vector DB connection timeout when initializing Mochow client (#34328) --- api/core/rag/datasource/vdb/baidu/baidu_vector.py | 6 +++++- .../rag/datasource/vdb/baidu/test_baidu_vector.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/api/core/rag/datasource/vdb/baidu/baidu_vector.py b/api/core/rag/datasource/vdb/baidu/baidu_vector.py index 3173920c9c..2b220fc04d 100644 --- a/api/core/rag/datasource/vdb/baidu/baidu_vector.py +++ b/api/core/rag/datasource/vdb/baidu/baidu_vector.py @@ -195,7 +195,11 @@ class BaiduVector(BaseVector): raise def _init_client(self, config) -> MochowClient: - config = Configuration(credentials=BceCredentials(config.account, config.api_key), endpoint=config.endpoint) + config = Configuration( + credentials=BceCredentials(config.account, config.api_key), + endpoint=config.endpoint, + connection_timeout_in_mills=config.connection_timeout_in_mills, + ) client = MochowClient(config) return client diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/baidu/test_baidu_vector.py b/api/tests/unit_tests/core/rag/datasource/vdb/baidu/test_baidu_vector.py index c46c3d5e4b..487d021697 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/baidu/test_baidu_vector.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/baidu/test_baidu_vector.py @@ -381,13 +381,22 @@ def test_init_client_constructs_configuration_and_client(baidu_module, monkeypat monkeypatch.setattr(baidu_module, "MochowClient", client_cls) vector = baidu_module.BaiduVector.__new__(baidu_module.BaiduVector) - config = SimpleNamespace(account="account", api_key="key", endpoint="https://endpoint") + config = SimpleNamespace( + account="account", + api_key="key", + endpoint="https://endpoint", + connection_timeout_in_mills=12_345, + ) client = vector._init_client(config) assert client == "client" credentials.assert_called_once_with("account", "key") - configuration.assert_called_once_with(credentials="credentials", endpoint="https://endpoint") + configuration.assert_called_once_with( + credentials="credentials", + endpoint="https://endpoint", + connection_timeout_in_mills=12_345, + ) client_cls.assert_called_once_with("configuration") From 31f7752ba9479e69753867cab8e3feafe7c101eb Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:03:49 +0200 Subject: [PATCH 23/30] refactor: select in 10 service files (#34373) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Asuka Minato --- api/services/agent_service.py | 24 +++---- api/services/api_based_extension_service.py | 48 ++++++++------ api/services/app_service.py | 9 ++- api/services/feedback_service.py | 22 +++---- .../rag_pipeline_transform_service.py | 7 +- api/services/recommended_app_service.py | 12 ++-- api/services/saved_message_service.py | 21 +++--- .../tools/builtin_tools_manage_service.py | 9 ++- api/services/vector_service.py | 15 ++--- api/services/workflow_service.py | 24 +++---- .../services/test_feedback_service.py | 21 +++--- .../services/test_vector_service.py | 47 ++++++-------- .../services/test_workflow_service.py | 64 +++++++------------ .../test_builtin_tools_manage_service.py | 4 +- 14 files changed, 147 insertions(+), 180 deletions(-) diff --git a/api/services/agent_service.py b/api/services/agent_service.py index 2b8a3ee594..d8f4e11e75 100644 --- a/api/services/agent_service.py +++ b/api/services/agent_service.py @@ -2,6 +2,7 @@ import threading from typing import Any import pytz +from sqlalchemy import select import contexts from core.app.app_config.easy_ui_based_app.agent.manager import AgentConfigManager @@ -23,25 +24,25 @@ class AgentService: contexts.plugin_tool_providers.set({}) contexts.plugin_tool_providers_lock.set(threading.Lock()) - conversation: Conversation | None = ( - db.session.query(Conversation) + conversation: Conversation | None = db.session.scalar( + select(Conversation) .where( Conversation.id == conversation_id, Conversation.app_id == app_model.id, ) - .first() + .limit(1) ) if not conversation: raise ValueError(f"Conversation not found: {conversation_id}") - message: Message | None = ( - db.session.query(Message) + message: Message | None = db.session.scalar( + select(Message) .where( Message.id == message_id, Message.conversation_id == conversation_id, ) - .first() + .limit(1) ) if not message: @@ -51,16 +52,11 @@ class AgentService: if conversation.from_end_user_id: # only select name field - executor = ( - db.session.query(EndUser, EndUser.name).where(EndUser.id == conversation.from_end_user_id).first() - ) + executor_name = db.session.scalar(select(EndUser.name).where(EndUser.id == conversation.from_end_user_id)) else: - executor = db.session.query(Account, Account.name).where(Account.id == conversation.from_account_id).first() + executor_name = db.session.scalar(select(Account.name).where(Account.id == conversation.from_account_id)) - if executor: - executor = executor.name - else: - executor = "Unknown" + executor = executor_name or "Unknown" assert isinstance(current_user, Account) assert current_user.timezone is not None timezone = pytz.timezone(current_user.timezone) diff --git a/api/services/api_based_extension_service.py b/api/services/api_based_extension_service.py index 3a0ed41be0..fdb377694b 100644 --- a/api/services/api_based_extension_service.py +++ b/api/services/api_based_extension_service.py @@ -1,3 +1,5 @@ +from sqlalchemy import select + from core.extension.api_based_extension_requestor import APIBasedExtensionRequestor from core.helper.encrypter import decrypt_token, encrypt_token from extensions.ext_database import db @@ -7,11 +9,12 @@ from models.api_based_extension import APIBasedExtension, APIBasedExtensionPoint class APIBasedExtensionService: @staticmethod def get_all_by_tenant_id(tenant_id: str) -> list[APIBasedExtension]: - extension_list = ( - db.session.query(APIBasedExtension) - .filter_by(tenant_id=tenant_id) - .order_by(APIBasedExtension.created_at.desc()) - .all() + extension_list = list( + db.session.scalars( + select(APIBasedExtension) + .where(APIBasedExtension.tenant_id == tenant_id) + .order_by(APIBasedExtension.created_at.desc()) + ).all() ) for extension in extension_list: @@ -36,11 +39,10 @@ class APIBasedExtensionService: @staticmethod def get_with_tenant_id(tenant_id: str, api_based_extension_id: str) -> APIBasedExtension: - extension = ( - db.session.query(APIBasedExtension) - .filter_by(tenant_id=tenant_id) - .filter_by(id=api_based_extension_id) - .first() + extension = db.session.scalar( + select(APIBasedExtension) + .where(APIBasedExtension.tenant_id == tenant_id, APIBasedExtension.id == api_based_extension_id) + .limit(1) ) if not extension: @@ -58,23 +60,27 @@ class APIBasedExtensionService: if not extension_data.id: # case one: check new data, name must be unique - is_name_existed = ( - db.session.query(APIBasedExtension) - .filter_by(tenant_id=extension_data.tenant_id) - .filter_by(name=extension_data.name) - .first() + is_name_existed = db.session.scalar( + select(APIBasedExtension) + .where( + APIBasedExtension.tenant_id == extension_data.tenant_id, + APIBasedExtension.name == extension_data.name, + ) + .limit(1) ) if is_name_existed: raise ValueError("name must be unique, it is already existed") else: # case two: check existing data, name must be unique - is_name_existed = ( - db.session.query(APIBasedExtension) - .filter_by(tenant_id=extension_data.tenant_id) - .filter_by(name=extension_data.name) - .where(APIBasedExtension.id != extension_data.id) - .first() + is_name_existed = db.session.scalar( + select(APIBasedExtension) + .where( + APIBasedExtension.tenant_id == extension_data.tenant_id, + APIBasedExtension.name == extension_data.name, + APIBasedExtension.id != extension_data.id, + ) + .limit(1) ) if is_name_existed: diff --git a/api/services/app_service.py b/api/services/app_service.py index e9aeb6c43d..87d52a3159 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -6,6 +6,7 @@ import sqlalchemy as sa from flask_sqlalchemy.pagination import Pagination from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel +from sqlalchemy import select from configs import dify_config from constants.model_template import default_app_templates @@ -433,9 +434,7 @@ class AppService: meta["tool_icons"][tool_name] = url_prefix + provider_id + "/icon" elif provider_type == "api": try: - provider: ApiToolProvider | None = ( - db.session.query(ApiToolProvider).where(ApiToolProvider.id == provider_id).first() - ) + provider: ApiToolProvider | None = db.session.get(ApiToolProvider, provider_id) if provider is None: raise ValueError(f"provider not found for tool {tool_name}") meta["tool_icons"][tool_name] = json.loads(provider.icon) @@ -451,7 +450,7 @@ class AppService: :param app_id: app id :return: app code """ - site = db.session.query(Site).where(Site.app_id == app_id).first() + site = db.session.scalar(select(Site).where(Site.app_id == app_id).limit(1)) if not site: raise ValueError(f"App with id {app_id} not found") return str(site.code) @@ -463,7 +462,7 @@ class AppService: :param app_code: app code :return: app id """ - site = db.session.query(Site).where(Site.code == app_code).first() + site = db.session.scalar(select(Site).where(Site.code == app_code).limit(1)) if not site: raise ValueError(f"App with code {app_code} not found") return str(site.app_id) diff --git a/api/services/feedback_service.py b/api/services/feedback_service.py index e7473d371b..d6c338a830 100644 --- a/api/services/feedback_service.py +++ b/api/services/feedback_service.py @@ -4,7 +4,7 @@ import json from datetime import datetime from flask import Response -from sqlalchemy import or_ +from sqlalchemy import or_, select from extensions.ext_database import db from models.enums import FeedbackRating @@ -41,8 +41,8 @@ class FeedbackService: raise ValueError(f"Unsupported format: {format_type}") # Build base query - query = ( - db.session.query(MessageFeedback, Message, Conversation, App, Account) + stmt = ( + select(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) @@ -52,36 +52,36 @@ class FeedbackService: # Apply filters if from_source: - query = query.filter(MessageFeedback.from_source == from_source) + stmt = stmt.where(MessageFeedback.from_source == from_source) if rating: - query = query.filter(MessageFeedback.rating == rating) + stmt = stmt.where(MessageFeedback.rating == rating) if has_comment is not None: if has_comment: - query = query.filter(MessageFeedback.content.isnot(None), MessageFeedback.content != "") + stmt = stmt.where(MessageFeedback.content.isnot(None), MessageFeedback.content != "") else: - query = query.filter(or_(MessageFeedback.content.is_(None), MessageFeedback.content == "")) + stmt = stmt.where(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) + stmt = stmt.where(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) + stmt = stmt.where(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()) + stmt = stmt.order_by(MessageFeedback.created_at.desc()) # Execute query - results = query.all() + results = db.session.execute(stmt).all() # Prepare data for export export_data = [] diff --git a/api/services/rag_pipeline/rag_pipeline_transform_service.py b/api/services/rag_pipeline/rag_pipeline_transform_service.py index 215a8c8528..c3b00fe109 100644 --- a/api/services/rag_pipeline/rag_pipeline_transform_service.py +++ b/api/services/rag_pipeline/rag_pipeline_transform_service.py @@ -6,6 +6,7 @@ from uuid import uuid4 import yaml from flask_login import current_user +from sqlalchemy import select from constants import DOCUMENT_EXTENSIONS from core.plugin.impl.plugin import PluginInstaller @@ -26,7 +27,7 @@ logger = logging.getLogger(__name__) class RagPipelineTransformService: def transform_dataset(self, dataset_id: str): - dataset = db.session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = db.session.get(Dataset, dataset_id) if not dataset: raise ValueError("Dataset not found") if dataset.pipeline_id and dataset.runtime_mode == DatasetRuntimeMode.RAG_PIPELINE: @@ -306,7 +307,7 @@ class RagPipelineTransformService: jina_node_id = "1752491761974" firecrawl_node_id = "1752565402678" - documents = db.session.query(Document).where(Document.dataset_id == dataset.id).all() + documents = db.session.scalars(select(Document).where(Document.dataset_id == dataset.id)).all() for document in documents: data_source_info_dict = document.data_source_info_dict @@ -316,7 +317,7 @@ class RagPipelineTransformService: document.data_source_type = DataSourceType.LOCAL_FILE file_id = data_source_info_dict.get("upload_file_id") if file_id: - file = db.session.query(UploadFile).where(UploadFile.id == file_id).first() + file = db.session.get(UploadFile, file_id) if file: data_source_info = json.dumps( { diff --git a/api/services/recommended_app_service.py b/api/services/recommended_app_service.py index 6b211a5632..9819822103 100644 --- a/api/services/recommended_app_service.py +++ b/api/services/recommended_app_service.py @@ -1,3 +1,5 @@ +from sqlalchemy import select + from configs import dify_config from extensions.ext_database import db from models.model import AccountTrialAppRecord, TrialApp @@ -27,7 +29,7 @@ class RecommendedAppService: apps = result["recommended_apps"] for app in apps: app_id = app["app_id"] - trial_app_model = db.session.query(TrialApp).where(TrialApp.app_id == app_id).first() + trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) if trial_app_model: app["can_trial"] = True else: @@ -46,7 +48,7 @@ class RecommendedAppService: result: dict = retrieval_instance.get_recommend_app_detail(app_id) if FeatureService.get_system_features().enable_trial_app: app_id = result["id"] - trial_app_model = db.session.query(TrialApp).where(TrialApp.app_id == app_id).first() + trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) if trial_app_model: result["can_trial"] = True else: @@ -60,10 +62,10 @@ class RecommendedAppService: :param app_id: app id :return: """ - account_trial_app_record = ( - db.session.query(AccountTrialAppRecord) + account_trial_app_record = db.session.scalar( + select(AccountTrialAppRecord) .where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id) - .first() + .limit(1) ) if account_trial_app_record: account_trial_app_record.count += 1 diff --git a/api/services/saved_message_service.py b/api/services/saved_message_service.py index d0f4f27968..77d1767c4b 100644 --- a/api/services/saved_message_service.py +++ b/api/services/saved_message_service.py @@ -1,5 +1,7 @@ from typing import Union +from sqlalchemy import select + from extensions.ext_database import db from libs.infinite_scroll_pagination import InfiniteScrollPagination from models import Account @@ -16,16 +18,15 @@ class SavedMessageService: ) -> InfiniteScrollPagination: if not user: raise ValueError("User is required") - saved_messages = ( - db.session.query(SavedMessage) + saved_messages = db.session.scalars( + select(SavedMessage) .where( SavedMessage.app_id == app_model.id, SavedMessage.created_by_role == ("account" if isinstance(user, Account) else "end_user"), SavedMessage.created_by == user.id, ) .order_by(SavedMessage.created_at.desc()) - .all() - ) + ).all() message_ids = [sm.message_id for sm in saved_messages] return MessageService.pagination_by_last_id( @@ -36,15 +37,15 @@ class SavedMessageService: def save(cls, app_model: App, user: Union[Account, EndUser] | None, message_id: str): if not user: return - saved_message = ( - db.session.query(SavedMessage) + saved_message = db.session.scalar( + select(SavedMessage) .where( SavedMessage.app_id == app_model.id, SavedMessage.message_id == message_id, SavedMessage.created_by_role == ("account" if isinstance(user, Account) else "end_user"), SavedMessage.created_by == user.id, ) - .first() + .limit(1) ) if saved_message: @@ -66,15 +67,15 @@ class SavedMessageService: def delete(cls, app_model: App, user: Union[Account, EndUser] | None, message_id: str): if not user: return - saved_message = ( - db.session.query(SavedMessage) + saved_message = db.session.scalar( + select(SavedMessage) .where( SavedMessage.app_id == app_model.id, SavedMessage.message_id == message_id, SavedMessage.created_by_role == ("account" if isinstance(user, Account) else "end_user"), SavedMessage.created_by == user.id, ) - .first() + .limit(1) ) if not saved_message: diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index 8e3c36e099..f7447d3c10 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -332,12 +332,11 @@ class BuiltinToolManageService: get builtin tool provider credentials """ with db.session.no_autoflush: - providers = ( - db.session.query(BuiltinToolProvider) - .filter_by(tenant_id=tenant_id, provider=provider_name) + providers = db.session.scalars( + select(BuiltinToolProvider) + .where(BuiltinToolProvider.tenant_id == tenant_id, BuiltinToolProvider.provider == provider_name) .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) - .all() - ) + ).all() if len(providers) == 0: return [] diff --git a/api/services/vector_service.py b/api/services/vector_service.py index 3f78b823a6..e7266cb8e9 100644 --- a/api/services/vector_service.py +++ b/api/services/vector_service.py @@ -1,6 +1,7 @@ import logging from graphon.model_runtime.entities.model_entities import ModelType +from sqlalchemy import delete, select from core.model_manager import ModelInstance, ModelManager from core.rag.datasource.keyword.keyword_factory import Keyword @@ -29,7 +30,7 @@ class VectorService: for segment in segments: if doc_form == IndexStructureType.PARENT_CHILD_INDEX: - dataset_document = db.session.query(DatasetDocument).filter_by(id=segment.document_id).first() + dataset_document = db.session.get(DatasetDocument, segment.document_id) if not dataset_document: logger.warning( "Expected DatasetDocument record to exist, but none was found, document_id=%s, segment_id=%s", @@ -38,11 +39,7 @@ class VectorService: ) continue # get the process rule - processing_rule = ( - db.session.query(DatasetProcessRule) - .where(DatasetProcessRule.id == dataset_document.dataset_process_rule_id) - .first() - ) + processing_rule = db.session.get(DatasetProcessRule, dataset_document.dataset_process_rule_id) if not processing_rule: raise ValueError("No processing rule found.") # get embedding model instance @@ -271,8 +268,8 @@ class VectorService: vector.delete_by_ids(old_attachment_ids) # Delete existing segment attachment bindings in one operation - db.session.query(SegmentAttachmentBinding).where(SegmentAttachmentBinding.segment_id == segment.id).delete( - synchronize_session=False + db.session.execute( + delete(SegmentAttachmentBinding).where(SegmentAttachmentBinding.segment_id == segment.id) ) if not attachment_ids: @@ -280,7 +277,7 @@ class VectorService: return # Bulk fetch upload files - only fetch needed fields - upload_file_list = db.session.query(UploadFile).where(UploadFile.id.in_(attachment_ids)).all() + upload_file_list = db.session.scalars(select(UploadFile).where(UploadFile.id.in_(attachment_ids))).all() if not upload_file_list: db.session.commit() diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 3b3ee6dd92..8f365c7c51 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -138,14 +138,14 @@ class WorkflowService: if workflow_id: return self.get_published_workflow_by_id(app_model, workflow_id) # fetch draft workflow by app_model - workflow = ( - db.session.query(Workflow) + workflow = db.session.scalar( + select(Workflow) .where( Workflow.tenant_id == app_model.tenant_id, Workflow.app_id == app_model.id, Workflow.version == Workflow.VERSION_DRAFT, ) - .first() + .limit(1) ) # return draft workflow @@ -155,14 +155,14 @@ class WorkflowService: """ fetch published workflow by workflow_id """ - workflow = ( - db.session.query(Workflow) + workflow = db.session.scalar( + select(Workflow) .where( Workflow.tenant_id == app_model.tenant_id, Workflow.app_id == app_model.id, Workflow.id == workflow_id, ) - .first() + .limit(1) ) if not workflow: return None @@ -182,14 +182,14 @@ class WorkflowService: return None # fetch published workflow by workflow_id - workflow = ( - db.session.query(Workflow) + workflow = db.session.scalar( + select(Workflow) .where( Workflow.tenant_id == app_model.tenant_id, Workflow.app_id == app_model.id, Workflow.id == app_model.workflow_id, ) - .first() + .limit(1) ) return workflow @@ -544,14 +544,14 @@ class WorkflowService: # Use the same fallback logic as runtime: get the first available credential # ordered by is_default DESC, created_at ASC (same as tool_manager.py) - default_provider = ( - db.session.query(BuiltinToolProvider) + default_provider = db.session.scalar( + select(BuiltinToolProvider) .where( BuiltinToolProvider.tenant_id == tenant_id, BuiltinToolProvider.provider == provider, ) .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) - .first() + .limit(1) ) if not default_provider: diff --git a/api/tests/test_containers_integration_tests/services/test_feedback_service.py b/api/tests/test_containers_integration_tests/services/test_feedback_service.py index 771f406775..d82933ccb9 100644 --- a/api/tests/test_containers_integration_tests/services/test_feedback_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feedback_service.py @@ -99,7 +99,7 @@ class TestFeedbackService: ) ] - mock_db_session.query.return_value = mock_query + mock_db_session.execute.return_value = mock_query # Test CSV export result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") @@ -138,7 +138,7 @@ class TestFeedbackService: ) ] - mock_db_session.query.return_value = mock_query + mock_db_session.execute.return_value = mock_query # Test JSON export result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") @@ -175,7 +175,7 @@ class TestFeedbackService: ) ] - mock_db_session.query.return_value = mock_query + mock_db_session.execute.return_value = mock_query # Test with filters result = FeedbackService.export_feedbacks( @@ -188,11 +188,8 @@ class TestFeedbackService: 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 + # Verify query was executed (filters are baked into the select statement) + assert mock_db_session.execute.called def test_export_feedbacks_no_data(self, mock_db_session, sample_data): """Test exporting feedback when no data exists.""" @@ -206,7 +203,7 @@ class TestFeedbackService: mock_query.order_by.return_value = mock_query mock_query.all.return_value = [] - mock_db_session.query.return_value = mock_query + mock_db_session.execute.return_value = mock_query result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") @@ -271,7 +268,7 @@ class TestFeedbackService: ) ] - mock_db_session.query.return_value = mock_query + mock_db_session.execute.return_value = mock_query # Test export result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") @@ -329,7 +326,7 @@ class TestFeedbackService: ) ] - mock_db_session.query.return_value = mock_query + mock_db_session.execute.return_value = mock_query # Test export result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") @@ -367,7 +364,7 @@ class TestFeedbackService: ), ] - mock_db_session.query.return_value = mock_query + mock_db_session.execute.return_value = mock_query # Test export result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") diff --git a/api/tests/unit_tests/services/test_vector_service.py b/api/tests/unit_tests/services/test_vector_service.py index 598ff3fc3a..a78a033f4d 100644 --- a/api/tests/unit_tests/services/test_vector_service.py +++ b/api/tests/unit_tests/services/test_vector_service.py @@ -77,22 +77,12 @@ def _make_segment( def _mock_db_session_for_update_multimodel(*, upload_files: list[_UploadFileStub] | None) -> MagicMock: session = MagicMock(name="session") - binding_query = MagicMock(name="binding_query") - binding_query.where.return_value = binding_query - binding_query.delete.return_value = 1 + # db.session.execute() is used for delete(SegmentAttachmentBinding).where(...) + session.execute = MagicMock(name="execute") - upload_query = MagicMock(name="upload_query") - upload_query.where.return_value = upload_query - upload_query.all.return_value = upload_files or [] + # db.session.scalars(select(UploadFile).where(...)).all() returns upload files + session.scalars.return_value.all.return_value = upload_files or [] - def query_side_effect(model: object) -> MagicMock: - if model is vector_service_module.SegmentAttachmentBinding: - return binding_query - if model is vector_service_module.UploadFile: - return upload_query - return MagicMock(name=f"query({model})") - - session.query.side_effect = query_side_effect db_mock = MagicMock(name="db") db_mock.session = session return db_mock @@ -165,22 +155,15 @@ def _mock_parent_child_queries( ) -> MagicMock: session = MagicMock(name="session") - doc_query = MagicMock(name="doc_query") - doc_query.filter_by.return_value = doc_query - doc_query.first.return_value = dataset_document + get_dispatch: dict[object, object | None] = { + vector_service_module.DatasetDocument: dataset_document, + vector_service_module.DatasetProcessRule: processing_rule, + } - rule_query = MagicMock(name="rule_query") - rule_query.where.return_value = rule_query - rule_query.first.return_value = processing_rule + def get_side_effect(model: object, pk: object) -> object | None: + return get_dispatch.get(model) - def query_side_effect(model: object) -> MagicMock: - if model is vector_service_module.DatasetDocument: - return doc_query - if model is vector_service_module.DatasetProcessRule: - return rule_query - return MagicMock(name=f"query({model})") - - session.query.side_effect = query_side_effect + session.get.side_effect = get_side_effect db_mock = MagicMock(name="db") db_mock.session = session return db_mock @@ -609,7 +592,7 @@ def test_update_multimodel_vector_deletes_bindings_and_commits_on_empty_new_ids( vector_cls.assert_called_once_with(dataset=dataset) vector_instance.delete_by_ids.assert_called_once_with(["old-1", "old-2"]) - db_mock.session.query.assert_called_once_with(vector_service_module.SegmentAttachmentBinding) + db_mock.session.execute.assert_called_once() db_mock.session.commit.assert_called_once() db_mock.session.add_all.assert_not_called() vector_instance.add_texts.assert_not_called() @@ -644,6 +627,8 @@ def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_up binding_ctor = MagicMock(side_effect=lambda **kwargs: kwargs) monkeypatch.setattr(vector_service_module, "SegmentAttachmentBinding", binding_ctor) + monkeypatch.setattr(vector_service_module, "delete", MagicMock()) + monkeypatch.setattr(vector_service_module, "select", MagicMock()) logger_mock = MagicMock() monkeypatch.setattr(vector_service_module, "logger", logger_mock) @@ -677,6 +662,8 @@ def test_update_multimodel_vector_updates_bindings_without_multimodal_vector_ops monkeypatch.setattr( vector_service_module, "SegmentAttachmentBinding", MagicMock(side_effect=lambda **kwargs: kwargs) ) + monkeypatch.setattr(vector_service_module, "delete", MagicMock()) + monkeypatch.setattr(vector_service_module, "select", MagicMock()) VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1"], dataset=dataset) @@ -698,6 +685,8 @@ def test_update_multimodel_vector_rolls_back_and_reraises_on_error(monkeypatch: monkeypatch.setattr( vector_service_module, "SegmentAttachmentBinding", MagicMock(side_effect=lambda **kwargs: kwargs) ) + monkeypatch.setattr(vector_service_module, "delete", MagicMock()) + monkeypatch.setattr(vector_service_module, "select", MagicMock()) logger_mock = MagicMock() monkeypatch.setattr(vector_service_module, "logger", logger_mock) diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index cd71981bcf..1b253eb2f1 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -268,7 +268,7 @@ class TestWorkflowService: Provides mock implementations of: - session.add(): Adding new records - session.commit(): Committing transactions - - session.query(): Querying database + - session.scalar(): Scalar queries - session.execute(): Executing SQL statements """ with patch("services.workflow_service.db") as mock_db: @@ -276,7 +276,7 @@ class TestWorkflowService: mock_db.session = mock_session mock_session.add = MagicMock() mock_session.commit = MagicMock() - mock_session.query = MagicMock() + mock_session.scalar = MagicMock() mock_session.execute = MagicMock() yield mock_db @@ -338,10 +338,8 @@ class TestWorkflowService: app = TestWorkflowAssociatedDataFactory.create_app_mock() mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock() - # Mock database query - mock_query = MagicMock() - mock_db_session.session.query.return_value = mock_query - mock_query.where.return_value.first.return_value = mock_workflow + # Mock db.session.scalar() used by get_draft_workflow + mock_db_session.session.scalar.return_value = mock_workflow result = workflow_service.get_draft_workflow(app) @@ -351,10 +349,8 @@ class TestWorkflowService: """Test get_draft_workflow returns None when no draft exists.""" app = TestWorkflowAssociatedDataFactory.create_app_mock() - # Mock database query to return None - mock_query = MagicMock() - mock_db_session.session.query.return_value = mock_query - mock_query.where.return_value.first.return_value = None + # Mock db.session.scalar() to return None + mock_db_session.session.scalar.return_value = None result = workflow_service.get_draft_workflow(app) @@ -366,10 +362,8 @@ class TestWorkflowService: workflow_id = "workflow-123" mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(version="v1") - # Mock database query - mock_query = MagicMock() - mock_db_session.session.query.return_value = mock_query - mock_query.where.return_value.first.return_value = mock_workflow + # Mock db.session.scalar() used by get_published_workflow_by_id + mock_db_session.session.scalar.return_value = mock_workflow result = workflow_service.get_draft_workflow(app, workflow_id=workflow_id) @@ -384,10 +378,8 @@ class TestWorkflowService: workflow_id = "workflow-123" mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1") - # Mock database query - mock_query = MagicMock() - mock_db_session.session.query.return_value = mock_query - mock_query.where.return_value.first.return_value = mock_workflow + # Mock db.session.scalar() used by get_published_workflow_by_id + mock_db_session.session.scalar.return_value = mock_workflow result = workflow_service.get_published_workflow_by_id(app, workflow_id) @@ -406,10 +398,8 @@ class TestWorkflowService: workflow_id=workflow_id, version=Workflow.VERSION_DRAFT ) - # Mock database query - mock_query = MagicMock() - mock_db_session.session.query.return_value = mock_query - mock_query.where.return_value.first.return_value = mock_workflow + # Mock db.session.scalar() used by get_published_workflow_by_id + mock_db_session.session.scalar.return_value = mock_workflow with pytest.raises(IsDraftWorkflowError): workflow_service.get_published_workflow_by_id(app, workflow_id) @@ -419,10 +409,8 @@ class TestWorkflowService: app = TestWorkflowAssociatedDataFactory.create_app_mock() workflow_id = "nonexistent-workflow" - # Mock database query to return None - mock_query = MagicMock() - mock_db_session.session.query.return_value = mock_query - mock_query.where.return_value.first.return_value = None + # Mock db.session.scalar() to return None + mock_db_session.session.scalar.return_value = None result = workflow_service.get_published_workflow_by_id(app, workflow_id) @@ -434,10 +422,8 @@ class TestWorkflowService: app = TestWorkflowAssociatedDataFactory.create_app_mock(workflow_id=workflow_id) mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1") - # Mock database query - mock_query = MagicMock() - mock_db_session.session.query.return_value = mock_query - mock_query.where.return_value.first.return_value = mock_workflow + # Mock db.session.scalar() used by get_published_workflow + mock_db_session.session.scalar.return_value = mock_workflow result = workflow_service.get_published_workflow(app) @@ -466,11 +452,9 @@ class TestWorkflowService: graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph() features = {"file_upload": {"enabled": False}} - # Mock get_draft_workflow to return None (no existing draft) + # Mock db.session.scalar() to return None (no existing draft) # This simulates the first time a workflow is created for an app - mock_query = MagicMock() - mock_db_session.session.query.return_value = mock_query - mock_query.where.return_value.first.return_value = None + mock_db_session.session.scalar.return_value = None with ( patch.object(workflow_service, "validate_features_structure"), @@ -504,12 +488,10 @@ class TestWorkflowService: features = {"file_upload": {"enabled": False}} unique_hash = "test-hash-123" - # Mock existing draft workflow + # Mock existing draft workflow via db.session.scalar() mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(unique_hash=unique_hash) - mock_query = MagicMock() - mock_db_session.session.query.return_value = mock_query - mock_query.where.return_value.first.return_value = mock_workflow + mock_db_session.session.scalar.return_value = mock_workflow with ( patch.object(workflow_service, "validate_features_structure"), @@ -545,12 +527,10 @@ class TestWorkflowService: graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph() features = {} - # Mock existing draft workflow with different hash + # Mock existing draft workflow with different hash via db.session.scalar() mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(unique_hash="old-hash") - mock_query = MagicMock() - mock_db_session.session.query.return_value = mock_query - mock_query.where.return_value.first.return_value = mock_workflow + mock_db_session.session.scalar.return_value = mock_workflow with pytest.raises(WorkflowHashNotEqualError): workflow_service.sync_draft_workflow( diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index 439d203c58..175900071b 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -347,7 +347,7 @@ class TestGetBuiltinToolProviderCredentials: def test_returns_empty_when_no_providers(self, mock_db): mock_db.session.no_autoflush.__enter__ = MagicMock(return_value=None) mock_db.session.no_autoflush.__exit__ = MagicMock(return_value=False) - mock_db.session.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = [] + mock_db.session.scalars.return_value.all.return_value = [] result = BuiltinToolManageService.get_builtin_tool_provider_credentials("t", "google") @@ -362,7 +362,7 @@ class TestGetBuiltinToolProviderCredentials: mock_db.session.no_autoflush.__exit__ = MagicMock(return_value=False) provider = MagicMock(provider="google", is_default=False) - mock_db.session.query.return_value.filter_by.return_value.order_by.return_value.all.return_value = [provider] + mock_db.session.scalars.return_value.all.return_value = [provider] mock_encrypter = MagicMock() mock_encrypter.decrypt.return_value = {"key": "decrypted"} From 2b9eb065552e4bae8ff14b00640d30e2d2257d78 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 1 Apr 2026 19:02:53 +0800 Subject: [PATCH 24/30] chore: move commit hook to root (#34404) --- .gitignore | 1 + {web/.husky => .vite-hooks}/pre-commit | 2 +- package.json | 12 +- pnpm-lock.yaml | 155 +------------------------ pnpm-workspace.yaml | 2 - vite.config.ts | 5 + web/Dockerfile | 2 +- web/Dockerfile.dockerignore | 1 - web/package.json | 6 - web/vite.config.ts | 3 + 10 files changed, 22 insertions(+), 167 deletions(-) rename {web/.husky => .vite-hooks}/pre-commit (99%) mode change 100644 => 100755 create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index d7698fe3fd..f703fc02e9 100644 --- a/.gitignore +++ b/.gitignore @@ -213,6 +213,7 @@ api/.vscode # pnpm /.pnpm-store /node_modules +.vite-hooks/_ # plugin migrate plugins.jsonl diff --git a/web/.husky/pre-commit b/.vite-hooks/pre-commit old mode 100644 new mode 100755 similarity index 99% rename from web/.husky/pre-commit rename to .vite-hooks/pre-commit index 3f25de256f..54e09f80d6 --- a/web/.husky/pre-commit +++ b/.vite-hooks/pre-commit @@ -77,7 +77,7 @@ if $web_modified; then fi cd ./web || exit 1 - lint-staged + vp staged if $web_ts_modified; then echo "Running TypeScript type-check:tsgo" diff --git a/package.json b/package.json index 07f1e16153..48c3acef02 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,15 @@ { "name": "dify", "private": true, + "scripts": { + "prepare": "vp config" + }, + "devDependencies": { + "taze": "catalog:", + "vite-plus": "catalog:" + }, "engines": { "node": "^22.22.1" }, - "packageManager": "pnpm@10.33.0", - "devDependencies": { - "taze": "catalog:" - } + "packageManager": "pnpm@10.33.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb45ea0ef8..baa4ed6c34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -345,9 +345,6 @@ catalogs: html-to-image: specifier: 1.11.13 version: 1.11.13 - husky: - specifier: 9.1.7 - version: 9.1.7 i18next: specifier: 25.10.10 version: 25.10.10 @@ -390,9 +387,6 @@ catalogs: lexical: specifier: 0.42.0 version: 0.42.0 - lint-staged: - specifier: 16.4.0 - version: 16.4.0 mermaid: specifier: 11.13.0 version: 11.13.0 @@ -624,6 +618,9 @@ importers: taze: specifier: 'catalog:' version: 19.10.0 + vite-plus: + specifier: 'catalog:' + version: 0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) e2e: devDependencies: @@ -1165,18 +1162,12 @@ importers: hono: specifier: 'catalog:' version: 4.12.9 - husky: - specifier: 'catalog:' - version: 9.1.7 iconify-import-svg: specifier: 'catalog:' version: 0.1.2 knip: specifier: 'catalog:' version: 6.1.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - lint-staged: - specifier: 'catalog:' - version: 16.4.0 postcss: specifier: 'catalog:' version: 8.5.8 @@ -4751,10 +4742,6 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - ansi-regex@4.1.1: resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} engines: {node: '>=6'} @@ -4775,10 +4762,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -5066,18 +5049,10 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - cli-table3@0.6.5: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} - client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -5104,9 +5079,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - comma-separated-tokens@1.0.8: resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} @@ -5572,10 +5544,6 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -5965,9 +5933,6 @@ packages: event-target-bus@1.0.0: resolution: {integrity: sha512-uPcWKbj/BJU3Tbw9XqhHqET4/LBOhvv3/SJWr7NksxA6TC5YqBpaZgawE9R+WpYFCBFSAE4Vun+xQS6w4ABdlA==} - eventemitter3@5.0.4: - resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -6289,11 +6254,6 @@ packages: htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true - i18next-resources-to-backend@1.2.1: resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==} @@ -6419,10 +6379,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -6730,15 +6686,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - lint-staged@16.4.0: - resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} - engines: {node: '>=20.17'} - hasBin: true - - listr2@9.0.5: - resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} - engines: {node: '>=20.0.0'} - load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -6770,10 +6717,6 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} - longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -7920,9 +7863,6 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -8043,14 +7983,6 @@ packages: size-sensor@1.0.3: resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==} - slice-ansi@7.1.2: - resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} - engines: {node: '>=18'} - - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} - smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} @@ -8134,10 +8066,6 @@ packages: resolution: {integrity: sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==} engines: {node: '>=0.6.19'} - string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} - string-ts@2.3.1: resolution: {integrity: sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==} @@ -8874,10 +8802,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@9.0.2: - resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} - engines: {node: '>=18'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -12658,10 +12582,6 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-escapes@7.3.0: - dependencies: - environment: 1.1.0 - ansi-regex@4.1.1: {} ansi-regex@5.0.1: {} @@ -12674,8 +12594,6 @@ snapshots: ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} - ansis@4.2.0: {} any-promise@1.3.0: {} @@ -12947,21 +12865,12 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - cli-table3@0.6.5: dependencies: string-width: 8.2.0 optionalDependencies: '@colors/colors': 1.5.0 - cli-truncate@5.2.0: - dependencies: - slice-ansi: 8.0.0 - string-width: 8.2.0 - client-only@0.0.1: {} clsx@2.1.1: {} @@ -12998,8 +12907,6 @@ snapshots: color-name@1.1.4: {} - colorette@2.0.20: {} - comma-separated-tokens@1.0.8: {} comma-separated-tokens@2.0.3: {} @@ -13443,8 +13350,6 @@ snapshots: entities@7.0.1: {} - environment@1.1.0: {} - error-stack-parser-es@1.0.5: {} error-stack-parser@2.1.4: @@ -14106,8 +14011,6 @@ snapshots: event-target-bus@1.0.0: {} - eventemitter3@5.0.4: {} - events@3.3.0: {} expand-template@2.0.3: @@ -14496,8 +14399,6 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 - husky@9.1.7: {} - i18next-resources-to-backend@1.2.1: dependencies: '@babel/runtime': 7.29.2 @@ -14596,10 +14497,6 @@ snapshots: is-extglob@2.1.1: {} - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.5.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -14853,24 +14750,6 @@ snapshots: lines-and-columns@1.2.4: {} - lint-staged@16.4.0: - dependencies: - commander: 14.0.3 - listr2: 9.0.5 - picomatch: 4.0.4 - string-argv: 0.3.2 - tinyexec: 1.0.4 - yaml: 2.8.3 - - listr2@9.0.5: - dependencies: - cli-truncate: 5.2.0 - colorette: 2.0.20 - eventemitter3: 5.0.4 - log-update: 6.1.0 - rfdc: 1.4.1 - wrap-ansi: 9.0.2 - load-tsconfig@0.2.5: {} loader-runner@4.3.1: {} @@ -14895,14 +14774,6 @@ snapshots: lodash@4.17.23: {} - log-update@6.1.0: - dependencies: - ansi-escapes: 7.3.0 - cli-cursor: 5.0.0 - slice-ansi: 7.1.2 - strip-ansi: 7.2.0 - wrap-ansi: 9.0.2 - longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -16539,8 +16410,6 @@ snapshots: reusify@1.1.0: {} - rfdc@1.4.1: {} - robust-predicates@3.0.3: {} rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): @@ -16734,16 +16603,6 @@ snapshots: size-sensor@1.0.3: {} - slice-ansi@7.1.2: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - - slice-ansi@8.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - smol-toml@1.6.1: {} solid-js@1.9.11: @@ -16844,8 +16703,6 @@ snapshots: string-argv@0.3.1: {} - string-argv@0.3.2: {} - string-ts@2.3.1: {} string-width@8.2.0: @@ -17690,12 +17547,6 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@9.0.2: - dependencies: - ansi-styles: 6.2.3 - string-width: 8.2.0 - strip-ansi: 7.2.0 - wrappy@1.0.2: {} ws@8.20.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b11cca6642..77451f6dfc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -183,7 +183,6 @@ catalog: hono: 4.12.9 html-entities: 2.6.0 html-to-image: 1.11.13 - husky: 9.1.7 i18next: 25.10.10 i18next-resources-to-backend: 1.2.1 iconify-import-svg: 0.1.2 @@ -198,7 +197,6 @@ catalog: ky: 1.14.3 lamejs: 1.2.1 lexical: 0.42.0 - lint-staged: 16.4.0 mermaid: 11.13.0 mime: 4.1.0 mitt: 3.0.1 diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000000..a34932a4ef --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite-plus' + +export default defineConfig({ + staged: {}, +}) diff --git a/web/Dockerfile b/web/Dockerfile index 75024db4f3..dc23416842 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -31,7 +31,7 @@ RUN corepack install # Install only the web workspace to keep image builds from pulling in # unrelated workspace dependencies such as e2e tooling. -RUN pnpm install --filter ./web... --frozen-lockfile +RUN VITE_GIT_HOOKS=0 pnpm install --filter ./web... --frozen-lockfile # build resources FROM base AS builder diff --git a/web/Dockerfile.dockerignore b/web/Dockerfile.dockerignore index 9801003d89..b572bd863e 100644 --- a/web/Dockerfile.dockerignore +++ b/web/Dockerfile.dockerignore @@ -22,7 +22,6 @@ web/node_modules web/dist web/build web/coverage -web/.husky web/.next web/.pnpm-store web/.vscode diff --git a/web/package.json b/web/package.json index 9ed21fdb22..08c10b12ad 100644 --- a/web/package.json +++ b/web/package.json @@ -40,7 +40,6 @@ "lint:quiet": "vp run lint --quiet", "lint:tss": "tsslint --project tsconfig.json", "preinstall": "npx only-allow pnpm", - "prepare": "cd ../ && node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky ./web/.husky", "refactor-component": "node ./scripts/refactor-component.js", "start": "node ./scripts/copy-and-start.mjs", "start:vinext": "vinext start", @@ -218,10 +217,8 @@ "eslint-plugin-storybook": "catalog:", "happy-dom": "catalog:", "hono": "catalog:", - "husky": "catalog:", "iconify-import-svg": "catalog:", "knip": "catalog:", - "lint-staged": "catalog:", "postcss": "catalog:", "postcss-js": "catalog:", "react-server-dom-webpack": "catalog:", @@ -237,8 +234,5 @@ "vite-plus": "catalog:", "vitest": "catalog:", "vitest-canvas-mock": "catalog:" - }, - "lint-staged": { - "*": "eslint --fix --pass-on-unpruned-suppressions" } } diff --git a/web/vite.config.ts b/web/vite.config.ts index 28746f81ca..92762676d1 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -18,6 +18,9 @@ export default defineConfig(({ mode }) => { || process.argv.some(arg => arg.toLowerCase().includes('storybook')) return { + staged: { + '*': 'eslint --fix --pass-on-unpruned-suppressions', + }, plugins: isTest ? [ nextStaticImageTestPlugin({ projectRoot }), From e41965061ceb78bb574d1eac60fa18fb7b3e0e98 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Wed, 1 Apr 2026 21:15:36 +0800 Subject: [PATCH 25/30] =?UTF-8?q?fix:=20sqlalchemy.exc.InvalidRequestError?= =?UTF-8?q?:=20Can't=20operate=20on=20closed=20tran=E2=80=A6=20(#34407)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rag_pipeline/rag_pipeline_workflow.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index 4251e7ebac..70dfe47d7f 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -593,17 +593,15 @@ class PublishedRagPipelineApi(Resource): # The role of the current user in the ta table must be admin, owner, or editor current_user, _ = current_account_with_tenant() rag_pipeline_service = RagPipelineService() - with sessionmaker(db.engine).begin() as session: - pipeline = session.merge(pipeline) - workflow = rag_pipeline_service.publish_workflow( - session=session, - pipeline=pipeline, - account=current_user, - ) - pipeline.is_published = True - pipeline.workflow_id = workflow.id - session.add(pipeline) - workflow_created_at = TimestampField().format(workflow.created_at) + workflow = rag_pipeline_service.publish_workflow( + session=db.session, # type: ignore[reportArgumentType,arg-type] + pipeline=pipeline, + account=current_user, + ) + pipeline.is_published = True + pipeline.workflow_id = workflow.id + db.session.commit() + workflow_created_at = TimestampField().format(workflow.created_at) return { "result": "success", From 391007d02e60e69962df2cc0ebcbac740cd1a1de Mon Sep 17 00:00:00 2001 From: Tim Ren <137012659+xr843@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:53:41 +0800 Subject: [PATCH 26/30] refactor: migrate service_api and inner_api to sessionmaker pattern (#34379) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/inner_api/plugin/wraps.py | 6 ++-- .../service_api/app/conversation.py | 4 +-- api/controllers/service_api/app/workflow.py | 4 +-- .../inner_api/plugin/test_plugin_wraps.py | 33 +++++++++---------- .../service_api/app/test_conversation.py | 11 +++++-- .../service_api/app/test_workflow.py | 22 +++++++++---- 6 files changed, 47 insertions(+), 33 deletions(-) diff --git a/api/controllers/inner_api/plugin/wraps.py b/api/controllers/inner_api/plugin/wraps.py index d6e3ebfbcd..ed0d490aad 100644 --- a/api/controllers/inner_api/plugin/wraps.py +++ b/api/controllers/inner_api/plugin/wraps.py @@ -6,7 +6,7 @@ from flask import current_app, request from flask_login import user_logged_in from pydantic import BaseModel from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from extensions.ext_database import db from libs.login import current_user @@ -33,7 +33,7 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser: user_id = DefaultEndUserSessionID.DEFAULT_SESSION_ID is_anonymous = user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID try: - with Session(db.engine) as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: user_model = None if is_anonymous: @@ -56,7 +56,7 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser: session_id=user_id, ) session.add(user_model) - session.commit() + session.flush() session.refresh(user_model) except Exception: diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index edbf011656..8c9a3eb5e9 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -3,7 +3,7 @@ from typing import Any, Literal from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator -from sqlalchemy.orm import Session +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, NotFound import services @@ -116,7 +116,7 @@ class ConversationApi(Resource): last_id = str(query_args.last_id) if query_args.last_id else None try: - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: pagination = ConversationService.pagination_by_last_id( session=session, app_model=app_model, diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index 1759075139..d7992a2a3a 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -8,7 +8,7 @@ from graphon.enums import WorkflowExecutionStatus from graphon.graph_engine.manager import GraphEngineManager from graphon.model_runtime.errors.invoke import InvokeError from pydantic import BaseModel, Field -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from controllers.common.schema import register_schema_models @@ -314,7 +314,7 @@ class WorkflowAppLogApi(Resource): # get paginate workflow app logs workflow_app_service = WorkflowAppService() - with Session(db.engine) as session: + with sessionmaker(db.engine).begin() as session: workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs( session=session, app_model=app_model, diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py index eac57fe4b7..957d7fbd9b 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py @@ -41,15 +41,15 @@ class TestGetUser: """Test get_user function""" @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.Session") + @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") - def test_should_return_existing_user_by_id(self, mock_db, mock_session_class, mock_enduser_class, app: Flask): + def test_should_return_existing_user_by_id(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask): """Test returning existing user when found by ID""" # Arrange mock_user = MagicMock() mock_user.id = "user123" mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session mock_session.get.return_value = mock_user # Act @@ -61,17 +61,17 @@ class TestGetUser: mock_session.get.assert_called_once() @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.Session") + @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") def test_should_return_existing_anonymous_user_by_session_id( - self, mock_db, mock_session_class, mock_enduser_class, app: Flask + self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask ): """Test returning existing anonymous user by session_id""" # Arrange mock_user = MagicMock() mock_user.session_id = "anonymous_session" mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session # non-anonymous path uses session.get(); anonymous uses session.scalar() mock_session.get.return_value = mock_user @@ -83,13 +83,13 @@ class TestGetUser: assert result == mock_user @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.Session") + @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") - def test_should_create_new_user_when_not_found(self, mock_db, mock_session_class, mock_enduser_class, app: Flask): + def test_should_create_new_user_when_not_found(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask): """Test creating new user when not found in database""" # Arrange mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session mock_session.get.return_value = None mock_new_user = MagicMock() mock_enduser_class.return_value = mock_new_user @@ -101,21 +101,20 @@ class TestGetUser: # Assert assert result == mock_new_user mock_session.add.assert_called_once() - mock_session.commit.assert_called_once() mock_session.refresh.assert_called_once() @patch("controllers.inner_api.plugin.wraps.select") @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.Session") + @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") def test_should_use_default_session_id_when_user_id_none( - self, mock_db, mock_session_class, mock_enduser_class, mock_select, app: Flask + self, mock_db, mock_sessionmaker, mock_enduser_class, mock_select, app: Flask ): """Test using default session ID when user_id is None""" # Arrange mock_user = MagicMock() mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session # When user_id is None, is_anonymous=True, so session.scalar() is used mock_session.scalar.return_value = mock_user @@ -127,15 +126,13 @@ class TestGetUser: assert result == mock_user @patch("controllers.inner_api.plugin.wraps.EndUser") - @patch("controllers.inner_api.plugin.wraps.Session") + @patch("controllers.inner_api.plugin.wraps.sessionmaker") @patch("controllers.inner_api.plugin.wraps.db") - def test_should_raise_error_on_database_exception( - self, mock_db, mock_session_class, mock_enduser_class, app: Flask - ): + def test_should_raise_error_on_database_exception(self, mock_db, mock_sessionmaker, mock_enduser_class, app: Flask): """Test raising ValueError when database operation fails""" # Arrange mock_session = MagicMock() - mock_session_class.return_value.__enter__.return_value = mock_session + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session mock_session.get.side_effect = Exception("Database error") # Act & Assert diff --git a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py index 81c45dcdb7..dbd06677d8 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py @@ -433,13 +433,20 @@ class TestConversationApiController: handler(api, app_model=app_model, end_user=end_user) def test_list_last_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: - class _SessionStub: + class _BeginStub: def __enter__(self): return SimpleNamespace() def __exit__(self, exc_type, exc, tb): return False + class _SessionMakerStub: + def __init__(self, *args, **kwargs): + pass + + def begin(self): + return _BeginStub() + monkeypatch.setattr( ConversationService, "pagination_by_last_id", @@ -447,7 +454,7 @@ class TestConversationApiController: ) conversation_module = sys.modules["controllers.service_api.app.conversation"] monkeypatch.setattr(conversation_module, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(conversation_module, "Session", lambda *_args, **_kwargs: _SessionStub()) + monkeypatch.setattr(conversation_module, "sessionmaker", _SessionMakerStub) api = ConversationApi() handler = _unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py index b1f036c6f3..cfa21bf2dd 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py @@ -470,16 +470,23 @@ class TestWorkflowTaskStopApi: class TestWorkflowAppLogApi: def test_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: - class _SessionStub: + class _BeginStub: def __enter__(self): return SimpleNamespace() def __exit__(self, exc_type, exc, tb): return False + class _SessionMakerStub: + def __init__(self, *args, **kwargs): + pass + + def begin(self): + return _BeginStub() + workflow_module = sys.modules["controllers.service_api.app.workflow"] monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) - monkeypatch.setattr(workflow_module, "Session", lambda *_args, **_kwargs: _SessionStub()) + monkeypatch.setattr(workflow_module, "sessionmaker", _SessionMakerStub) monkeypatch.setattr( WorkflowAppService, "get_paginate_workflow_app_logs", @@ -635,11 +642,14 @@ class TestWorkflowAppLogApiGet: mock_svc_instance.get_paginate_workflow_app_logs.return_value = mock_pagination mock_wf_svc_cls.return_value = mock_svc_instance - # Mock Session context manager + # Mock sessionmaker(...).begin() context manager mock_session = Mock() mock_db.engine = Mock() - mock_session.__enter__ = Mock(return_value=mock_session) - mock_session.__exit__ = Mock(return_value=False) + mock_begin = Mock() + mock_begin.__enter__ = Mock(return_value=mock_session) + mock_begin.__exit__ = Mock(return_value=False) + mock_session_factory = Mock() + mock_session_factory.begin.return_value = mock_begin from controllers.service_api.app.workflow import WorkflowAppLogApi @@ -647,7 +657,7 @@ class TestWorkflowAppLogApiGet: "/workflows/logs?page=1&limit=20", method="GET", ): - with patch("controllers.service_api.app.workflow.Session", return_value=mock_session): + with patch("controllers.service_api.app.workflow.sessionmaker", return_value=mock_session_factory): api = WorkflowAppLogApi() result = _unwrap(api.get)(api, app_model=mock_workflow_app) From 4e1d0604391e2df11c6df7b3864b9121e9304fe8 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:37:27 +0200 Subject: [PATCH 27/30] refactor: select in message_service and ops_service (#34414) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/services/message_service.py | 57 ++++--- api/services/ops_service.py | 32 ++-- .../services/test_message_service.py | 147 +++--------------- .../unit_tests/services/test_ops_service.py | 53 ++++--- 4 files changed, 94 insertions(+), 195 deletions(-) diff --git a/api/services/message_service.py b/api/services/message_service.py index a04f9cbe01..5c2978db21 100644 --- a/api/services/message_service.py +++ b/api/services/message_service.py @@ -3,6 +3,7 @@ from typing import Union from graphon.model_runtime.entities.model_entities import ModelType from pydantic import TypeAdapter +from sqlalchemy import select from sqlalchemy.orm import sessionmaker from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager @@ -75,17 +76,15 @@ class MessageService: fetch_limit = limit + 1 if first_id: - first_message = ( - db.session.query(Message) - .where(Message.conversation_id == conversation.id, Message.id == first_id) - .first() + first_message = db.session.scalar( + select(Message).where(Message.conversation_id == conversation.id, Message.id == first_id).limit(1) ) if not first_message: raise FirstMessageNotExistsError() - history_messages = ( - db.session.query(Message) + history_messages = db.session.scalars( + select(Message) .where( Message.conversation_id == conversation.id, Message.created_at < first_message.created_at, @@ -93,16 +92,14 @@ class MessageService: ) .order_by(Message.created_at.desc()) .limit(fetch_limit) - .all() - ) + ).all() else: - history_messages = ( - db.session.query(Message) + history_messages = db.session.scalars( + select(Message) .where(Message.conversation_id == conversation.id) .order_by(Message.created_at.desc()) .limit(fetch_limit) - .all() - ) + ).all() has_more = False if len(history_messages) > limit: @@ -129,7 +126,7 @@ class MessageService: if not user: return InfiniteScrollPagination(data=[], limit=limit, has_more=False) - base_query = db.session.query(Message) + stmt = select(Message) fetch_limit = limit + 1 @@ -138,28 +135,27 @@ class MessageService: app_model=app_model, user=user, conversation_id=conversation_id ) - base_query = base_query.where(Message.conversation_id == conversation.id) + stmt = stmt.where(Message.conversation_id == conversation.id) # Check if include_ids is not None and not empty to avoid WHERE false condition if include_ids is not None: if len(include_ids) == 0: return InfiniteScrollPagination(data=[], limit=limit, has_more=False) - base_query = base_query.where(Message.id.in_(include_ids)) + stmt = stmt.where(Message.id.in_(include_ids)) if last_id: - last_message = base_query.where(Message.id == last_id).first() + last_message = db.session.scalar(stmt.where(Message.id == last_id).limit(1)) if not last_message: raise LastMessageNotExistsError() - history_messages = ( - base_query.where(Message.created_at < last_message.created_at, Message.id != last_message.id) + history_messages = db.session.scalars( + stmt.where(Message.created_at < last_message.created_at, Message.id != last_message.id) .order_by(Message.created_at.desc()) .limit(fetch_limit) - .all() - ) + ).all() else: - history_messages = base_query.order_by(Message.created_at.desc()).limit(fetch_limit).all() + history_messages = db.session.scalars(stmt.order_by(Message.created_at.desc()).limit(fetch_limit)).all() has_more = False if len(history_messages) > limit: @@ -214,21 +210,20 @@ class MessageService: def get_all_messages_feedbacks(cls, app_model: App, page: int, limit: int): """Get all feedbacks of an app""" offset = (page - 1) * limit - feedbacks = ( - db.session.query(MessageFeedback) + feedbacks = db.session.scalars( + select(MessageFeedback) .where(MessageFeedback.app_id == app_model.id) .order_by(MessageFeedback.created_at.desc(), MessageFeedback.id.desc()) .limit(limit) .offset(offset) - .all() - ) + ).all() return [record.to_dict() for record in feedbacks] @classmethod def get_message(cls, app_model: App, user: Union[Account, EndUser] | None, message_id: str): - message = ( - db.session.query(Message) + message = db.session.scalar( + select(Message) .where( Message.id == message_id, Message.app_id == app_model.id, @@ -236,7 +231,7 @@ class MessageService: Message.from_end_user_id == (user.id if isinstance(user, EndUser) else None), Message.from_account_id == (user.id if isinstance(user, Account) else None), ) - .first() + .limit(1) ) if not message: @@ -282,10 +277,10 @@ class MessageService: ) else: if not conversation.override_model_configs: - app_model_config = ( - db.session.query(AppModelConfig) + app_model_config = db.session.scalar( + select(AppModelConfig) .where(AppModelConfig.id == conversation.app_model_config_id, AppModelConfig.app_id == app_model.id) - .first() + .limit(1) ) else: conversation_override_model_configs = _app_model_config_adapter.validate_json( diff --git a/api/services/ops_service.py b/api/services/ops_service.py index 50ea832085..2a64088dd6 100644 --- a/api/services/ops_service.py +++ b/api/services/ops_service.py @@ -1,5 +1,7 @@ from typing import Any +from sqlalchemy import select + from core.ops.entities.config_entity import BaseTracingConfig from core.ops.ops_trace_manager import OpsTraceManager, provider_config_map from extensions.ext_database import db @@ -15,17 +17,17 @@ class OpsService: :param tracing_provider: tracing provider :return: """ - trace_config_data: TraceAppConfig | None = ( - db.session.query(TraceAppConfig) + trace_config_data: TraceAppConfig | None = db.session.scalar( + select(TraceAppConfig) .where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider) - .first() + .limit(1) ) if not trace_config_data: return None # decrypt_token and obfuscated_token - app = db.session.query(App).where(App.id == app_id).first() + app = db.session.get(App, app_id) if not app: return None tenant_id = app.tenant_id @@ -182,17 +184,17 @@ class OpsService: project_url = None # check if trace config already exists - trace_config_data: TraceAppConfig | None = ( - db.session.query(TraceAppConfig) + trace_config_data: TraceAppConfig | None = db.session.scalar( + select(TraceAppConfig) .where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider) - .first() + .limit(1) ) if trace_config_data: return None # get tenant id - app = db.session.query(App).where(App.id == app_id).first() + app = db.session.get(App, app_id) if not app: return None tenant_id = app.tenant_id @@ -224,17 +226,17 @@ class OpsService: raise ValueError(f"Invalid tracing provider: {tracing_provider}") # check if trace config already exists - current_trace_config = ( - db.session.query(TraceAppConfig) + current_trace_config = db.session.scalar( + select(TraceAppConfig) .where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider) - .first() + .limit(1) ) if not current_trace_config: return None # get tenant id - app = db.session.query(App).where(App.id == app_id).first() + app = db.session.get(App, app_id) if not app: return None tenant_id = app.tenant_id @@ -261,10 +263,10 @@ class OpsService: :param tracing_provider: tracing provider :return: """ - trace_config = ( - db.session.query(TraceAppConfig) + trace_config = db.session.scalar( + select(TraceAppConfig) .where(TraceAppConfig.app_id == app_id, TraceAppConfig.tracing_provider == tracing_provider) - .first() + .limit(1) ) if not trace_config: diff --git a/api/tests/unit_tests/services/test_message_service.py b/api/tests/unit_tests/services/test_message_service.py index 101b9bff24..b6e990ebe0 100644 --- a/api/tests/unit_tests/services/test_message_service.py +++ b/api/tests/unit_tests/services/test_message_service.py @@ -151,12 +151,7 @@ class TestMessageServicePaginationByFirstId: for i in range(5) ] - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.limit.return_value = mock_query - mock_query.all.return_value = messages + mock_db.session.scalars.return_value.all.return_value = messages # Act result = MessageService.pagination_by_first_id( @@ -196,12 +191,7 @@ class TestMessageServicePaginationByFirstId: for i in range(5) ] - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.limit.return_value = mock_query - mock_query.all.return_value = messages + mock_db.session.scalars.return_value.all.return_value = messages # Act result = MessageService.pagination_by_first_id( @@ -246,31 +236,8 @@ class TestMessageServicePaginationByFirstId: for i in range(5) ] - # Setup query mocks - mock_query_first = MagicMock() - mock_query_history = MagicMock() - - query_calls = [] - - def query_side_effect(*args): - if args[0] == Message: - query_calls.append(args) - if len(query_calls) == 1: - return mock_query_first - else: - return mock_query_history - - mock_db.session.query.side_effect = [mock_query_first, mock_query_history] - - # Setup first message query - mock_query_first.where.return_value = mock_query_first - mock_query_first.first.return_value = first_message - - # Setup history messages query - mock_query_history.where.return_value = mock_query_history - mock_query_history.order_by.return_value = mock_query_history - mock_query_history.limit.return_value = mock_query_history - mock_query_history.all.return_value = history_messages + mock_db.session.scalar.return_value = first_message + mock_db.session.scalars.return_value.all.return_value = history_messages # Act result = MessageService.pagination_by_first_id( @@ -285,8 +252,6 @@ class TestMessageServicePaginationByFirstId: # Assert assert len(result.data) == 5 assert result.has_more is False - mock_query_first.where.assert_called_once() - mock_query_history.where.assert_called_once() # Test 06: First message not found @patch("services.message_service.db") @@ -300,10 +265,7 @@ class TestMessageServicePaginationByFirstId: mock_conversation_service.get_conversation.return_value = conversation - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.first.return_value = None # Message not found + mock_db.session.scalar.return_value = None # Message not found # Act & Assert with pytest.raises(FirstMessageNotExistsError): @@ -336,12 +298,7 @@ class TestMessageServicePaginationByFirstId: for i in range(11) ] - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.limit.return_value = mock_query - mock_query.all.return_value = messages + mock_db.session.scalars.return_value.all.return_value = messages # Act result = MessageService.pagination_by_first_id( @@ -369,12 +326,7 @@ class TestMessageServicePaginationByFirstId: mock_conversation_service.get_conversation.return_value = conversation - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.limit.return_value = mock_query - mock_query.all.return_value = [] + mock_db.session.scalars.return_value.all.return_value = [] # Act result = MessageService.pagination_by_first_id( @@ -443,12 +395,7 @@ class TestMessageServicePaginationByLastId: for i in range(5) ] - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.limit.return_value = mock_query - mock_query.all.return_value = messages + mock_db.session.scalars.return_value.all.return_value = messages # Act result = MessageService.pagination_by_last_id( @@ -485,22 +432,8 @@ class TestMessageServicePaginationByLastId: for i in range(6, 10) ] - # Setup base query mock that returns itself for chaining - mock_base_query = MagicMock() - mock_db.session.query.return_value = mock_base_query - - # First where() call for last_id lookup - mock_query_last = MagicMock() - mock_query_last.first.return_value = last_message - - # Second where() call for history messages - mock_query_history = MagicMock() - mock_query_history.order_by.return_value = mock_query_history - mock_query_history.limit.return_value = mock_query_history - mock_query_history.all.return_value = new_messages - - # Setup where() to return different mocks on consecutive calls - mock_base_query.where.side_effect = [mock_query_last, mock_query_history] + mock_db.session.scalar.return_value = last_message + mock_db.session.scalars.return_value.all.return_value = new_messages # Act result = MessageService.pagination_by_last_id( @@ -522,10 +455,7 @@ class TestMessageServicePaginationByLastId: app = factory.create_app_mock() user = factory.create_end_user_mock() - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.first.return_value = None # Message not found + mock_db.session.scalar.return_value = None # Message not found # Act & Assert with pytest.raises(LastMessageNotExistsError): @@ -557,12 +487,7 @@ class TestMessageServicePaginationByLastId: for i in range(5) ] - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.limit.return_value = mock_query - mock_query.all.return_value = messages + mock_db.session.scalars.return_value.all.return_value = messages # Act result = MessageService.pagination_by_last_id( @@ -576,8 +501,6 @@ class TestMessageServicePaginationByLastId: # Assert assert len(result.data) == 5 assert result.has_more is False - # Verify conversation_id was used in query - mock_query.where.assert_called() mock_conversation_service.get_conversation.assert_called_once() # Test 14: Pagination with include_ids filter @@ -594,12 +517,7 @@ class TestMessageServicePaginationByLastId: factory.create_message_mock(message_id="msg-003"), ] - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.limit.return_value = mock_query - mock_query.all.return_value = messages + mock_db.session.scalars.return_value.all.return_value = messages # Act result = MessageService.pagination_by_last_id( @@ -632,12 +550,7 @@ class TestMessageServicePaginationByLastId: for i in range(11) ] - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.limit.return_value = mock_query - mock_query.all.return_value = messages + mock_db.session.scalars.return_value.all.return_value = messages # Act result = MessageService.pagination_by_last_id( @@ -743,17 +656,13 @@ class TestMessageServiceGetMessage: user = factory.create_end_user_mock(user_id="end-user-123") message = factory.create_message_mock() - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.first.return_value = message + mock_db.session.scalar.return_value = message # Act result = MessageService.get_message(app_model=app, user=user, message_id="msg-123") # Assert assert result == message - mock_query.where.assert_called_once() # Test 21: get_message success for Account (Admin) @patch("services.message_service.db") @@ -767,10 +676,7 @@ class TestMessageServiceGetMessage: user.id = "account-123" message = factory.create_message_mock() - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.first.return_value = message + mock_db.session.scalar.return_value = message # Act result = MessageService.get_message(app_model=app, user=user, message_id="msg-123") @@ -786,10 +692,7 @@ class TestMessageServiceGetMessage: app = factory.create_app_mock() user = factory.create_end_user_mock() - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.first.return_value = None + mock_db.session.scalar.return_value = None # Act & Assert with pytest.raises(MessageNotExistsError): @@ -899,21 +802,13 @@ class TestMessageServiceFeedback: feedback = MagicMock() feedback.to_dict.return_value = {"id": "fb-1"} - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.limit.return_value = mock_query - mock_query.offset.return_value = mock_query - mock_query.all.return_value = [feedback] + mock_db.session.scalars.return_value.all.return_value = [feedback] # Act result = MessageService.get_all_messages_feedbacks(app_model=app, page=1, limit=10) # Assert assert result == [{"id": "fb-1"}] - mock_query.limit.assert_called_with(10) - mock_query.offset.assert_called_with(0) class TestMessageServiceSuggestedQuestions: @@ -1015,10 +910,7 @@ class TestMessageServiceSuggestedQuestions: app_model_config.suggested_questions_after_answer_dict = {"enabled": True} app_model_config.model_dict = {"provider": "openai", "name": "gpt-4"} - mock_query = MagicMock() - mock_db.session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.first.return_value = app_model_config + mock_db.session.scalar.return_value = app_model_config mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] @@ -1029,7 +921,6 @@ class TestMessageServiceSuggestedQuestions: # Assert assert result == ["Q1?"] - mock_query.first.assert_called_once() mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once() # Test 30: get_suggested_questions_after_answer - Disabled Error diff --git a/api/tests/unit_tests/services/test_ops_service.py b/api/tests/unit_tests/services/test_ops_service.py index ab7b473790..7067e3b3dd 100644 --- a/api/tests/unit_tests/services/test_ops_service.py +++ b/api/tests/unit_tests/services/test_ops_service.py @@ -12,28 +12,27 @@ class TestOpsService: @patch("services.ops_service.OpsTraceManager") def test_get_tracing_app_config_no_config(self, mock_ops_trace_manager, mock_db): # Arrange - mock_db.session.query.return_value.where.return_value.first.return_value = None + mock_db.session.scalar.return_value = None # Act result = OpsService.get_tracing_app_config("app_id", "arize") # Assert assert result is None - mock_db.session.query.assert_called_with(TraceAppConfig) @patch("services.ops_service.db") @patch("services.ops_service.OpsTraceManager") def test_get_tracing_app_config_no_app(self, mock_ops_trace_manager, mock_db): # Arrange trace_config = MagicMock(spec=TraceAppConfig) - mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, None] + mock_db.session.scalar.return_value = trace_config + mock_db.session.get.return_value = None # Act result = OpsService.get_tracing_app_config("app_id", "arize") # Assert assert result is None - assert mock_db.session.query.call_count == 2 @patch("services.ops_service.db") @patch("services.ops_service.OpsTraceManager") @@ -43,7 +42,8 @@ class TestOpsService: trace_config.tracing_config = None app = MagicMock(spec=App) app.tenant_id = "tenant_id" - mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, app] + mock_db.session.scalar.return_value = trace_config + mock_db.session.get.return_value = app # Act & Assert with pytest.raises(ValueError, match="Tracing config cannot be None."): @@ -72,7 +72,8 @@ class TestOpsService: trace_config.to_dict.return_value = {"tracing_config": {"project_url": default_url}} app = MagicMock(spec=App) app.tenant_id = "tenant_id" - mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, app] + mock_db.session.scalar.return_value = trace_config + mock_db.session.get.return_value = app mock_ops_trace_manager.decrypt_tracing_config.return_value = {} mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {} @@ -97,7 +98,8 @@ class TestOpsService: trace_config.to_dict.return_value = {"tracing_config": {"project_url": "success_url"}} app = MagicMock(spec=App) app.tenant_id = "tenant_id" - mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, app] + mock_db.session.scalar.return_value = trace_config + mock_db.session.get.return_value = app mock_ops_trace_manager.decrypt_tracing_config.return_value = {} mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {} @@ -118,7 +120,8 @@ class TestOpsService: trace_config.to_dict.return_value = {"tracing_config": {"project_url": "https://api.langfuse.com/project/key"}} app = MagicMock(spec=App) app.tenant_id = "tenant_id" - mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, app] + mock_db.session.scalar.return_value = trace_config + mock_db.session.get.return_value = app mock_ops_trace_manager.decrypt_tracing_config.return_value = {"host": "https://api.langfuse.com"} mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {"host": "https://api.langfuse.com"} @@ -139,7 +142,8 @@ class TestOpsService: trace_config.to_dict.return_value = {"tracing_config": {"project_url": "https://api.langfuse.com/"}} app = MagicMock(spec=App) app.tenant_id = "tenant_id" - mock_db.session.query.return_value.where.return_value.first.side_effect = [trace_config, app] + mock_db.session.scalar.return_value = trace_config + mock_db.session.get.return_value = app mock_ops_trace_manager.decrypt_tracing_config.return_value = {"host": "https://api.langfuse.com"} mock_ops_trace_manager.obfuscated_decrypt_token.return_value = {"host": "https://api.langfuse.com"} @@ -189,7 +193,7 @@ class TestOpsService: mock_ops_trace_manager.check_trace_config_is_effective.return_value = True mock_ops_trace_manager.get_trace_config_project_url.side_effect = Exception("error") mock_ops_trace_manager.get_trace_config_project_key.side_effect = Exception("error") - mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock(spec=TraceAppConfig) + mock_db.session.scalar.return_value = MagicMock(spec=TraceAppConfig) # Act result = OpsService.create_tracing_app_config("app_id", provider, config) @@ -206,7 +210,8 @@ class TestOpsService: mock_ops_trace_manager.get_trace_config_project_key.return_value = "key" app = MagicMock(spec=App) app.tenant_id = "tenant_id" - mock_db.session.query.return_value.where.return_value.first.side_effect = [None, app] + mock_db.session.scalar.return_value = None + mock_db.session.get.return_value = app mock_ops_trace_manager.encrypt_tracing_config.return_value = {} # Act @@ -223,7 +228,7 @@ class TestOpsService: # Arrange provider = TracingProviderEnum.ARIZE mock_ops_trace_manager.check_trace_config_is_effective.return_value = True - mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock(spec=TraceAppConfig) + mock_db.session.scalar.return_value = MagicMock(spec=TraceAppConfig) # Act result = OpsService.create_tracing_app_config("app_id", provider, {}) @@ -237,7 +242,8 @@ class TestOpsService: # Arrange provider = TracingProviderEnum.ARIZE mock_ops_trace_manager.check_trace_config_is_effective.return_value = True - mock_db.session.query.return_value.where.return_value.first.side_effect = [None, None] + mock_db.session.scalar.return_value = None + mock_db.session.get.return_value = None # Act result = OpsService.create_tracing_app_config("app_id", provider, {}) @@ -253,7 +259,8 @@ class TestOpsService: mock_ops_trace_manager.check_trace_config_is_effective.return_value = True app = MagicMock(spec=App) app.tenant_id = "tenant_id" - mock_db.session.query.return_value.where.return_value.first.side_effect = [None, app] + mock_db.session.scalar.return_value = None + mock_db.session.get.return_value = app mock_ops_trace_manager.encrypt_tracing_config.return_value = {} # Act @@ -274,7 +281,8 @@ class TestOpsService: mock_ops_trace_manager.get_trace_config_project_url.return_value = "http://project_url" app = MagicMock(spec=App) app.tenant_id = "tenant_id" - mock_db.session.query.return_value.where.return_value.first.side_effect = [None, app] + mock_db.session.scalar.return_value = None + mock_db.session.get.return_value = app mock_ops_trace_manager.encrypt_tracing_config.return_value = {"encrypted": "config"} # Act @@ -297,7 +305,7 @@ class TestOpsService: def test_update_tracing_app_config_no_config(self, mock_ops_trace_manager, mock_db): # Arrange provider = TracingProviderEnum.ARIZE - mock_db.session.query.return_value.where.return_value.first.return_value = None + mock_db.session.scalar.return_value = None # Act result = OpsService.update_tracing_app_config("app_id", provider, {}) @@ -311,7 +319,8 @@ class TestOpsService: # Arrange provider = TracingProviderEnum.ARIZE current_config = MagicMock(spec=TraceAppConfig) - mock_db.session.query.return_value.where.return_value.first.side_effect = [current_config, None] + mock_db.session.scalar.return_value = current_config + mock_db.session.get.return_value = None # Act result = OpsService.update_tracing_app_config("app_id", provider, {}) @@ -327,7 +336,8 @@ class TestOpsService: current_config = MagicMock(spec=TraceAppConfig) app = MagicMock(spec=App) app.tenant_id = "tenant_id" - mock_db.session.query.return_value.where.return_value.first.side_effect = [current_config, app] + mock_db.session.scalar.return_value = current_config + mock_db.session.get.return_value = app mock_ops_trace_manager.decrypt_tracing_config.return_value = {} mock_ops_trace_manager.check_trace_config_is_effective.return_value = False @@ -344,7 +354,8 @@ class TestOpsService: current_config.to_dict.return_value = {"some": "data"} app = MagicMock(spec=App) app.tenant_id = "tenant_id" - mock_db.session.query.return_value.where.return_value.first.side_effect = [current_config, app] + mock_db.session.scalar.return_value = current_config + mock_db.session.get.return_value = app mock_ops_trace_manager.decrypt_tracing_config.return_value = {} mock_ops_trace_manager.check_trace_config_is_effective.return_value = True @@ -358,7 +369,7 @@ class TestOpsService: @patch("services.ops_service.db") def test_delete_tracing_app_config_no_config(self, mock_db): # Arrange - mock_db.session.query.return_value.where.return_value.first.return_value = None + mock_db.session.scalar.return_value = None # Act result = OpsService.delete_tracing_app_config("app_id", "arize") @@ -370,7 +381,7 @@ class TestOpsService: def test_delete_tracing_app_config_success(self, mock_db): # Arrange trace_config = MagicMock(spec=TraceAppConfig) - mock_db.session.query.return_value.where.return_value.first.return_value = trace_config + mock_db.session.scalar.return_value = trace_config # Act result = OpsService.delete_tracing_app_config("app_id", "arize") From 725f9e3dc4d38b04cfa6493108fc074fb84b8ab0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:33:09 +0900 Subject: [PATCH 28/30] chore(deps): bump aiohttp from 3.13.3 to 3.13.4 in /api (#34425) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 72 ++++++++++++++++++++++++++--------------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index 39c362eda0..9ec408d380 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -60,7 +60,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -71,42 +71,42 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" }, + { url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" }, + { url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" }, + { url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" }, + { url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" }, + { url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" }, + { url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, + { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" }, + { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" }, ] [[package]] From 2d29345f2631c33b6ca55d00af4ac668378ff691 Mon Sep 17 00:00:00 2001 From: YBoy Date: Thu, 2 Apr 2026 03:47:08 +0200 Subject: [PATCH 29/30] =?UTF-8?q?refactor(api):=20type=20OpsTraceProviderC?= =?UTF-8?q?onfigMap=20with=20TracingProviderCon=E2=80=A6=20(#34424)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/core/ops/ops_trace_manager.py | 24 ++++++++++++++++-------- api/services/ops_service.py | 6 ++---- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index c689a86614..aa39e6b681 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -19,6 +19,7 @@ from typing_extensions import TypedDict from core.helper.encrypter import batch_decrypt_token, encrypt_token, obfuscated_token from core.ops.entities.config_entity import ( OPS_FILE_PATH, + BaseTracingConfig, TracingProviderEnum, ) from core.ops.entities.trace_entity import ( @@ -195,8 +196,15 @@ def _lookup_llm_credential_info( return None, "" -class OpsTraceProviderConfigMap(collections.UserDict[str, dict[str, Any]]): - def __getitem__(self, provider: str) -> dict[str, Any]: +class TracingProviderConfigEntry(TypedDict): + config_class: type[BaseTracingConfig] + secret_keys: list[str] + other_keys: list[str] + trace_instance: type[Any] + + +class OpsTraceProviderConfigMap(collections.UserDict[str, TracingProviderConfigEntry]): + def __getitem__(self, provider: str) -> TracingProviderConfigEntry: match provider: case TracingProviderEnum.LANGFUSE: from core.ops.entities.config_entity import LangfuseConfig @@ -585,8 +593,8 @@ class OpsTraceManager: provider_config_map[tracing_provider]["config_class"], provider_config_map[tracing_provider]["trace_instance"], ) - tracing_config = config_type(**tracing_config) - return trace_instance(tracing_config).api_check() + config = config_type(**tracing_config) + return trace_instance(config).api_check() @staticmethod def get_trace_config_project_key(tracing_config: dict, tracing_provider: str): @@ -600,8 +608,8 @@ class OpsTraceManager: provider_config_map[tracing_provider]["config_class"], provider_config_map[tracing_provider]["trace_instance"], ) - tracing_config = config_type(**tracing_config) - return trace_instance(tracing_config).get_project_key() + config = config_type(**tracing_config) + return trace_instance(config).get_project_key() @staticmethod def get_trace_config_project_url(tracing_config: dict, tracing_provider: str): @@ -615,8 +623,8 @@ class OpsTraceManager: provider_config_map[tracing_provider]["config_class"], provider_config_map[tracing_provider]["trace_instance"], ) - tracing_config = config_type(**tracing_config) - return trace_instance(tracing_config).get_project_url() + config = config_type(**tracing_config) + return trace_instance(config).get_project_url() class TraceTask: diff --git a/api/services/ops_service.py b/api/services/ops_service.py index 2a64088dd6..0db3d3efec 100644 --- a/api/services/ops_service.py +++ b/api/services/ops_service.py @@ -1,9 +1,7 @@ -from typing import Any - from sqlalchemy import select from core.ops.entities.config_entity import BaseTracingConfig -from core.ops.ops_trace_manager import OpsTraceManager, provider_config_map +from core.ops.ops_trace_manager import OpsTraceManager, TracingProviderConfigEntry, provider_config_map from extensions.ext_database import db from models.model import App, TraceAppConfig @@ -150,7 +148,7 @@ class OpsService: except KeyError: return {"error": f"Invalid tracing provider: {tracing_provider}"} - provider_config: dict[str, Any] = provider_config_map[tracing_provider] + provider_config: TracingProviderConfigEntry = provider_config_map[tracing_provider] config_class: type[BaseTracingConfig] = provider_config["config_class"] other_keys: list[str] = provider_config["other_keys"] From 20ddc9c48a4ebd2395f228e52ee8e9bff24bfb22 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 2 Apr 2026 11:22:46 +0800 Subject: [PATCH 30/30] fix: url query change record cookie --- .../__tests__/cookie-recorder.spec.tsx | 18 ++++++++++++++++++ .../billing/partner-stack/cookie-recorder.tsx | 4 ++-- .../components/billing/partner-stack/index.tsx | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx b/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx index 1441653c9c..8c1639e941 100644 --- a/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx +++ b/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx @@ -2,6 +2,8 @@ import { render } from '@testing-library/react' import PartnerStackCookieRecorder from '../cookie-recorder' let isCloudEdition = true +let psPartnerKey: string | undefined +let psClickId: string | undefined const saveOrUpdate = vi.fn() @@ -13,6 +15,8 @@ vi.mock('@/config', () => ({ vi.mock('../use-ps-info', () => ({ default: () => ({ + psPartnerKey, + psClickId, saveOrUpdate, }), })) @@ -21,6 +25,8 @@ describe('PartnerStackCookieRecorder', () => { beforeEach(() => { vi.clearAllMocks() isCloudEdition = true + psPartnerKey = undefined + psClickId = undefined }) it('should call saveOrUpdate once on mount when running in cloud edition', () => { @@ -42,4 +48,16 @@ describe('PartnerStackCookieRecorder', () => { expect(container.innerHTML).toBe('') }) + + it('should call saveOrUpdate again when partner stack query changes', () => { + const { rerender } = render() + + expect(saveOrUpdate).toHaveBeenCalledTimes(1) + + psPartnerKey = 'updated-partner' + psClickId = 'updated-click' + rerender() + + expect(saveOrUpdate).toHaveBeenCalledTimes(2) + }) }) diff --git a/web/app/components/billing/partner-stack/cookie-recorder.tsx b/web/app/components/billing/partner-stack/cookie-recorder.tsx index 3c75b2973c..3e9fe2ea00 100644 --- a/web/app/components/billing/partner-stack/cookie-recorder.tsx +++ b/web/app/components/billing/partner-stack/cookie-recorder.tsx @@ -5,13 +5,13 @@ import { IS_CLOUD_EDITION } from '@/config' import usePSInfo from './use-ps-info' const PartnerStackCookieRecorder = () => { - const { saveOrUpdate } = usePSInfo() + const { psPartnerKey, psClickId, saveOrUpdate } = usePSInfo() useEffect(() => { if (!IS_CLOUD_EDITION) return saveOrUpdate() - }, []) + }, [psPartnerKey, psClickId, saveOrUpdate]) return null } diff --git a/web/app/components/billing/partner-stack/index.tsx b/web/app/components/billing/partner-stack/index.tsx index e7b954a576..be77f0967b 100644 --- a/web/app/components/billing/partner-stack/index.tsx +++ b/web/app/components/billing/partner-stack/index.tsx @@ -6,7 +6,7 @@ import { IS_CLOUD_EDITION } from '@/config' import usePSInfo from './use-ps-info' const PartnerStack: FC = () => { - const { saveOrUpdate, bind } = usePSInfo() + const { psPartnerKey, psClickId, saveOrUpdate, bind } = usePSInfo() useEffect(() => { if (!IS_CLOUD_EDITION) return @@ -14,7 +14,7 @@ const PartnerStack: FC = () => { saveOrUpdate() // bind PartnerStack info after user logged in bind() - }, []) + }, [psPartnerKey, psClickId, saveOrUpdate, bind]) return null }