From 815ae6c7548564510115d4f094a91add0a837004 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sun, 4 Jan 2026 10:22:36 +0800 Subject: [PATCH 01/33] chore: remove redundant web/app/page.module.css (#30482) --- web/app/page.module.css | 266 ---------------------------------------- 1 file changed, 266 deletions(-) delete mode 100644 web/app/page.module.css diff --git a/web/app/page.module.css b/web/app/page.module.css deleted file mode 100644 index b51afee7b1..0000000000 --- a/web/app/page.module.css +++ /dev/null @@ -1,266 +0,0 @@ -.main { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; - padding: 6rem; - min-height: 100vh; -} - -.description { - display: inherit; - justify-content: inherit; - align-items: inherit; - font-size: 0.85rem; - max-width: var(--max-width); - width: 100%; - z-index: 2; - font-family: var(--font-mono); -} - -.description a { - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; -} - -.description p { - position: relative; - margin: 0; - padding: 1rem; - background-color: rgba(var(--callout-rgb), 0.5); - border: 1px solid rgba(var(--callout-border-rgb), 0.3); - border-radius: var(--border-radius); -} - -.code { - font-weight: 700; - font-family: var(--font-mono); -} - -.grid { - display: grid; - grid-template-columns: repeat(3, minmax(33%, auto)); - width: var(--max-width); - max-width: 100%; -} - -.card { - padding: 1rem 1.2rem; - border-radius: var(--border-radius); - background: rgba(var(--card-rgb), 0); - border: 1px solid rgba(var(--card-border-rgb), 0); - transition: background 200ms, border 200ms; -} - -.card span { - display: inline-block; - transition: transform 200ms; -} - -.card h2 { - font-weight: 600; - margin-bottom: 0.7rem; -} - -.card p { - margin: 0; - opacity: 0.6; - font-size: 0.9rem; - line-height: 1.5; - max-width: 34ch; -} - -.center { - display: flex; - justify-content: center; - align-items: center; - position: relative; - padding: 4rem 0; -} - -.center::before { - background: var(--secondary-glow); - border-radius: 50%; - width: 480px; - height: 360px; - margin-left: -400px; -} - -.center::after { - background: var(--primary-glow); - width: 240px; - height: 180px; - z-index: -1; -} - -.center::before, -.center::after { - content: ''; - left: 50%; - position: absolute; - filter: blur(45px); - transform: translateZ(0); -} - -.logo, -.thirteen { - position: relative; -} - -.thirteen { - display: flex; - justify-content: center; - align-items: center; - width: 75px; - height: 75px; - padding: 25px 10px; - margin-left: 16px; - transform: translateZ(0); - border-radius: var(--border-radius); - overflow: hidden; - box-shadow: 0px 2px 8px -1px #0000001a; -} - -.thirteen::before, -.thirteen::after { - content: ''; - position: absolute; - z-index: -1; -} - -/* Conic Gradient Animation */ -.thirteen::before { - animation: 6s rotate linear infinite; - width: 200%; - height: 200%; - background: var(--tile-border); -} - -/* Inner Square */ -.thirteen::after { - inset: 0; - padding: 1px; - border-radius: var(--border-radius); - background: linear-gradient(to bottom right, - rgba(var(--tile-start-rgb), 1), - rgba(var(--tile-end-rgb), 1)); - background-clip: content-box; -} - -/* Enable hover only on non-touch devices */ -@media (hover: hover) and (pointer: fine) { - .card:hover { - background: rgba(var(--card-rgb), 0.1); - border: 1px solid rgba(var(--card-border-rgb), 0.15); - } - - .card:hover span { - transform: translateX(4px); - } -} - -@media (prefers-reduced-motion) { - .thirteen::before { - animation: none; - } - - .card:hover span { - transform: none; - } -} - -/* Mobile and Tablet */ -@media (max-width: 1023px) { - .content { - padding: 4rem; - } - - .grid { - grid-template-columns: 1fr; - margin-bottom: 120px; - max-width: 320px; - text-align: center; - } - - .card { - padding: 1rem 2.5rem; - } - - .card h2 { - margin-bottom: 0.5rem; - } - - .center { - padding: 8rem 0 6rem; - } - - .center::before { - transform: none; - height: 300px; - } - - .description { - font-size: 0.8rem; - } - - .description a { - padding: 1rem; - } - - .description p, - .description div { - display: flex; - justify-content: center; - position: fixed; - width: 100%; - } - - .description p { - align-items: center; - inset: 0 0 auto; - padding: 2rem 1rem 1.4rem; - border-radius: 0; - border: none; - border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); - background: linear-gradient(to bottom, - rgba(var(--background-start-rgb), 1), - rgba(var(--callout-rgb), 0.5)); - background-clip: padding-box; - backdrop-filter: blur(24px); - } - - .description div { - align-items: flex-end; - pointer-events: none; - inset: auto 0 0; - padding: 2rem; - height: 200px; - background: linear-gradient(to bottom, - transparent 0%, - rgb(var(--background-end-rgb)) 40%); - z-index: 1; - } -} - -@media (prefers-color-scheme: dark) { - .vercelLogo { - filter: invert(1); - } - - .logo, - .thirteen img { - filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); - } -} - -@keyframes rotate { - from { - transform: rotate(360deg); - } - - to { - transform: rotate(0deg); - } -} From 822374eca5776683add1978df3ac8cc9c0845ecd Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sun, 4 Jan 2026 11:20:06 +0800 Subject: [PATCH 02/33] chore: integrate @tanstack/eslint-plugin-query and fix service layer lint errors (#30444) --- web/app/components/goto-anything/index.tsx | 2 +- web/eslint.config.mjs | 2 ++ web/package.json | 1 + web/pnpm-lock.yaml | 30 +++++++++++++++++----- web/service/access-control.ts | 2 +- web/service/knowledge/use-document.ts | 6 ++--- web/service/knowledge/use-segment.ts | 8 +++--- web/service/use-explore.ts | 2 +- web/service/use-models.ts | 2 +- web/service/use-pipeline.ts | 3 +-- web/service/use-plugins.ts | 11 ++++---- 11 files changed, 44 insertions(+), 25 deletions(-) diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index 30add3480d..d34176e4c7 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -110,7 +110,7 @@ const GotoAnything: FC = ({ isWorkflowPage, isRagPipelinePage, defaultLocale, - Object.keys(Actions).sort().join(','), + Actions, ], queryFn: async () => { const query = searchQueryDebouncedValue.toLowerCase() diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 574dbb091e..3cdd3efedb 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -1,5 +1,6 @@ // @ts-check import antfu from '@antfu/eslint-config' +import pluginQuery from '@tanstack/eslint-plugin-query' import sonar from 'eslint-plugin-sonarjs' import storybook from 'eslint-plugin-storybook' import tailwind from 'eslint-plugin-tailwindcss' @@ -79,6 +80,7 @@ export default antfu( }, }, storybook.configs['flat/recommended'], + ...pluginQuery.configs['flat/recommended'], // sonar { rules: { diff --git a/web/package.json b/web/package.json index 7ee2325dbc..b595d433f9 100644 --- a/web/package.json +++ b/web/package.json @@ -165,6 +165,7 @@ "@storybook/addon-themes": "9.1.13", "@storybook/nextjs": "9.1.13", "@storybook/react": "9.1.13", + "@tanstack/eslint-plugin-query": "^5.91.2", "@tanstack/react-devtools": "^0.9.0", "@tanstack/react-form-devtools": "^0.2.9", "@tanstack/react-query-devtools": "^5.90.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index cdd194da37..9ffb092c6e 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -406,6 +406,9 @@ importers: '@storybook/react': specifier: 9.1.13 version: 9.1.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@9.1.17(@testing-library/dom@10.4.1)(vite@7.3.0(@types/node@18.15.0)(jiti@1.21.7)(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.9.3) + '@tanstack/eslint-plugin-query': + specifier: ^5.91.2 + version: 5.91.2(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@tanstack/react-devtools': specifier: ^0.9.0 version: 0.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) @@ -3387,6 +3390,11 @@ packages: peerDependencies: solid-js: '>=1.9.7' + '@tanstack/eslint-plugin-query@5.91.2': + resolution: {integrity: sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw==} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + '@tanstack/form-core@1.27.1': resolution: {integrity: sha512-hPM+0tUnZ2C2zb2TE1lar1JJ0S0cbnQHlUwFcCnVBpMV3rjtUzkoM766gUpWrlmTGCzNad0GbJ0aTxVsjT6J8g==} @@ -10130,7 +10138,7 @@ snapshots: '@es-joy/jsdoccomment@0.76.0': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/types': 8.50.1 comment-parser: 1.4.1 esquery: 1.6.0 jsdoc-type-pratt-parser: 6.10.0 @@ -10138,7 +10146,7 @@ snapshots: '@es-joy/jsdoccomment@0.78.0': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/types': 8.50.1 comment-parser: 1.4.1 esquery: 1.6.0 jsdoc-type-pratt-parser: 7.0.0 @@ -11957,7 +11965,7 @@ snapshots: '@stylistic/eslint-plugin@5.6.1(eslint@9.39.2(jiti@1.21.7))': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) - '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/types': 8.50.1 eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 4.2.1 espree: 10.4.0 @@ -12039,6 +12047,14 @@ snapshots: - csstype - utf-8-validate + '@tanstack/eslint-plugin-query@5.91.2(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + transitivePeerDependencies: + - supports-color + - typescript + '@tanstack/form-core@1.27.1': dependencies: '@tanstack/devtools-event-client': 0.3.5 @@ -12512,8 +12528,8 @@ snapshots: '@typescript-eslint/project-service@8.50.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.9.3) - '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) + '@typescript-eslint/types': 8.50.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -12742,7 +12758,7 @@ snapshots: '@vitest/eslint-plugin@1.6.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.16(@types/node@18.15.0)(happy-dom@20.0.11)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.0))(sass@1.95.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/scope-manager': 8.50.1 '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) optionalDependencies: @@ -14240,7 +14256,7 @@ snapshots: eslint-plugin-perfectionist@4.15.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/types': 8.50.1 '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) natural-orderby: 5.0.0 diff --git a/web/service/access-control.ts b/web/service/access-control.ts index ad0c14fd0a..c87e01f482 100644 --- a/web/service/access-control.ts +++ b/web/service/access-control.ts @@ -73,7 +73,7 @@ export const useUpdateAccessMode = () => { export const useGetUserCanAccessApp = ({ appId, isInstalledApp = true, enabled }: { appId?: string, isInstalledApp?: boolean, enabled?: boolean }) => { const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) return useQuery({ - queryKey: [NAME_SPACE, 'user-can-access-app', appId], + queryKey: [NAME_SPACE, 'user-can-access-app', appId, systemFeatures.webapp_auth.enabled, isInstalledApp], queryFn: () => { if (systemFeatures.webapp_auth.enabled) return getUserCanAccess(appId!, isInstalledApp) diff --git a/web/service/knowledge/use-document.ts b/web/service/knowledge/use-document.ts index 63713130d9..1776ed1f4c 100644 --- a/web/service/knowledge/use-document.ts +++ b/web/service/knowledge/use-document.ts @@ -38,7 +38,7 @@ export const useDocumentList = (payload: { if (normalizedStatus && normalizedStatus !== 'all') params.status = normalizedStatus return useQuery({ - queryKey: [...useDocumentListKey, datasetId, keyword, page, limit, sort, normalizedStatus], + queryKey: [...useDocumentListKey, datasetId, params], queryFn: () => get(`/datasets/${datasetId}/documents`, { params, }), @@ -123,7 +123,7 @@ export const useDocumentDetail = (payload: { }) => { const { datasetId, documentId, params } = payload return useQuery({ - queryKey: [...useDocumentDetailKey, 'withoutMetaData', datasetId, documentId], + queryKey: [...useDocumentDetailKey, 'withoutMetaData', datasetId, documentId, params], queryFn: () => get(`/datasets/${datasetId}/documents/${documentId}`, { params }), }) } @@ -135,7 +135,7 @@ export const useDocumentMetadata = (payload: { }) => { const { datasetId, documentId, params } = payload return useQuery({ - queryKey: [...useDocumentDetailKey, 'onlyMetaData', datasetId, documentId], + queryKey: [...useDocumentDetailKey, 'onlyMetaData', datasetId, documentId, params], queryFn: () => get(`/datasets/${datasetId}/documents/${documentId}`, { params }), }) } diff --git a/web/service/knowledge/use-segment.ts b/web/service/knowledge/use-segment.ts index 1d0ce4b774..c42324ce6c 100644 --- a/web/service/knowledge/use-segment.ts +++ b/web/service/knowledge/use-segment.ts @@ -32,9 +32,9 @@ export const useSegmentList = ( disable?: boolean, ) => { const { datasetId, documentId, params } = payload - const { page, limit, keyword, enabled } = params + return useQuery({ - queryKey: [...useSegmentListKey, { datasetId, documentId, page, limit, keyword, enabled }], + queryKey: [...useSegmentListKey, datasetId, documentId, params], queryFn: () => { return get(`/datasets/${datasetId}/documents/${documentId}/segments`, { params }) }, @@ -111,9 +111,9 @@ export const useChildSegmentList = ( disable?: boolean, ) => { const { datasetId, documentId, segmentId, params } = payload - const { page, limit, keyword } = params + return useQuery({ - queryKey: [...useChildSegmentListKey, { datasetId, documentId, segmentId, page, limit, keyword }], + queryKey: [...useChildSegmentListKey, datasetId, documentId, segmentId, params], queryFn: () => { return get(`/datasets/${datasetId}/documents/${documentId}/segments/${segmentId}/child_chunks`, { params }) }, diff --git a/web/service/use-explore.ts b/web/service/use-explore.ts index 68ddf966ab..a15b926306 100644 --- a/web/service/use-explore.ts +++ b/web/service/use-explore.ts @@ -59,7 +59,7 @@ export const useUpdateAppPinStatus = () => { export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => { const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) return useQuery({ - queryKey: [NAME_SPACE, 'appAccessMode', appId], + queryKey: [NAME_SPACE, 'appAccessMode', appId, systemFeatures.webapp_auth.enabled], queryFn: () => { if (systemFeatures.webapp_auth.enabled === false) { return { diff --git a/web/service/use-models.ts b/web/service/use-models.ts index d960bda33f..05582b4105 100644 --- a/web/service/use-models.ts +++ b/web/service/use-models.ts @@ -82,7 +82,7 @@ export const useGetModelCredential = ( ) => { return useQuery({ enabled, - queryKey: [NAME_SPACE, 'model-list', provider, model, modelType, credentialId], + queryKey: [NAME_SPACE, 'model-list', provider, model, modelType, credentialId, configFrom], queryFn: () => get(`/workspaces/current/model-providers/${provider}/models/credentials?model=${model}&model_type=${modelType}&config_from=${configFrom}${credentialId ? `&credential_id=${credentialId}` : ''}`), staleTime: 0, gcTime: 0, diff --git a/web/service/use-pipeline.ts b/web/service/use-pipeline.ts index c1abbb1984..34c7332f4c 100644 --- a/web/service/use-pipeline.ts +++ b/web/service/use-pipeline.ts @@ -40,9 +40,8 @@ const NAME_SPACE = 'pipeline' export const PipelineTemplateListQueryKeyPrefix = [NAME_SPACE, 'template-list'] export const usePipelineTemplateList = (params: PipelineTemplateListParams, enabled = true) => { - const { type, language } = params return useQuery({ - queryKey: [...PipelineTemplateListQueryKeyPrefix, type, language], + queryKey: [...PipelineTemplateListQueryKeyPrefix, params], queryFn: () => { return get('/rag/pipeline/templates', { params }) }, diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 5c10bac5d2..4e9776df97 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -677,20 +677,21 @@ export const useMutationCheckDependencies = () => { } export const useModelInList = (currentProvider?: ModelProvider, modelId?: string) => { + const provider = currentProvider?.provider return useQuery({ - queryKey: ['modelInList', currentProvider?.provider, modelId], + queryKey: ['modelInList', provider, modelId], queryFn: async () => { - if (!modelId || !currentProvider) + if (!modelId || !provider) return false try { - const modelsData = await fetchModelProviderModelList(`/workspaces/current/model-providers/${currentProvider?.provider}/models`) + const modelsData = await fetchModelProviderModelList(`/workspaces/current/model-providers/${provider}/models`) return !!modelId && !!modelsData.data.find(item => item.model === modelId) } catch { return false } }, - enabled: !!modelId && !!currentProvider, + enabled: !!modelId && !!provider, }) } @@ -742,7 +743,7 @@ export const usePluginReadme = ({ plugin_unique_identifier, language }: { plugin export const usePluginReadmeAsset = ({ file_name, plugin_unique_identifier }: { file_name?: string, plugin_unique_identifier?: string }) => { const normalizedFileName = file_name?.replace(/(^\.\/_assets\/|^_assets\/)/, '') return useQuery({ - queryKey: ['pluginReadmeAsset', plugin_unique_identifier, file_name], + queryKey: ['pluginReadmeAsset', plugin_unique_identifier, normalizedFileName], queryFn: () => get('/workspaces/current/plugin/asset', { params: { plugin_unique_identifier, file_name: normalizedFileName } }, { silent: true }), enabled: !!plugin_unique_identifier && !!file_name && /(^\.\/_assets|^_assets)/.test(file_name), }) From 5362f69083f7ee3ae6b69bce92488421ae5b8793 Mon Sep 17 00:00:00 2001 From: "Byron.wang" Date: Sat, 3 Jan 2026 19:46:46 -0800 Subject: [PATCH 03/33] feat(refactoring): Support Structured Logging (JSON) (#30170) --- api/.env.example | 2 + api/app_factory.py | 29 +- api/configs/feature/__init__.py | 5 + api/core/helper/ssrf_proxy.py | 45 ++- api/core/helper/trace_id_helper.py | 57 ++++ api/core/logging/__init__.py | 20 ++ api/core/logging/context.py | 35 +++ api/core/logging/filters.py | 94 ++++++ api/core/logging/structured_formatter.py | 107 +++++++ api/core/plugin/impl/base.py | 28 ++ api/extensions/ext_celery.py | 4 + api/extensions/ext_logging.py | 105 +++++-- api/extensions/otel/instrumentation.py | 53 ++-- api/libs/external_api.py | 8 +- .../unit_tests/core/helper/test_ssrf_proxy.py | 115 ++++++-- api/tests/unit_tests/core/logging/__init__.py | 0 .../unit_tests/core/logging/test_context.py | 79 ++++++ .../unit_tests/core/logging/test_filters.py | 114 ++++++++ .../core/logging/test_structured_formatter.py | 267 ++++++++++++++++++ .../core/logging/test_trace_helpers.py | 102 +++++++ .../unit_tests/libs/test_external_api.py | 33 +-- docker/.env.example | 2 + docker/docker-compose.middleware.yaml | 1 + docker/docker-compose.yaml | 1 + 24 files changed, 1193 insertions(+), 113 deletions(-) create mode 100644 api/core/logging/__init__.py create mode 100644 api/core/logging/context.py create mode 100644 api/core/logging/filters.py create mode 100644 api/core/logging/structured_formatter.py create mode 100644 api/tests/unit_tests/core/logging/__init__.py create mode 100644 api/tests/unit_tests/core/logging/test_context.py create mode 100644 api/tests/unit_tests/core/logging/test_filters.py create mode 100644 api/tests/unit_tests/core/logging/test_structured_formatter.py create mode 100644 api/tests/unit_tests/core/logging/test_trace_helpers.py diff --git a/api/.env.example b/api/.env.example index 5f8d369ec4..88611e016e 100644 --- a/api/.env.example +++ b/api/.env.example @@ -502,6 +502,8 @@ LOG_FILE_BACKUP_COUNT=5 LOG_DATEFORMAT=%Y-%m-%d %H:%M:%S # Log Timezone LOG_TZ=UTC +# Log output format: text or json +LOG_OUTPUT_FORMAT=text # Log format LOG_FORMAT=%(asctime)s,%(msecs)d %(levelname)-2s [%(filename)s:%(lineno)d] %(req_id)s %(message)s diff --git a/api/app_factory.py b/api/app_factory.py index bcad88e9e0..f827842d68 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -2,9 +2,11 @@ import logging import time from opentelemetry.trace import get_current_span +from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID from configs import dify_config from contexts.wrapper import RecyclableContextVar +from core.logging.context import init_request_context from dify_app import DifyApp logger = logging.getLogger(__name__) @@ -25,28 +27,35 @@ def create_flask_app_with_configs() -> DifyApp: # add before request hook @dify_app.before_request def before_request(): - # add an unique identifier to each request + # Initialize logging context for this request + init_request_context() RecyclableContextVar.increment_thread_recycles() - # add after request hook for injecting X-Trace-Id header from OpenTelemetry span context + # add after request hook for injecting trace headers from OpenTelemetry span context + # Only adds headers when OTEL is enabled and has valid context @dify_app.after_request - def add_trace_id_header(response): + def add_trace_headers(response): try: span = get_current_span() ctx = span.get_span_context() if span else None - if ctx and ctx.is_valid: - trace_id_hex = format(ctx.trace_id, "032x") - # Avoid duplicates if some middleware added it - if "X-Trace-Id" not in response.headers: - response.headers["X-Trace-Id"] = trace_id_hex + + if not ctx or not ctx.is_valid: + return response + + # Inject trace headers from OTEL context + if ctx.trace_id != INVALID_TRACE_ID and "X-Trace-Id" not in response.headers: + response.headers["X-Trace-Id"] = format(ctx.trace_id, "032x") + if ctx.span_id != INVALID_SPAN_ID and "X-Span-Id" not in response.headers: + response.headers["X-Span-Id"] = format(ctx.span_id, "016x") + except Exception: # Never break the response due to tracing header injection - logger.warning("Failed to add trace ID to response header", exc_info=True) + logger.warning("Failed to add trace headers to response", exc_info=True) return response # Capture the decorator's return value to avoid pyright reportUnusedFunction _ = before_request - _ = add_trace_id_header + _ = add_trace_headers return dify_app diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 43dddbd011..6a04171d2d 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -587,6 +587,11 @@ class LoggingConfig(BaseSettings): default="INFO", ) + LOG_OUTPUT_FORMAT: Literal["text", "json"] = Field( + description="Log output format: 'text' for human-readable, 'json' for structured JSON logs.", + default="text", + ) + LOG_FILE: str | None = Field( description="File path for log output.", default=None, diff --git a/api/core/helper/ssrf_proxy.py b/api/core/helper/ssrf_proxy.py index 0b36969cf9..1785cbde4c 100644 --- a/api/core/helper/ssrf_proxy.py +++ b/api/core/helper/ssrf_proxy.py @@ -88,7 +88,41 @@ def _get_user_provided_host_header(headers: dict | None) -> str | None: return None +def _inject_trace_headers(headers: dict | None) -> dict: + """ + Inject W3C traceparent header for distributed tracing. + + When OTEL is enabled, HTTPXClientInstrumentor handles trace propagation automatically. + When OTEL is disabled, we manually inject the traceparent header. + """ + if headers is None: + headers = {} + + # Skip if already present (case-insensitive check) + for key in headers: + if key.lower() == "traceparent": + return headers + + # Skip if OTEL is enabled - HTTPXClientInstrumentor handles this automatically + if dify_config.ENABLE_OTEL: + return headers + + # Generate and inject traceparent for non-OTEL scenarios + try: + from core.helper.trace_id_helper import generate_traceparent_header + + traceparent = generate_traceparent_header() + if traceparent: + headers["traceparent"] = traceparent + except Exception: + # Silently ignore errors to avoid breaking requests + logger.debug("Failed to generate traceparent header", exc_info=True) + + return headers + + def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs): + # Convert requests-style allow_redirects to httpx-style follow_redirects if "allow_redirects" in kwargs: allow_redirects = kwargs.pop("allow_redirects") if "follow_redirects" not in kwargs: @@ -106,18 +140,21 @@ def make_request(method, url, max_retries=SSRF_DEFAULT_MAX_RETRIES, **kwargs): verify_option = kwargs.pop("ssl_verify", dify_config.HTTP_REQUEST_NODE_SSL_VERIFY) client = _get_ssrf_client(verify_option) + # Inject traceparent header for distributed tracing (when OTEL is not enabled) + headers = kwargs.get("headers") or {} + headers = _inject_trace_headers(headers) + kwargs["headers"] = headers + # Preserve user-provided Host header # When using a forward proxy, httpx may override the Host header based on the URL. # We extract and preserve any explicitly set Host header to support virtual hosting. - headers = kwargs.get("headers", {}) user_provided_host = _get_user_provided_host_header(headers) retries = 0 while retries <= max_retries: try: - # Build the request manually to preserve the Host header - # httpx may override the Host header when using a proxy, so we use - # the request API to explicitly set headers before sending + # Preserve the user-provided Host header + # httpx may override the Host header when using a proxy headers = {k: v for k, v in headers.items() if k.lower() != "host"} if user_provided_host is not None: headers["host"] = user_provided_host diff --git a/api/core/helper/trace_id_helper.py b/api/core/helper/trace_id_helper.py index 820502e558..e827859109 100644 --- a/api/core/helper/trace_id_helper.py +++ b/api/core/helper/trace_id_helper.py @@ -103,3 +103,60 @@ def parse_traceparent_header(traceparent: str) -> str | None: if len(parts) == 4 and len(parts[1]) == 32: return parts[1] return None + + +def get_span_id_from_otel_context() -> str | None: + """ + Retrieve the current span ID from the active OpenTelemetry trace context. + + Returns: + A 16-character hex string representing the span ID, or None if not available. + """ + try: + from opentelemetry.trace import get_current_span + from opentelemetry.trace.span import INVALID_SPAN_ID + + span = get_current_span() + if not span: + return None + + span_context = span.get_span_context() + if not span_context or span_context.span_id == INVALID_SPAN_ID: + return None + + return f"{span_context.span_id:016x}" + except Exception: + return None + + +def generate_traceparent_header() -> str | None: + """ + Generate a W3C traceparent header from the current context. + + Uses OpenTelemetry context if available, otherwise uses the + ContextVar-based trace_id from the logging context. + + Format: {version}-{trace_id}-{span_id}-{flags} + Example: 00-5b8aa5a2d2c872e8321cf37308d69df2-051581bf3bb55c45-01 + + Returns: + A valid traceparent header string, or None if generation fails. + """ + import uuid + + # Try OTEL context first + trace_id = get_trace_id_from_otel_context() + span_id = get_span_id_from_otel_context() + + if trace_id and span_id: + return f"00-{trace_id}-{span_id}-01" + + # Fallback: use ContextVar-based trace_id or generate new one + from core.logging.context import get_trace_id as get_logging_trace_id + + trace_id = get_logging_trace_id() or uuid.uuid4().hex + + # Generate a new span_id (16 hex chars) + span_id = uuid.uuid4().hex[:16] + + return f"00-{trace_id}-{span_id}-01" diff --git a/api/core/logging/__init__.py b/api/core/logging/__init__.py new file mode 100644 index 0000000000..db046cc9fa --- /dev/null +++ b/api/core/logging/__init__.py @@ -0,0 +1,20 @@ +"""Structured logging components for Dify.""" + +from core.logging.context import ( + clear_request_context, + get_request_id, + get_trace_id, + init_request_context, +) +from core.logging.filters import IdentityContextFilter, TraceContextFilter +from core.logging.structured_formatter import StructuredJSONFormatter + +__all__ = [ + "IdentityContextFilter", + "StructuredJSONFormatter", + "TraceContextFilter", + "clear_request_context", + "get_request_id", + "get_trace_id", + "init_request_context", +] diff --git a/api/core/logging/context.py b/api/core/logging/context.py new file mode 100644 index 0000000000..18633a0b05 --- /dev/null +++ b/api/core/logging/context.py @@ -0,0 +1,35 @@ +"""Request context for logging - framework agnostic. + +This module provides request-scoped context variables for logging, +using Python's contextvars for thread-safe and async-safe storage. +""" + +import uuid +from contextvars import ContextVar + +_request_id: ContextVar[str] = ContextVar("log_request_id", default="") +_trace_id: ContextVar[str] = ContextVar("log_trace_id", default="") + + +def get_request_id() -> str: + """Get current request ID (10 hex chars).""" + return _request_id.get() + + +def get_trace_id() -> str: + """Get fallback trace ID when OTEL is unavailable (32 hex chars).""" + return _trace_id.get() + + +def init_request_context() -> None: + """Initialize request context. Call at start of each request.""" + req_id = uuid.uuid4().hex[:10] + trace_id = uuid.uuid5(uuid.NAMESPACE_DNS, req_id).hex + _request_id.set(req_id) + _trace_id.set(trace_id) + + +def clear_request_context() -> None: + """Clear request context. Call at end of request (optional).""" + _request_id.set("") + _trace_id.set("") diff --git a/api/core/logging/filters.py b/api/core/logging/filters.py new file mode 100644 index 0000000000..1e8aa8d566 --- /dev/null +++ b/api/core/logging/filters.py @@ -0,0 +1,94 @@ +"""Logging filters for structured logging.""" + +import contextlib +import logging + +import flask + +from core.logging.context import get_request_id, get_trace_id + + +class TraceContextFilter(logging.Filter): + """ + Filter that adds trace_id and span_id to log records. + Integrates with OpenTelemetry when available, falls back to ContextVar-based trace_id. + """ + + def filter(self, record: logging.LogRecord) -> bool: + # Get trace context from OpenTelemetry + trace_id, span_id = self._get_otel_context() + + # Set trace_id (fallback to ContextVar if no OTEL context) + if trace_id: + record.trace_id = trace_id + else: + record.trace_id = get_trace_id() + + record.span_id = span_id or "" + + # For backward compatibility, also set req_id + record.req_id = get_request_id() + + return True + + def _get_otel_context(self) -> tuple[str, str]: + """Extract trace_id and span_id from OpenTelemetry context.""" + with contextlib.suppress(Exception): + from opentelemetry.trace import get_current_span + from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID + + span = get_current_span() + if span and span.get_span_context(): + ctx = span.get_span_context() + if ctx.is_valid and ctx.trace_id != INVALID_TRACE_ID: + trace_id = f"{ctx.trace_id:032x}" + span_id = f"{ctx.span_id:016x}" if ctx.span_id != INVALID_SPAN_ID else "" + return trace_id, span_id + return "", "" + + +class IdentityContextFilter(logging.Filter): + """ + Filter that adds user identity context to log records. + Extracts tenant_id, user_id, and user_type from Flask-Login current_user. + """ + + def filter(self, record: logging.LogRecord) -> bool: + identity = self._extract_identity() + record.tenant_id = identity.get("tenant_id", "") + record.user_id = identity.get("user_id", "") + record.user_type = identity.get("user_type", "") + return True + + def _extract_identity(self) -> dict[str, str]: + """Extract identity from current_user if in request context.""" + try: + if not flask.has_request_context(): + return {} + from flask_login import current_user + + # Check if user is authenticated using the proxy + if not current_user.is_authenticated: + return {} + + # Access the underlying user object + user = current_user + + from models import Account + from models.model import EndUser + + identity: dict[str, str] = {} + + if isinstance(user, Account): + if user.current_tenant_id: + identity["tenant_id"] = user.current_tenant_id + identity["user_id"] = user.id + identity["user_type"] = "account" + elif isinstance(user, EndUser): + identity["tenant_id"] = user.tenant_id + identity["user_id"] = user.id + identity["user_type"] = user.type or "end_user" + + return identity + except Exception: + return {} diff --git a/api/core/logging/structured_formatter.py b/api/core/logging/structured_formatter.py new file mode 100644 index 0000000000..4295d2dd34 --- /dev/null +++ b/api/core/logging/structured_formatter.py @@ -0,0 +1,107 @@ +"""Structured JSON log formatter for Dify.""" + +import logging +import traceback +from datetime import UTC, datetime +from typing import Any + +import orjson + +from configs import dify_config + + +class StructuredJSONFormatter(logging.Formatter): + """ + JSON log formatter following the specified schema: + { + "ts": "ISO 8601 UTC", + "severity": "INFO|ERROR|WARN|DEBUG", + "service": "service name", + "caller": "file:line", + "trace_id": "hex 32", + "span_id": "hex 16", + "identity": { "tenant_id", "user_id", "user_type" }, + "message": "log message", + "attributes": { ... }, + "stack_trace": "..." + } + """ + + SEVERITY_MAP: dict[int, str] = { + logging.DEBUG: "DEBUG", + logging.INFO: "INFO", + logging.WARNING: "WARN", + logging.ERROR: "ERROR", + logging.CRITICAL: "ERROR", + } + + def __init__(self, service_name: str | None = None): + super().__init__() + self._service_name = service_name or dify_config.APPLICATION_NAME + + def format(self, record: logging.LogRecord) -> str: + log_dict = self._build_log_dict(record) + try: + return orjson.dumps(log_dict).decode("utf-8") + except TypeError: + # Fallback: convert non-serializable objects to string + import json + + return json.dumps(log_dict, default=str, ensure_ascii=False) + + def _build_log_dict(self, record: logging.LogRecord) -> dict[str, Any]: + # Core fields + log_dict: dict[str, Any] = { + "ts": datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z"), + "severity": self.SEVERITY_MAP.get(record.levelno, "INFO"), + "service": self._service_name, + "caller": f"{record.filename}:{record.lineno}", + "message": record.getMessage(), + } + + # Trace context (from TraceContextFilter) + trace_id = getattr(record, "trace_id", "") + span_id = getattr(record, "span_id", "") + + if trace_id: + log_dict["trace_id"] = trace_id + if span_id: + log_dict["span_id"] = span_id + + # Identity context (from IdentityContextFilter) + identity = self._extract_identity(record) + if identity: + log_dict["identity"] = identity + + # Dynamic attributes + attributes = getattr(record, "attributes", None) + if attributes: + log_dict["attributes"] = attributes + + # Stack trace for errors with exceptions + if record.exc_info and record.levelno >= logging.ERROR: + log_dict["stack_trace"] = self._format_exception(record.exc_info) + + return log_dict + + def _extract_identity(self, record: logging.LogRecord) -> dict[str, str] | None: + tenant_id = getattr(record, "tenant_id", None) + user_id = getattr(record, "user_id", None) + user_type = getattr(record, "user_type", None) + + if not any([tenant_id, user_id, user_type]): + return None + + identity: dict[str, str] = {} + if tenant_id: + identity["tenant_id"] = tenant_id + if user_id: + identity["user_id"] = user_id + if user_type: + identity["user_type"] = user_type + return identity + + def _format_exception(self, exc_info: tuple[Any, ...]) -> str: + if exc_info and exc_info[0] is not None: + return "".join(traceback.format_exception(*exc_info)) + return "" diff --git a/api/core/plugin/impl/base.py b/api/core/plugin/impl/base.py index 7bb2749afa..0e49824ad0 100644 --- a/api/core/plugin/impl/base.py +++ b/api/core/plugin/impl/base.py @@ -103,6 +103,9 @@ class BasePluginClient: prepared_headers["X-Api-Key"] = dify_config.PLUGIN_DAEMON_KEY prepared_headers.setdefault("Accept-Encoding", "gzip, deflate, br") + # Inject traceparent header for distributed tracing + self._inject_trace_headers(prepared_headers) + prepared_data: bytes | dict[str, Any] | str | None = ( data if isinstance(data, (bytes, str, dict)) or data is None else None ) @@ -114,6 +117,31 @@ class BasePluginClient: return str(url), prepared_headers, prepared_data, params, files + def _inject_trace_headers(self, headers: dict[str, str]) -> None: + """ + Inject W3C traceparent header for distributed tracing. + + This ensures trace context is propagated to plugin daemon even if + HTTPXClientInstrumentor doesn't cover module-level httpx functions. + """ + if not dify_config.ENABLE_OTEL: + return + + import contextlib + + # Skip if already present (case-insensitive check) + for key in headers: + if key.lower() == "traceparent": + return + + # Inject traceparent - works as fallback when OTEL instrumentation doesn't cover this call + with contextlib.suppress(Exception): + from core.helper.trace_id_helper import generate_traceparent_header + + traceparent = generate_traceparent_header() + if traceparent: + headers["traceparent"] = traceparent + def _stream_request( self, method: str, diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 764df5d178..2fbab001d0 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -46,7 +46,11 @@ def _get_celery_ssl_options() -> dict[str, Any] | None: def init_app(app: DifyApp) -> Celery: class FlaskTask(Task): def __call__(self, *args: object, **kwargs: object) -> object: + from core.logging.context import init_request_context + with app.app_context(): + # Initialize logging context for this task (similar to before_request in Flask) + init_request_context() return self.run(*args, **kwargs) broker_transport_options = {} diff --git a/api/extensions/ext_logging.py b/api/extensions/ext_logging.py index 000d03ac41..978a40c503 100644 --- a/api/extensions/ext_logging.py +++ b/api/extensions/ext_logging.py @@ -1,18 +1,19 @@ +"""Logging extension for Dify Flask application.""" + import logging import os import sys -import uuid from logging.handlers import RotatingFileHandler -import flask - from configs import dify_config -from core.helper.trace_id_helper import get_trace_id_from_otel_context from dify_app import DifyApp def init_app(app: DifyApp): + """Initialize logging with support for text or JSON format.""" log_handlers: list[logging.Handler] = [] + + # File handler log_file = dify_config.LOG_FILE if log_file: log_dir = os.path.dirname(log_file) @@ -25,27 +26,53 @@ def init_app(app: DifyApp): ) ) - # Always add StreamHandler to log to console + # Console handler sh = logging.StreamHandler(sys.stdout) log_handlers.append(sh) - # Apply RequestIdFilter to all handlers - for handler in log_handlers: - handler.addFilter(RequestIdFilter()) + # Apply filters to all handlers + from core.logging.filters import IdentityContextFilter, TraceContextFilter + for handler in log_handlers: + handler.addFilter(TraceContextFilter()) + handler.addFilter(IdentityContextFilter()) + + # Configure formatter based on format type + formatter = _create_formatter() + for handler in log_handlers: + handler.setFormatter(formatter) + + # Configure root logger logging.basicConfig( level=dify_config.LOG_LEVEL, - format=dify_config.LOG_FORMAT, - datefmt=dify_config.LOG_DATEFORMAT, handlers=log_handlers, force=True, ) - # Apply RequestIdFormatter to all handlers - apply_request_id_formatter() - # Disable propagation for noisy loggers to avoid duplicate logs logging.getLogger("sqlalchemy.engine").propagate = False + + # Apply timezone if specified (only for text format) + if dify_config.LOG_OUTPUT_FORMAT == "text": + _apply_timezone(log_handlers) + + +def _create_formatter() -> logging.Formatter: + """Create appropriate formatter based on configuration.""" + if dify_config.LOG_OUTPUT_FORMAT == "json": + from core.logging.structured_formatter import StructuredJSONFormatter + + return StructuredJSONFormatter() + else: + # Text format - use existing pattern with backward compatible formatter + return _TextFormatter( + fmt=dify_config.LOG_FORMAT, + datefmt=dify_config.LOG_DATEFORMAT, + ) + + +def _apply_timezone(handlers: list[logging.Handler]): + """Apply timezone conversion to text formatters.""" log_tz = dify_config.LOG_TZ if log_tz: from datetime import datetime @@ -57,34 +84,51 @@ def init_app(app: DifyApp): def time_converter(seconds): return datetime.fromtimestamp(seconds, tz=timezone).timetuple() - for handler in logging.root.handlers: + for handler in handlers: if handler.formatter: - handler.formatter.converter = time_converter + handler.formatter.converter = time_converter # type: ignore[attr-defined] -def get_request_id(): - if getattr(flask.g, "request_id", None): - return flask.g.request_id +class _TextFormatter(logging.Formatter): + """Text formatter that ensures trace_id and req_id are always present.""" - new_uuid = uuid.uuid4().hex[:10] - flask.g.request_id = new_uuid - - return new_uuid + def format(self, record: logging.LogRecord) -> str: + if not hasattr(record, "req_id"): + record.req_id = "" + if not hasattr(record, "trace_id"): + record.trace_id = "" + if not hasattr(record, "span_id"): + record.span_id = "" + return super().format(record) +def get_request_id() -> str: + """Get request ID for current request context. + + Deprecated: Use core.logging.context.get_request_id() directly. + """ + from core.logging.context import get_request_id as _get_request_id + + return _get_request_id() + + +# Backward compatibility aliases class RequestIdFilter(logging.Filter): - # This is a logging filter that makes the request ID available for use in - # the logging format. Note that we're checking if we're in a request - # context, as we may want to log things before Flask is fully loaded. - def filter(self, record): - trace_id = get_trace_id_from_otel_context() or "" - record.req_id = get_request_id() if flask.has_request_context() else "" - record.trace_id = trace_id + """Deprecated: Use TraceContextFilter from core.logging.filters instead.""" + + def filter(self, record: logging.LogRecord) -> bool: + from core.logging.context import get_request_id as _get_request_id + from core.logging.context import get_trace_id as _get_trace_id + + record.req_id = _get_request_id() + record.trace_id = _get_trace_id() return True class RequestIdFormatter(logging.Formatter): - def format(self, record): + """Deprecated: Use _TextFormatter instead.""" + + def format(self, record: logging.LogRecord) -> str: if not hasattr(record, "req_id"): record.req_id = "" if not hasattr(record, "trace_id"): @@ -93,6 +137,7 @@ class RequestIdFormatter(logging.Formatter): def apply_request_id_formatter(): + """Deprecated: Formatter is now applied in init_app.""" for handler in logging.root.handlers: if handler.formatter: handler.formatter = RequestIdFormatter(dify_config.LOG_FORMAT, dify_config.LOG_DATEFORMAT) diff --git a/api/extensions/otel/instrumentation.py b/api/extensions/otel/instrumentation.py index 3597110cba..6617f69513 100644 --- a/api/extensions/otel/instrumentation.py +++ b/api/extensions/otel/instrumentation.py @@ -19,26 +19,43 @@ logger = logging.getLogger(__name__) class ExceptionLoggingHandler(logging.Handler): + """ + Handler that records exceptions to the current OpenTelemetry span. + + Unlike creating a new span, this records exceptions on the existing span + to maintain trace context consistency throughout the request lifecycle. + """ + def emit(self, record: logging.LogRecord): with contextlib.suppress(Exception): - if record.exc_info: - tracer = get_tracer_provider().get_tracer("dify.exception.logging") - with tracer.start_as_current_span( - "log.exception", - attributes={ - "log.level": record.levelname, - "log.message": record.getMessage(), - "log.logger": record.name, - "log.file.path": record.pathname, - "log.file.line": record.lineno, - }, - ) as span: - span.set_status(StatusCode.ERROR) - if record.exc_info[1]: - span.record_exception(record.exc_info[1]) - span.set_attribute("exception.message", str(record.exc_info[1])) - if record.exc_info[0]: - span.set_attribute("exception.type", record.exc_info[0].__name__) + if not record.exc_info: + return + + from opentelemetry.trace import get_current_span + + span = get_current_span() + if not span or not span.is_recording(): + return + + # Record exception on the current span instead of creating a new one + span.set_status(StatusCode.ERROR, record.getMessage()) + + # Add log context as span events/attributes + span.add_event( + "log.exception", + attributes={ + "log.level": record.levelname, + "log.message": record.getMessage(), + "log.logger": record.name, + "log.file.path": record.pathname, + "log.file.line": record.lineno, + }, + ) + + if record.exc_info[1]: + span.record_exception(record.exc_info[1]) + if record.exc_info[0]: + span.set_attribute("exception.type", record.exc_info[0].__name__) def instrument_exception_logging() -> None: diff --git a/api/libs/external_api.py b/api/libs/external_api.py index 61a90ee4a9..e8592407c3 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -1,5 +1,4 @@ import re -import sys from collections.abc import Mapping from typing import Any @@ -109,11 +108,8 @@ def register_external_error_handlers(api: Api): data.setdefault("code", "unknown") data.setdefault("status", status_code) - # Log stack - exc_info: Any = sys.exc_info() - if exc_info[1] is None: - exc_info = (None, None, None) - current_app.log_exception(exc_info) + # Note: Exception logging is handled by Flask/Flask-RESTX framework automatically + # Explicit log_exception call removed to avoid duplicate log entries return data, status_code diff --git a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py index beae1d0358..d6d75fb72f 100644 --- a/api/tests/unit_tests/core/helper/test_ssrf_proxy.py +++ b/api/tests/unit_tests/core/helper/test_ssrf_proxy.py @@ -14,12 +14,12 @@ def test_successful_request(mock_get_client): mock_client = MagicMock() mock_response = MagicMock() mock_response.status_code = 200 - mock_client.send.return_value = mock_response mock_client.request.return_value = mock_response mock_get_client.return_value = mock_client response = make_request("GET", "http://example.com") assert response.status_code == 200 + mock_client.request.assert_called_once() @patch("core.helper.ssrf_proxy._get_ssrf_client") @@ -27,7 +27,6 @@ def test_retry_exceed_max_retries(mock_get_client): mock_client = MagicMock() mock_response = MagicMock() mock_response.status_code = 500 - mock_client.send.return_value = mock_response mock_client.request.return_value = mock_response mock_get_client.return_value = mock_client @@ -72,34 +71,12 @@ class TestGetUserProvidedHostHeader: assert result in ("first.com", "second.com") -@patch("core.helper.ssrf_proxy._get_ssrf_client") -def test_host_header_preservation_without_user_header(mock_get_client): - """Test that when no Host header is provided, the default behavior is maintained.""" - mock_client = MagicMock() - mock_request = MagicMock() - mock_request.headers = {} - mock_response = MagicMock() - mock_response.status_code = 200 - mock_client.send.return_value = mock_response - mock_client.request.return_value = mock_response - mock_get_client.return_value = mock_client - - response = make_request("GET", "http://example.com") - - assert response.status_code == 200 - # Host should not be set if not provided by user - assert "Host" not in mock_request.headers or mock_request.headers.get("Host") is None - - @patch("core.helper.ssrf_proxy._get_ssrf_client") def test_host_header_preservation_with_user_header(mock_get_client): """Test that user-provided Host header is preserved in the request.""" mock_client = MagicMock() - mock_request = MagicMock() - mock_request.headers = {} mock_response = MagicMock() mock_response.status_code = 200 - mock_client.send.return_value = mock_response mock_client.request.return_value = mock_response mock_get_client.return_value = mock_client @@ -107,3 +84,93 @@ def test_host_header_preservation_with_user_header(mock_get_client): response = make_request("GET", "http://example.com", headers={"Host": custom_host}) assert response.status_code == 200 + # Verify client.request was called with the host header preserved (lowercase) + call_kwargs = mock_client.request.call_args.kwargs + assert call_kwargs["headers"]["host"] == custom_host + + +@patch("core.helper.ssrf_proxy._get_ssrf_client") +@pytest.mark.parametrize("host_key", ["host", "HOST", "Host"]) +def test_host_header_preservation_case_insensitive(mock_get_client, host_key): + """Test that Host header is preserved regardless of case.""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.request.return_value = mock_response + mock_get_client.return_value = mock_client + + response = make_request("GET", "http://example.com", headers={host_key: "api.example.com"}) + + assert response.status_code == 200 + # Host header should be normalized to lowercase "host" + call_kwargs = mock_client.request.call_args.kwargs + assert call_kwargs["headers"]["host"] == "api.example.com" + + +class TestFollowRedirectsParameter: + """Tests for follow_redirects parameter handling. + + These tests verify that follow_redirects is correctly passed to client.request(). + """ + + @patch("core.helper.ssrf_proxy._get_ssrf_client") + def test_follow_redirects_passed_to_request(self, mock_get_client): + """Verify follow_redirects IS passed to client.request().""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.request.return_value = mock_response + mock_get_client.return_value = mock_client + + make_request("GET", "http://example.com", follow_redirects=True) + + # Verify follow_redirects was passed to request + call_kwargs = mock_client.request.call_args.kwargs + assert call_kwargs.get("follow_redirects") is True + + @patch("core.helper.ssrf_proxy._get_ssrf_client") + def test_allow_redirects_converted_to_follow_redirects(self, mock_get_client): + """Verify allow_redirects (requests-style) is converted to follow_redirects (httpx-style).""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.request.return_value = mock_response + mock_get_client.return_value = mock_client + + # Use allow_redirects (requests-style parameter) + make_request("GET", "http://example.com", allow_redirects=True) + + # Verify it was converted to follow_redirects + call_kwargs = mock_client.request.call_args.kwargs + assert call_kwargs.get("follow_redirects") is True + assert "allow_redirects" not in call_kwargs + + @patch("core.helper.ssrf_proxy._get_ssrf_client") + def test_follow_redirects_not_set_when_not_specified(self, mock_get_client): + """Verify follow_redirects is not in kwargs when not specified (httpx default behavior).""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.request.return_value = mock_response + mock_get_client.return_value = mock_client + + make_request("GET", "http://example.com") + + # follow_redirects should not be in kwargs, letting httpx use its default + call_kwargs = mock_client.request.call_args.kwargs + assert "follow_redirects" not in call_kwargs + + @patch("core.helper.ssrf_proxy._get_ssrf_client") + def test_follow_redirects_takes_precedence_over_allow_redirects(self, mock_get_client): + """Verify follow_redirects takes precedence when both are specified.""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.request.return_value = mock_response + mock_get_client.return_value = mock_client + + # Both specified - follow_redirects should take precedence + make_request("GET", "http://example.com", allow_redirects=False, follow_redirects=True) + + call_kwargs = mock_client.request.call_args.kwargs + assert call_kwargs.get("follow_redirects") is True diff --git a/api/tests/unit_tests/core/logging/__init__.py b/api/tests/unit_tests/core/logging/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/logging/test_context.py b/api/tests/unit_tests/core/logging/test_context.py new file mode 100644 index 0000000000..f388a3a0b9 --- /dev/null +++ b/api/tests/unit_tests/core/logging/test_context.py @@ -0,0 +1,79 @@ +"""Tests for logging context module.""" + +import uuid + +from core.logging.context import ( + clear_request_context, + get_request_id, + get_trace_id, + init_request_context, +) + + +class TestLoggingContext: + """Tests for the logging context functions.""" + + def test_init_creates_request_id(self): + """init_request_context should create a 10-char request ID.""" + init_request_context() + request_id = get_request_id() + assert len(request_id) == 10 + assert all(c in "0123456789abcdef" for c in request_id) + + def test_init_creates_trace_id(self): + """init_request_context should create a 32-char trace ID.""" + init_request_context() + trace_id = get_trace_id() + assert len(trace_id) == 32 + assert all(c in "0123456789abcdef" for c in trace_id) + + def test_trace_id_derived_from_request_id(self): + """trace_id should be deterministically derived from request_id.""" + init_request_context() + request_id = get_request_id() + trace_id = get_trace_id() + + # Verify trace_id is derived using uuid5 + expected_trace = uuid.uuid5(uuid.NAMESPACE_DNS, request_id).hex + assert trace_id == expected_trace + + def test_clear_resets_context(self): + """clear_request_context should reset both IDs to empty strings.""" + init_request_context() + assert get_request_id() != "" + assert get_trace_id() != "" + + clear_request_context() + assert get_request_id() == "" + assert get_trace_id() == "" + + def test_default_values_are_empty(self): + """Default values should be empty strings before init.""" + clear_request_context() + assert get_request_id() == "" + assert get_trace_id() == "" + + def test_multiple_inits_create_different_ids(self): + """Each init should create new unique IDs.""" + init_request_context() + first_request_id = get_request_id() + first_trace_id = get_trace_id() + + init_request_context() + second_request_id = get_request_id() + second_trace_id = get_trace_id() + + assert first_request_id != second_request_id + assert first_trace_id != second_trace_id + + def test_context_isolation(self): + """Context should be isolated per-call (no thread leakage in same thread).""" + init_request_context() + id1 = get_request_id() + + # Simulate another request + init_request_context() + id2 = get_request_id() + + # IDs should be different + assert id1 != id2 diff --git a/api/tests/unit_tests/core/logging/test_filters.py b/api/tests/unit_tests/core/logging/test_filters.py new file mode 100644 index 0000000000..b66ad111d5 --- /dev/null +++ b/api/tests/unit_tests/core/logging/test_filters.py @@ -0,0 +1,114 @@ +"""Tests for logging filters.""" + +import logging +from unittest import mock + +import pytest + + +@pytest.fixture +def log_record(): + return logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="test", + args=(), + exc_info=None, + ) + + +class TestTraceContextFilter: + def test_sets_empty_trace_id_without_context(self, log_record): + from core.logging.context import clear_request_context + from core.logging.filters import TraceContextFilter + + # Ensure no context is set + clear_request_context() + + filter = TraceContextFilter() + result = filter.filter(log_record) + + assert result is True + assert hasattr(log_record, "trace_id") + assert hasattr(log_record, "span_id") + assert hasattr(log_record, "req_id") + # Without context, IDs should be empty + assert log_record.trace_id == "" + assert log_record.req_id == "" + + def test_sets_trace_id_from_context(self, log_record): + """Test that trace_id and req_id are set from ContextVar when initialized.""" + from core.logging.context import init_request_context + from core.logging.filters import TraceContextFilter + + # Initialize context (no Flask needed!) + init_request_context() + + filter = TraceContextFilter() + filter.filter(log_record) + + # With context initialized, IDs should be set + assert log_record.trace_id != "" + assert len(log_record.trace_id) == 32 + assert log_record.req_id != "" + assert len(log_record.req_id) == 10 + + def test_filter_always_returns_true(self, log_record): + from core.logging.filters import TraceContextFilter + + filter = TraceContextFilter() + result = filter.filter(log_record) + assert result is True + + def test_sets_trace_id_from_otel_when_available(self, log_record): + from core.logging.filters import TraceContextFilter + + mock_span = mock.MagicMock() + mock_context = mock.MagicMock() + mock_context.trace_id = 0x5B8AA5A2D2C872E8321CF37308D69DF2 + mock_context.span_id = 0x051581BF3BB55C45 + mock_span.get_span_context.return_value = mock_context + + with ( + mock.patch("opentelemetry.trace.get_current_span", return_value=mock_span), + mock.patch("opentelemetry.trace.span.INVALID_TRACE_ID", 0), + mock.patch("opentelemetry.trace.span.INVALID_SPAN_ID", 0), + ): + filter = TraceContextFilter() + filter.filter(log_record) + + assert log_record.trace_id == "5b8aa5a2d2c872e8321cf37308d69df2" + assert log_record.span_id == "051581bf3bb55c45" + + +class TestIdentityContextFilter: + def test_sets_empty_identity_without_request_context(self, log_record): + from core.logging.filters import IdentityContextFilter + + filter = IdentityContextFilter() + result = filter.filter(log_record) + + assert result is True + assert log_record.tenant_id == "" + assert log_record.user_id == "" + assert log_record.user_type == "" + + def test_filter_always_returns_true(self, log_record): + from core.logging.filters import IdentityContextFilter + + filter = IdentityContextFilter() + result = filter.filter(log_record) + assert result is True + + def test_handles_exception_gracefully(self, log_record): + from core.logging.filters import IdentityContextFilter + + filter = IdentityContextFilter() + + # Should not raise even if something goes wrong + with mock.patch("core.logging.filters.flask.has_request_context", side_effect=Exception("Test error")): + result = filter.filter(log_record) + assert result is True + assert log_record.tenant_id == "" diff --git a/api/tests/unit_tests/core/logging/test_structured_formatter.py b/api/tests/unit_tests/core/logging/test_structured_formatter.py new file mode 100644 index 0000000000..94b91d205e --- /dev/null +++ b/api/tests/unit_tests/core/logging/test_structured_formatter.py @@ -0,0 +1,267 @@ +"""Tests for structured JSON formatter.""" + +import logging +import sys + +import orjson + + +class TestStructuredJSONFormatter: + def test_basic_log_format(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter(service_name="test-service") + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=42, + msg="Test message", + args=(), + exc_info=None, + ) + + output = formatter.format(record) + log_dict = orjson.loads(output) + + assert log_dict["severity"] == "INFO" + assert log_dict["service"] == "test-service" + assert log_dict["caller"] == "test.py:42" + assert log_dict["message"] == "Test message" + assert "ts" in log_dict + assert log_dict["ts"].endswith("Z") + + def test_severity_mapping(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter() + + test_cases = [ + (logging.DEBUG, "DEBUG"), + (logging.INFO, "INFO"), + (logging.WARNING, "WARN"), + (logging.ERROR, "ERROR"), + (logging.CRITICAL, "ERROR"), + ] + + for level, expected_severity in test_cases: + record = logging.LogRecord( + name="test", + level=level, + pathname="test.py", + lineno=1, + msg="Test", + args=(), + exc_info=None, + ) + output = formatter.format(record) + log_dict = orjson.loads(output) + assert log_dict["severity"] == expected_severity, f"Level {level} should map to {expected_severity}" + + def test_error_with_stack_trace(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter() + + try: + raise ValueError("Test error") + except ValueError: + exc_info = sys.exc_info() + + record = logging.LogRecord( + name="test", + level=logging.ERROR, + pathname="test.py", + lineno=10, + msg="Error occurred", + args=(), + exc_info=exc_info, + ) + + output = formatter.format(record) + log_dict = orjson.loads(output) + + assert log_dict["severity"] == "ERROR" + assert "stack_trace" in log_dict + assert "ValueError: Test error" in log_dict["stack_trace"] + + def test_no_stack_trace_for_info(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter() + + try: + raise ValueError("Test error") + except ValueError: + exc_info = sys.exc_info() + + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=10, + msg="Info message", + args=(), + exc_info=exc_info, + ) + + output = formatter.format(record) + log_dict = orjson.loads(output) + + assert "stack_trace" not in log_dict + + def test_trace_context_included(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="Test", + args=(), + exc_info=None, + ) + record.trace_id = "5b8aa5a2d2c872e8321cf37308d69df2" + record.span_id = "051581bf3bb55c45" + + output = formatter.format(record) + log_dict = orjson.loads(output) + + assert log_dict["trace_id"] == "5b8aa5a2d2c872e8321cf37308d69df2" + assert log_dict["span_id"] == "051581bf3bb55c45" + + def test_identity_context_included(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="Test", + args=(), + exc_info=None, + ) + record.tenant_id = "t-global-corp" + record.user_id = "u-admin-007" + record.user_type = "admin" + + output = formatter.format(record) + log_dict = orjson.loads(output) + + assert "identity" in log_dict + assert log_dict["identity"]["tenant_id"] == "t-global-corp" + assert log_dict["identity"]["user_id"] == "u-admin-007" + assert log_dict["identity"]["user_type"] == "admin" + + def test_no_identity_when_empty(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="Test", + args=(), + exc_info=None, + ) + + output = formatter.format(record) + log_dict = orjson.loads(output) + + assert "identity" not in log_dict + + def test_attributes_included(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="Test", + args=(), + exc_info=None, + ) + record.attributes = {"order_id": "ord-123", "amount": 99.99} + + output = formatter.format(record) + log_dict = orjson.loads(output) + + assert log_dict["attributes"]["order_id"] == "ord-123" + assert log_dict["attributes"]["amount"] == 99.99 + + def test_message_with_args(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="User %s logged in from %s", + args=("john", "192.168.1.1"), + exc_info=None, + ) + + output = formatter.format(record) + log_dict = orjson.loads(output) + + assert log_dict["message"] == "User john logged in from 192.168.1.1" + + def test_timestamp_format(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="Test", + args=(), + exc_info=None, + ) + + output = formatter.format(record) + log_dict = orjson.loads(output) + + # Verify ISO 8601 format with Z suffix + ts = log_dict["ts"] + assert ts.endswith("Z") + assert "T" in ts + # Should have milliseconds + assert "." in ts + + def test_fallback_for_non_serializable_attributes(self): + from core.logging.structured_formatter import StructuredJSONFormatter + + formatter = StructuredJSONFormatter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="test.py", + lineno=1, + msg="Test with non-serializable", + args=(), + exc_info=None, + ) + # Set is not serializable by orjson + record.attributes = {"items": {1, 2, 3}, "custom": object()} + + # Should not raise, fallback to json.dumps with default=str + output = formatter.format(record) + + # Verify it's valid JSON (parsed by stdlib json since orjson may fail) + import json + + log_dict = json.loads(output) + assert log_dict["message"] == "Test with non-serializable" + assert "attributes" in log_dict diff --git a/api/tests/unit_tests/core/logging/test_trace_helpers.py b/api/tests/unit_tests/core/logging/test_trace_helpers.py new file mode 100644 index 0000000000..aab1753b9b --- /dev/null +++ b/api/tests/unit_tests/core/logging/test_trace_helpers.py @@ -0,0 +1,102 @@ +"""Tests for trace helper functions.""" + +import re +from unittest import mock + + +class TestGetSpanIdFromOtelContext: + def test_returns_none_without_span(self): + from core.helper.trace_id_helper import get_span_id_from_otel_context + + with mock.patch("opentelemetry.trace.get_current_span", return_value=None): + result = get_span_id_from_otel_context() + assert result is None + + def test_returns_span_id_when_available(self): + from core.helper.trace_id_helper import get_span_id_from_otel_context + + mock_span = mock.MagicMock() + mock_context = mock.MagicMock() + mock_context.span_id = 0x051581BF3BB55C45 + mock_span.get_span_context.return_value = mock_context + + with mock.patch("opentelemetry.trace.get_current_span", return_value=mock_span): + with mock.patch("opentelemetry.trace.span.INVALID_SPAN_ID", 0): + result = get_span_id_from_otel_context() + assert result == "051581bf3bb55c45" + + def test_returns_none_on_exception(self): + from core.helper.trace_id_helper import get_span_id_from_otel_context + + with mock.patch("opentelemetry.trace.get_current_span", side_effect=Exception("Test error")): + result = get_span_id_from_otel_context() + assert result is None + + +class TestGenerateTraceparentHeader: + def test_generates_valid_format(self): + from core.helper.trace_id_helper import generate_traceparent_header + + with mock.patch("opentelemetry.trace.get_current_span", return_value=None): + result = generate_traceparent_header() + + assert result is not None + # Format: 00-{trace_id}-{span_id}-01 + parts = result.split("-") + assert len(parts) == 4 + assert parts[0] == "00" # version + assert len(parts[1]) == 32 # trace_id (32 hex chars) + assert len(parts[2]) == 16 # span_id (16 hex chars) + assert parts[3] == "01" # flags + + def test_uses_otel_context_when_available(self): + from core.helper.trace_id_helper import generate_traceparent_header + + mock_span = mock.MagicMock() + mock_context = mock.MagicMock() + mock_context.trace_id = 0x5B8AA5A2D2C872E8321CF37308D69DF2 + mock_context.span_id = 0x051581BF3BB55C45 + mock_span.get_span_context.return_value = mock_context + + with mock.patch("opentelemetry.trace.get_current_span", return_value=mock_span): + with ( + mock.patch("opentelemetry.trace.span.INVALID_TRACE_ID", 0), + mock.patch("opentelemetry.trace.span.INVALID_SPAN_ID", 0), + ): + result = generate_traceparent_header() + + assert result == "00-5b8aa5a2d2c872e8321cf37308d69df2-051581bf3bb55c45-01" + + def test_generates_hex_only_values(self): + from core.helper.trace_id_helper import generate_traceparent_header + + with mock.patch("opentelemetry.trace.get_current_span", return_value=None): + result = generate_traceparent_header() + + parts = result.split("-") + # All parts should be valid hex + assert re.match(r"^[0-9a-f]+$", parts[1]) + assert re.match(r"^[0-9a-f]+$", parts[2]) + + +class TestParseTraceparentHeader: + def test_parses_valid_traceparent(self): + from core.helper.trace_id_helper import parse_traceparent_header + + traceparent = "00-5b8aa5a2d2c872e8321cf37308d69df2-051581bf3bb55c45-01" + result = parse_traceparent_header(traceparent) + + assert result == "5b8aa5a2d2c872e8321cf37308d69df2" + + def test_returns_none_for_invalid_format(self): + from core.helper.trace_id_helper import parse_traceparent_header + + # Wrong number of parts + assert parse_traceparent_header("00-abc-def") is None + # Wrong trace_id length + assert parse_traceparent_header("00-abc-def-01") is None + + def test_returns_none_for_empty_string(self): + from core.helper.trace_id_helper import parse_traceparent_header + + assert parse_traceparent_header("") is None diff --git a/api/tests/unit_tests/libs/test_external_api.py b/api/tests/unit_tests/libs/test_external_api.py index 9aa157a651..5135970bcc 100644 --- a/api/tests/unit_tests/libs/test_external_api.py +++ b/api/tests/unit_tests/libs/test_external_api.py @@ -99,29 +99,20 @@ def test_external_api_json_message_and_bad_request_rewrite(): assert res.get_json()["message"] == "Invalid JSON payload received or JSON payload is empty." -def test_external_api_param_mapping_and_quota_and_exc_info_none(): - # Force exc_info() to return (None,None,None) only during request - import libs.external_api as ext +def test_external_api_param_mapping_and_quota(): + app = _create_api_app() + client = app.test_client() - orig_exc_info = ext.sys.exc_info - try: - ext.sys.exc_info = lambda: (None, None, None) + # Param errors mapping payload path + res = client.get("/api/param-errors") + assert res.status_code == 400 + data = res.get_json() + assert data["code"] == "invalid_param" + assert data["params"] == "field" - app = _create_api_app() - client = app.test_client() - - # Param errors mapping payload path - res = client.get("/api/param-errors") - assert res.status_code == 400 - data = res.get_json() - assert data["code"] == "invalid_param" - assert data["params"] == "field" - - # Quota path — depending on Flask-RESTX internals it may be handled - res = client.get("/api/quota") - assert res.status_code in (400, 429) - finally: - ext.sys.exc_info = orig_exc_info # type: ignore[assignment] + # Quota path — depending on Flask-RESTX internals it may be handled + res = client.get("/api/quota") + assert res.status_code in (400, 429) def test_unauthorized_and_force_logout_clears_cookies(): diff --git a/docker/.env.example b/docker/.env.example index 5c1089408c..c3feccb102 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -69,6 +69,8 @@ PYTHONIOENCODING=utf-8 # The log level for the application. # Supported values are `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` LOG_LEVEL=INFO +# Log output format: text or json +LOG_OUTPUT_FORMAT=text # Log file path LOG_FILE=/app/logs/server.log # Log file max size, the unit is MB diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index dba61d1816..81c34fc6a2 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -129,6 +129,7 @@ services: - ./middleware.env environment: # Use the shared environment variables. + LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text} DB_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin} REDIS_HOST: ${REDIS_HOST:-redis} REDIS_PORT: ${REDIS_PORT:-6379} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 9910c95a84..a67141ce05 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -17,6 +17,7 @@ x-shared-env: &shared-api-worker-env LC_ALL: ${LC_ALL:-en_US.UTF-8} PYTHONIOENCODING: ${PYTHONIOENCODING:-utf-8} LOG_LEVEL: ${LOG_LEVEL:-INFO} + LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text} LOG_FILE: ${LOG_FILE:-/app/logs/server.log} LOG_FILE_MAX_SIZE: ${LOG_FILE_MAX_SIZE:-20} LOG_FILE_BACKUP_COUNT: ${LOG_FILE_BACKUP_COUNT:-5} From 84cbf0526d7813e0f0c5c218be9377f3a07af9a6 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Sun, 4 Jan 2026 15:26:37 +0800 Subject: [PATCH 04/33] feat: model total credits (#26942) Co-authored-by: CodingOnStar Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> --- .../base/icons/assets/public/llm/Tongyi.svg | 17 + .../public/llm/anthropic-short-light.svg | 4 + .../base/icons/assets/public/llm/deepseek.svg | 4 + .../base/icons/assets/public/llm/gemini.svg | 105 +++ .../base/icons/assets/public/llm/grok.svg | 11 + .../icons/assets/public/llm/openai-small.svg | 17 + .../src/public/llm/AnthropicShortLight.json | 36 + .../src/public/llm/AnthropicShortLight.tsx | 20 + .../base/icons/src/public/llm/Deepseek.json | 36 + .../base/icons/src/public/llm/Deepseek.tsx | 20 + .../base/icons/src/public/llm/Gemini.json | 807 ++++++++++++++++++ .../base/icons/src/public/llm/Gemini.tsx | 20 + .../base/icons/src/public/llm/Grok.json | 72 ++ .../base/icons/src/public/llm/Grok.tsx | 20 + .../base/icons/src/public/llm/OpenaiBlue.json | 37 + .../base/icons/src/public/llm/OpenaiBlue.tsx | 20 + .../icons/src/public/llm/OpenaiSmall.json | 128 +++ .../base/icons/src/public/llm/OpenaiSmall.tsx | 20 + .../base/icons/src/public/llm/OpenaiTeal.json | 37 + .../base/icons/src/public/llm/OpenaiTeal.tsx | 20 + .../icons/src/public/llm/OpenaiViolet.json | 37 + .../icons/src/public/llm/OpenaiViolet.tsx | 20 + .../base/icons/src/public/llm/Tongyi.json | 128 +++ .../base/icons/src/public/llm/Tongyi.tsx | 20 + .../base/icons/src/public/llm/index.ts | 9 + .../src/public/tracing/DatabricksIcon.tsx | 2 +- .../src/public/tracing/DatabricksIconBig.tsx | 2 +- .../icons/src/public/tracing/MlflowIcon.tsx | 2 +- .../src/public/tracing/MlflowIconBig.tsx | 2 +- .../icons/src/public/tracing/TencentIcon.json | 6 +- .../src/public/tracing/TencentIconBig.json | 10 +- .../apps-full-in-dialog/index.spec.tsx | 4 + .../model-provider-page/index.tsx | 12 +- .../provider-added-card/credential-panel.tsx | 5 +- .../provider-added-card/index.tsx | 15 +- .../provider-added-card/quota-panel.tsx | 185 +++- .../model-provider-page/utils.ts | 20 +- web/app/components/plugins/provider-card.tsx | 2 +- web/context/app-context.tsx | 8 +- web/i18n/en-US/billing.json | 2 +- web/i18n/en-US/common.json | 6 +- web/i18n/ja-JP/billing.json | 2 +- web/i18n/ja-JP/common.json | 6 +- web/i18n/zh-Hans/billing.json | 2 +- web/i18n/zh-Hans/common.json | 6 +- web/models/common.ts | 3 + 46 files changed, 1890 insertions(+), 77 deletions(-) create mode 100644 web/app/components/base/icons/assets/public/llm/Tongyi.svg create mode 100644 web/app/components/base/icons/assets/public/llm/anthropic-short-light.svg create mode 100644 web/app/components/base/icons/assets/public/llm/deepseek.svg create mode 100644 web/app/components/base/icons/assets/public/llm/gemini.svg create mode 100644 web/app/components/base/icons/assets/public/llm/grok.svg create mode 100644 web/app/components/base/icons/assets/public/llm/openai-small.svg create mode 100644 web/app/components/base/icons/src/public/llm/AnthropicShortLight.json create mode 100644 web/app/components/base/icons/src/public/llm/AnthropicShortLight.tsx create mode 100644 web/app/components/base/icons/src/public/llm/Deepseek.json create mode 100644 web/app/components/base/icons/src/public/llm/Deepseek.tsx create mode 100644 web/app/components/base/icons/src/public/llm/Gemini.json create mode 100644 web/app/components/base/icons/src/public/llm/Gemini.tsx create mode 100644 web/app/components/base/icons/src/public/llm/Grok.json create mode 100644 web/app/components/base/icons/src/public/llm/Grok.tsx create mode 100644 web/app/components/base/icons/src/public/llm/OpenaiBlue.json create mode 100644 web/app/components/base/icons/src/public/llm/OpenaiBlue.tsx create mode 100644 web/app/components/base/icons/src/public/llm/OpenaiSmall.json create mode 100644 web/app/components/base/icons/src/public/llm/OpenaiSmall.tsx create mode 100644 web/app/components/base/icons/src/public/llm/OpenaiTeal.json create mode 100644 web/app/components/base/icons/src/public/llm/OpenaiTeal.tsx create mode 100644 web/app/components/base/icons/src/public/llm/OpenaiViolet.json create mode 100644 web/app/components/base/icons/src/public/llm/OpenaiViolet.tsx create mode 100644 web/app/components/base/icons/src/public/llm/Tongyi.json create mode 100644 web/app/components/base/icons/src/public/llm/Tongyi.tsx diff --git a/web/app/components/base/icons/assets/public/llm/Tongyi.svg b/web/app/components/base/icons/assets/public/llm/Tongyi.svg new file mode 100644 index 0000000000..cca23b3aae --- /dev/null +++ b/web/app/components/base/icons/assets/public/llm/Tongyi.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/web/app/components/base/icons/assets/public/llm/anthropic-short-light.svg b/web/app/components/base/icons/assets/public/llm/anthropic-short-light.svg new file mode 100644 index 0000000000..c8e2370803 --- /dev/null +++ b/web/app/components/base/icons/assets/public/llm/anthropic-short-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/app/components/base/icons/assets/public/llm/deepseek.svg b/web/app/components/base/icons/assets/public/llm/deepseek.svg new file mode 100644 index 0000000000..046f89e1ce --- /dev/null +++ b/web/app/components/base/icons/assets/public/llm/deepseek.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/app/components/base/icons/assets/public/llm/gemini.svg b/web/app/components/base/icons/assets/public/llm/gemini.svg new file mode 100644 index 0000000000..698f6ea629 --- /dev/null +++ b/web/app/components/base/icons/assets/public/llm/gemini.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/app/components/base/icons/assets/public/llm/grok.svg b/web/app/components/base/icons/assets/public/llm/grok.svg new file mode 100644 index 0000000000..6c0cbe227d --- /dev/null +++ b/web/app/components/base/icons/assets/public/llm/grok.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/app/components/base/icons/assets/public/llm/openai-small.svg b/web/app/components/base/icons/assets/public/llm/openai-small.svg new file mode 100644 index 0000000000..4af58790e4 --- /dev/null +++ b/web/app/components/base/icons/assets/public/llm/openai-small.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/web/app/components/base/icons/src/public/llm/AnthropicShortLight.json b/web/app/components/base/icons/src/public/llm/AnthropicShortLight.json new file mode 100644 index 0000000000..2a8ff2f28a --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/AnthropicShortLight.json @@ -0,0 +1,36 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "40", + "height": "40", + "viewBox": "0 0 40 40", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "width": "40", + "height": "40", + "fill": "white" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M25.7926 10.1311H21.5089L29.3208 29.869H33.6045L25.7926 10.1311ZM13.4164 10.1311L5.60449 29.869H9.97273L11.5703 25.724H19.743L21.3405 29.869H25.7087L17.8969 10.1311H13.4164ZM12.9834 22.0583L15.6566 15.1217L18.3299 22.0583H12.9834Z", + "fill": "black" + }, + "children": [] + } + ] + }, + "name": "AnthropicShortLight" +} diff --git a/web/app/components/base/icons/src/public/llm/AnthropicShortLight.tsx b/web/app/components/base/icons/src/public/llm/AnthropicShortLight.tsx new file mode 100644 index 0000000000..2bd21f48da --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/AnthropicShortLight.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './AnthropicShortLight.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'AnthropicShortLight' + +export default Icon diff --git a/web/app/components/base/icons/src/public/llm/Deepseek.json b/web/app/components/base/icons/src/public/llm/Deepseek.json new file mode 100644 index 0000000000..1483974a02 --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/Deepseek.json @@ -0,0 +1,36 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "40", + "height": "40", + "viewBox": "0 0 40 40", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "width": "40", + "height": "40", + "fill": "white" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M36.6676 11.2917C36.3316 11.1277 36.1871 11.4402 35.9906 11.599C35.9242 11.6511 35.8668 11.7188 35.8108 11.7787C35.3199 12.3048 34.747 12.6485 33.9996 12.6068C32.9046 12.5469 31.971 12.8907 31.1455 13.7293C30.9696 12.6954 30.3863 12.0782 29.4996 11.6824C29.0348 11.4766 28.5647 11.2709 28.2406 10.823C28.0127 10.5053 27.9515 10.1511 27.8368 9.80214C27.7652 9.59121 27.6923 9.37506 27.4502 9.33861C27.1871 9.29694 27.0843 9.51829 26.9814 9.70318C26.5674 10.4584 26.4084 11.2917 26.4228 12.1355C26.4592 14.0313 27.26 15.5417 28.8486 16.6173C29.0296 16.7397 29.0764 16.8646 29.0191 17.0443C28.9111 17.4141 28.7822 17.7735 28.6676 18.1433C28.596 18.3803 28.4879 18.4323 28.2354 18.3282C27.363 17.9637 26.609 17.4246 25.9436 16.7709C24.8135 15.6771 23.7914 14.4689 22.5166 13.5235C22.2171 13.3021 21.919 13.0964 21.609 12.9011C20.3082 11.6355 21.7796 10.5964 22.1194 10.474C22.4762 10.3464 22.2431 9.9037 21.092 9.90891C19.9423 9.91413 18.889 10.2995 17.5478 10.8126C17.3512 10.8907 17.1455 10.948 16.9332 10.9922C15.7158 10.7631 14.4515 10.711 13.1298 10.8594C10.6428 11.1381 8.65587 12.3152 7.19493 14.3255C5.44102 16.7397 5.02826 19.4845 5.53347 22.349C6.06473 25.3646 7.60249 27.8646 9.96707 29.8178C12.4176 31.8413 15.2406 32.8334 18.4606 32.6433C20.4163 32.5313 22.5947 32.2683 25.0504 30.1875C25.6702 30.4949 26.3199 30.6173 27.3994 30.711C28.2302 30.7891 29.0296 30.6694 29.6494 30.5417C30.6194 30.3361 30.5518 29.4375 30.2015 29.2709C27.3578 27.9454 27.9814 28.4845 27.4136 28.0495C28.859 26.3361 31.0374 24.5574 31.889 18.797C31.9554 18.3386 31.898 18.0522 31.889 17.6798C31.8838 17.4558 31.9346 17.3673 32.1923 17.3413C32.9046 17.2605 33.596 17.0651 34.2314 16.7137C36.0739 15.7058 36.816 14.0522 36.9918 12.0678C37.0179 11.7657 36.9866 11.4506 36.6676 11.2917ZM20.613 29.1485C17.8564 26.9793 16.5204 26.2657 15.9684 26.297C15.4527 26.3255 15.5452 26.9167 15.6584 27.3022C15.777 27.6823 15.9319 27.9454 16.1494 28.2787C16.2991 28.5001 16.402 28.8307 15.9996 29.0755C15.1116 29.6277 13.5687 28.8907 13.4958 28.8542C11.7001 27.797 10.1988 26.3985 9.14025 24.487C8.11941 22.6459 7.52566 20.6719 7.42801 18.5651C7.40197 18.0547 7.5517 17.875 8.05691 17.7839C8.72227 17.6615 9.40978 17.6355 10.0751 17.7318C12.8876 18.1433 15.2822 19.4037 17.2887 21.3959C18.4346 22.5339 19.3018 23.8907 20.195 25.2162C21.1442 26.6251 22.1663 27.9662 23.4671 29.0651C23.9254 29.4506 24.2926 29.7449 24.6428 29.961C23.5856 30.0782 21.8199 30.1042 20.613 29.1485ZM21.9332 20.6407C21.9332 20.4141 22.1142 20.2345 22.342 20.2345C22.3928 20.2345 22.4398 20.2449 22.4814 20.2605C22.5374 20.2813 22.5895 20.3126 22.6299 20.3594C22.7027 20.4298 22.7444 20.5339 22.7444 20.6407C22.7444 20.8673 22.5635 21.047 22.3368 21.047C22.109 21.047 21.9332 20.8673 21.9332 20.6407ZM26.036 22.7501C25.7731 22.8569 25.51 22.9506 25.2575 22.961C24.8655 22.9793 24.4371 22.8203 24.204 22.6251C23.8434 22.323 23.5856 22.1537 23.4762 21.6225C23.4306 21.3959 23.4567 21.047 23.497 20.8465C23.5908 20.4141 23.4866 20.1381 23.1832 19.8855C22.9346 19.6798 22.6207 19.6251 22.2744 19.6251C22.1455 19.6251 22.027 19.5678 21.9384 19.5209C21.7939 19.4479 21.6754 19.2683 21.7887 19.047C21.8251 18.9766 22.001 18.8022 22.0426 18.7709C22.5114 18.5027 23.053 18.5913 23.5543 18.7918C24.0191 18.9818 24.3694 19.3307 24.8746 19.823C25.3915 20.4194 25.484 20.5861 25.7783 21.0313C26.01 21.3829 26.2223 21.7422 26.3668 22.1537C26.454 22.4089 26.3408 22.6198 26.036 22.7501Z", + "fill": "#4D6BFE" + }, + "children": [] + } + ] + }, + "name": "Deepseek" +} diff --git a/web/app/components/base/icons/src/public/llm/Deepseek.tsx b/web/app/components/base/icons/src/public/llm/Deepseek.tsx new file mode 100644 index 0000000000..b19beb8b8f --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/Deepseek.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './Deepseek.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'Deepseek' + +export default Icon diff --git a/web/app/components/base/icons/src/public/llm/Gemini.json b/web/app/components/base/icons/src/public/llm/Gemini.json new file mode 100644 index 0000000000..3121b1ea19 --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/Gemini.json @@ -0,0 +1,807 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "40", + "height": "40", + "viewBox": "0 0 40 40", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "width": "40", + "height": "40", + "fill": "white" + }, + "children": [] + }, + { + "type": "element", + "name": "mask", + "attributes": { + "id": "mask0_3892_95663", + "style": "mask-type:alpha", + "maskUnits": "userSpaceOnUse", + "x": "6", + "y": "6", + "width": "28", + "height": "29" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M20 6C20.2936 6 20.5488 6.2005 20.6205 6.48556C20.8393 7.3566 21.1277 8.20866 21.4828 9.03356C22.4116 11.191 23.6854 13.0791 25.3032 14.6968C26.9218 16.3146 28.8095 17.5888 30.9664 18.5172C31.7941 18.8735 32.6436 19.16 33.5149 19.3795C33.6533 19.4143 33.7762 19.4942 33.8641 19.6067C33.9519 19.7192 33.9998 19.8578 34 20.0005C34 20.2941 33.7995 20.5492 33.5149 20.621C32.6437 20.8399 31.7915 21.1282 30.9664 21.4833C28.8095 22.4121 26.9209 23.6859 25.3032 25.3036C23.6854 26.9223 22.4116 28.8099 21.4828 30.9669C21.1278 31.7919 20.8394 32.6439 20.6205 33.5149C20.586 33.6534 20.5062 33.7764 20.3937 33.8644C20.2813 33.9524 20.1427 34.0003 20 34.0005C19.8572 34.0003 19.7186 33.9525 19.6062 33.8645C19.4937 33.7765 19.414 33.6535 19.3795 33.5149C19.1605 32.6439 18.872 31.7918 18.5167 30.9669C17.5884 28.8099 16.3151 26.9214 14.6964 25.3036C13.0782 23.6859 11.1906 22.4121 9.03309 21.4833C8.20814 21.1283 7.35608 20.8399 6.48509 20.621C6.34667 20.5864 6.22377 20.5065 6.13589 20.3941C6.04801 20.2817 6.00018 20.1432 6 20.0005C6.00024 19.8578 6.04808 19.7192 6.13594 19.6067C6.2238 19.4942 6.34667 19.4143 6.48509 19.3795C7.35612 19.1607 8.20819 18.8723 9.03309 18.5172C11.1906 17.5888 13.0786 16.3146 14.6964 14.6968C16.3141 13.0791 17.5884 11.191 18.5167 9.03356C18.8719 8.20862 19.1604 7.35656 19.3795 6.48556C19.4508 6.2005 19.7064 6 20 6Z", + "fill": "black" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M20 6C20.2936 6 20.5488 6.2005 20.6205 6.48556C20.8393 7.3566 21.1277 8.20866 21.4828 9.03356C22.4116 11.191 23.6854 13.0791 25.3032 14.6968C26.9218 16.3146 28.8095 17.5888 30.9664 18.5172C31.7941 18.8735 32.6436 19.16 33.5149 19.3795C33.6533 19.4143 33.7762 19.4942 33.8641 19.6067C33.9519 19.7192 33.9998 19.8578 34 20.0005C34 20.2941 33.7995 20.5492 33.5149 20.621C32.6437 20.8399 31.7915 21.1282 30.9664 21.4833C28.8095 22.4121 26.9209 23.6859 25.3032 25.3036C23.6854 26.9223 22.4116 28.8099 21.4828 30.9669C21.1278 31.7919 20.8394 32.6439 20.6205 33.5149C20.586 33.6534 20.5062 33.7764 20.3937 33.8644C20.2813 33.9524 20.1427 34.0003 20 34.0005C19.8572 34.0003 19.7186 33.9525 19.6062 33.8645C19.4937 33.7765 19.414 33.6535 19.3795 33.5149C19.1605 32.6439 18.872 31.7918 18.5167 30.9669C17.5884 28.8099 16.3151 26.9214 14.6964 25.3036C13.0782 23.6859 11.1906 22.4121 9.03309 21.4833C8.20814 21.1283 7.35608 20.8399 6.48509 20.621C6.34667 20.5864 6.22377 20.5065 6.13589 20.3941C6.04801 20.2817 6.00018 20.1432 6 20.0005C6.00024 19.8578 6.04808 19.7192 6.13594 19.6067C6.2238 19.4942 6.34667 19.4143 6.48509 19.3795C7.35612 19.1607 8.20819 18.8723 9.03309 18.5172C11.1906 17.5888 13.0786 16.3146 14.6964 14.6968C16.3141 13.0791 17.5884 11.191 18.5167 9.03356C18.8719 8.20862 19.1604 7.35656 19.3795 6.48556C19.4508 6.2005 19.7064 6 20 6Z", + "fill": "url(#paint0_linear_3892_95663)" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "mask": "url(#mask0_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter0_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M3.47232 27.8921C6.70753 29.0411 10.426 26.8868 11.7778 23.0804C13.1296 19.274 11.6028 15.2569 8.36763 14.108C5.13242 12.959 1.41391 15.1133 0.06211 18.9197C-1.28969 22.7261 0.23711 26.7432 3.47232 27.8921Z", + "fill": "#FFE432" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter1_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M17.8359 15.341C22.2806 15.341 25.8838 11.6588 25.8838 7.11644C25.8838 2.57412 22.2806 -1.10815 17.8359 -1.10815C13.3912 -1.10815 9.78809 2.57412 9.78809 7.11644C9.78809 11.6588 13.3912 15.341 17.8359 15.341Z", + "fill": "#FC413D" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter2_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M14.7081 41.6431C19.3478 41.4163 22.8707 36.3599 22.5768 30.3493C22.283 24.3387 18.2836 19.65 13.644 19.8769C9.00433 20.1037 5.48139 25.1601 5.77525 31.1707C6.06911 37.1813 10.0685 41.87 14.7081 41.6431Z", + "fill": "#00B95C" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter3_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M14.7081 41.6431C19.3478 41.4163 22.8707 36.3599 22.5768 30.3493C22.283 24.3387 18.2836 19.65 13.644 19.8769C9.00433 20.1037 5.48139 25.1601 5.77525 31.1707C6.06911 37.1813 10.0685 41.87 14.7081 41.6431Z", + "fill": "#00B95C" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter4_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M19.355 38.0071C23.2447 35.6405 24.2857 30.2506 21.6803 25.9684C19.0748 21.6862 13.8095 20.1334 9.91983 22.5C6.03016 24.8666 4.98909 30.2565 7.59454 34.5387C10.2 38.8209 15.4653 40.3738 19.355 38.0071Z", + "fill": "#00B95C" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter5_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M35.0759 24.5504C39.4477 24.5504 42.9917 21.1377 42.9917 16.9278C42.9917 12.7179 39.4477 9.30518 35.0759 9.30518C30.7042 9.30518 27.1602 12.7179 27.1602 16.9278C27.1602 21.1377 30.7042 24.5504 35.0759 24.5504Z", + "fill": "#3186FF" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter6_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M0.362818 23.6667C4.3882 26.7279 10.2688 25.7676 13.4976 21.5219C16.7264 17.2762 16.0806 11.3528 12.0552 8.29156C8.02982 5.23037 2.14917 6.19062 -1.07959 10.4364C-4.30835 14.6821 -3.66256 20.6055 0.362818 23.6667Z", + "fill": "#FBBC04" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter7_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M20.9877 28.1903C25.7924 31.4936 32.1612 30.5732 35.2128 26.1346C38.2644 21.696 36.8432 15.4199 32.0385 12.1166C27.2338 8.81334 20.865 9.73372 17.8134 14.1723C14.7618 18.611 16.183 24.887 20.9877 28.1903Z", + "fill": "#3186FF" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter8_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M29.7231 4.99175C30.9455 6.65415 29.3748 9.88535 26.2149 12.2096C23.0549 14.5338 19.5026 15.0707 18.2801 13.4088C17.0576 11.7468 18.6284 8.51514 21.7883 6.19092C24.9482 3.86717 28.5006 3.32982 29.7231 4.99175Z", + "fill": "#749BFF" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter9_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M19.6891 12.9486C24.5759 8.41581 26.2531 2.27858 23.4354 -0.759249C20.6176 -3.79708 14.3718 -2.58516 9.485 1.94765C4.59823 6.48046 2.92099 12.6177 5.73879 15.6555C8.55658 18.6933 14.8024 17.4814 19.6891 12.9486Z", + "fill": "#FC413D" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "g", + "attributes": { + "filter": "url(#filter10_f_3892_95663)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M9.6712 29.23C12.5757 31.3088 15.9102 31.6247 17.1191 29.9356C18.328 28.2465 16.9535 25.1921 14.049 23.1133C11.1446 21.0345 7.81003 20.7186 6.60113 22.4077C5.39223 24.0968 6.76675 27.1512 9.6712 29.23Z", + "fill": "#FFEE48" + }, + "children": [] + } + ] + } + ] + }, + { + "type": "element", + "name": "defs", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter0_f_3892_95663", + "x": "-3.44095", + "y": "10.7885", + "width": "18.7217", + "height": "20.4229", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "1.50514", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter1_f_3892_95663", + "x": "-4.76352", + "y": "-15.6598", + "width": "45.1989", + "height": "45.5524", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "7.2758", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter2_f_3892_95663", + "x": "-6.61209", + "y": "7.49899", + "width": "41.5757", + "height": "46.522", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "6.18495", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter3_f_3892_95663", + "x": "-6.61209", + "y": "7.49899", + "width": "41.5757", + "height": "46.522", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "6.18495", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter4_f_3892_95663", + "x": "-6.21073", + "y": "9.02316", + "width": "41.6959", + "height": "42.4608", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "6.18495", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter5_f_3892_95663", + "x": "15.405", + "y": "-2.44994", + "width": "39.3423", + "height": "38.7556", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "5.87756", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter6_f_3892_95663", + "x": "-13.7886", + "y": "-4.15284", + "width": "39.9951", + "height": "40.2639", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "5.32691", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter7_f_3892_95663", + "x": "6.6925", + "y": "0.620963", + "width": "39.6414", + "height": "39.065", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "4.75678", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter8_f_3892_95663", + "x": "9.35225", + "y": "-4.48661", + "width": "29.2984", + "height": "27.3739", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "4.25649", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter9_f_3892_95663", + "x": "-2.81919", + "y": "-9.62339", + "width": "34.8122", + "height": "34.143", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "3.59514", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "filter", + "attributes": { + "id": "filter10_f_3892_95663", + "x": "-2.73761", + "y": "12.4221", + "width": "29.1949", + "height": "27.4994", + "filterUnits": "userSpaceOnUse", + "color-interpolation-filters": "sRGB" + }, + "children": [ + { + "type": "element", + "name": "feFlood", + "attributes": { + "flood-opacity": "0", + "result": "BackgroundImageFix" + }, + "children": [] + }, + { + "type": "element", + "name": "feBlend", + "attributes": { + "mode": "normal", + "in": "SourceGraphic", + "in2": "BackgroundImageFix", + "result": "shape" + }, + "children": [] + }, + { + "type": "element", + "name": "feGaussianBlur", + "attributes": { + "stdDeviation": "4.44986", + "result": "effect1_foregroundBlur_3892_95663" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "linearGradient", + "attributes": { + "id": "paint0_linear_3892_95663", + "x1": "13.9595", + "y1": "24.7349", + "x2": "28.5025", + "y2": "12.4738", + "gradientUnits": "userSpaceOnUse" + }, + "children": [ + { + "type": "element", + "name": "stop", + "attributes": { + "stop-color": "#4893FC" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0.27", + "stop-color": "#4893FC" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "0.777", + "stop-color": "#969DFF" + }, + "children": [] + }, + { + "type": "element", + "name": "stop", + "attributes": { + "offset": "1", + "stop-color": "#BD99FE" + }, + "children": [] + } + ] + } + ] + } + ] + }, + "name": "Gemini" +} diff --git a/web/app/components/base/icons/src/public/llm/Gemini.tsx b/web/app/components/base/icons/src/public/llm/Gemini.tsx new file mode 100644 index 0000000000..f5430036bb --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/Gemini.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './Gemini.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'Gemini' + +export default Icon diff --git a/web/app/components/base/icons/src/public/llm/Grok.json b/web/app/components/base/icons/src/public/llm/Grok.json new file mode 100644 index 0000000000..590f845eeb --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/Grok.json @@ -0,0 +1,72 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "40", + "height": "40", + "viewBox": "0 0 40 40", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "width": "40", + "height": "40", + "fill": "white" + }, + "children": [] + }, + { + "type": "element", + "name": "g", + "attributes": { + "clip-path": "url(#clip0_3892_95659)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M15.745 24.54L26.715 16.35C27.254 15.95 28.022 16.106 28.279 16.73C29.628 20.018 29.025 23.971 26.341 26.685C23.658 29.399 19.924 29.995 16.511 28.639L12.783 30.384C18.13 34.081 24.623 33.166 28.681 29.06C31.9 25.805 32.897 21.368 31.965 17.367L31.973 17.376C30.622 11.498 32.305 9.149 35.755 4.345L36 4L31.46 8.59V8.576L15.743 24.544M13.48 26.531C9.643 22.824 10.305 17.085 13.58 13.776C16 11.327 19.968 10.328 23.432 11.797L27.152 10.06C26.482 9.57 25.622 9.043 24.637 8.673C20.182 6.819 14.848 7.742 11.227 11.401C7.744 14.924 6.648 20.341 8.53 24.962C9.935 28.416 7.631 30.86 5.31 33.326C4.49 34.2 3.666 35.074 3 36L13.478 26.534", + "fill": "black" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "defs", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "clipPath", + "attributes": { + "id": "clip0_3892_95659" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "width": "33", + "height": "32", + "fill": "white", + "transform": "translate(3 4)" + }, + "children": [] + } + ] + } + ] + } + ] + }, + "name": "Grok" +} diff --git a/web/app/components/base/icons/src/public/llm/Grok.tsx b/web/app/components/base/icons/src/public/llm/Grok.tsx new file mode 100644 index 0000000000..8b378de490 --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/Grok.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './Grok.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'Grok' + +export default Icon diff --git a/web/app/components/base/icons/src/public/llm/OpenaiBlue.json b/web/app/components/base/icons/src/public/llm/OpenaiBlue.json new file mode 100644 index 0000000000..c5d4f974a2 --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/OpenaiBlue.json @@ -0,0 +1,37 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "24", + "height": "24", + "viewBox": "0 0 24 24", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "width": "24", + "height": "24", + "rx": "6", + "fill": "#03A4EE" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M19.7758 11.5959C19.9546 11.9948 20.0681 12.4213 20.1145 12.8563C20.1592 13.2913 20.1369 13.7315 20.044 14.1596C19.9529 14.5878 19.7947 14.9987 19.5746 15.377C19.4302 15.6298 19.2599 15.867 19.0639 16.0854C18.8696 16.3021 18.653 16.4981 18.4174 16.67C18.1801 16.842 17.9274 16.9864 17.6591 17.105C17.3926 17.222 17.1141 17.3114 16.8286 17.3698C16.6945 17.7859 16.4951 18.1797 16.2371 18.5339C15.9809 18.8881 15.6697 19.1993 15.3155 19.4555C14.9613 19.7134 14.5693 19.9129 14.1532 20.047C13.7371 20.1829 13.302 20.2499 12.8636 20.2499C12.573 20.2516 12.2807 20.2207 11.9953 20.1622C11.7116 20.102 11.433 20.0109 11.1665 19.8923C10.9 19.7736 10.6472 19.6258 10.4116 19.4538C10.1778 19.2819 9.96115 19.0841 9.76857 18.8658C9.33871 18.9586 8.89853 18.981 8.46351 18.9363C8.02849 18.8898 7.60207 18.7763 7.20143 18.5975C6.80252 18.4204 6.43284 18.1797 6.10786 17.8857C5.78289 17.5916 5.50606 17.2478 5.28769 16.8695C5.14153 16.6167 5.02117 16.3502 4.93004 16.0734C4.83891 15.7965 4.77873 15.5111 4.74778 15.2205C4.71683 14.9317 4.71855 14.6393 4.7495 14.3488C4.78045 14.0599 4.84407 13.7745 4.9352 13.4976C4.64289 13.1727 4.40217 12.803 4.22335 12.4041C4.04624 12.0034 3.93104 11.5787 3.88634 11.1437C3.83991 10.7087 3.86398 10.2685 3.95511 9.84036C4.04624 9.41222 4.20443 9.00127 4.42452 8.62299C4.56896 8.37023 4.73918 8.13123 4.93348 7.91458C5.12778 7.69793 5.34615 7.50191 5.58171 7.32997C5.81728 7.15802 6.07176 7.01187 6.33827 6.89495C6.6065 6.7763 6.88506 6.68861 7.17048 6.63015C7.3046 6.21232 7.50406 5.82029 7.76026 5.46608C8.01817 5.11188 8.32939 4.80066 8.6836 4.54274C9.03781 4.28654 9.42984 4.08708 9.84595 3.95125C10.2621 3.81713 10.6971 3.74835 11.1355 3.75007C11.4261 3.74835 11.7184 3.77758 12.0039 3.83776C12.2893 3.89794 12.5678 3.98736 12.8344 4.106C13.1009 4.22636 13.3536 4.37251 13.5892 4.54446C13.8248 4.71812 14.0414 4.91414 14.234 5.13251C14.6621 5.04138 15.1023 5.01903 15.5373 5.06373C15.9723 5.10844 16.3971 5.22364 16.7977 5.40074C17.1966 5.57957 17.5663 5.81857 17.8913 6.1126C18.2162 6.4049 18.4931 6.74707 18.7114 7.12707C18.8576 7.37811 18.9779 7.64463 19.0691 7.92318C19.1602 8.20001 19.2221 8.48544 19.2513 8.77602C19.2823 9.06661 19.2823 9.35892 19.2496 9.64951C19.2187 9.94009 19.155 10.2255 19.0639 10.5024C19.3579 10.8273 19.5969 11.1953 19.7758 11.5959ZM14.0466 18.9363C14.4214 18.7815 14.7619 18.5528 15.049 18.2657C15.3362 17.9785 15.5648 17.6381 15.7196 17.2615C15.8743 16.8867 15.9552 16.4843 15.9552 16.0785V12.2442C15.954 12.2407 15.9529 12.2367 15.9517 12.2321C15.9506 12.2287 15.9488 12.2252 15.9466 12.2218C15.9443 12.2184 15.9414 12.2155 15.938 12.2132C15.9345 12.2098 15.9311 12.2075 15.9276 12.2063L14.54 11.4051V16.0373C14.54 16.0837 14.5332 16.1318 14.5211 16.1765C14.5091 16.223 14.4919 16.2659 14.4678 16.3072C14.4438 16.3485 14.4162 16.3863 14.3819 16.419C14.3484 16.4523 14.3109 16.4812 14.2701 16.505L10.9842 18.4015C10.9567 18.4187 10.9103 18.4428 10.8862 18.4565C11.0221 18.5717 11.1699 18.6732 11.3247 18.7626C11.4811 18.852 11.6428 18.9277 11.8113 18.9896C11.9798 19.0497 12.1535 19.0962 12.3288 19.1271C12.5059 19.1581 12.6848 19.1735 12.8636 19.1735C13.2694 19.1735 13.6717 19.0927 14.0466 18.9363ZM6.22135 16.333C6.42596 16.6855 6.69592 16.9916 7.01745 17.2392C7.34071 17.4868 7.70695 17.6673 8.09899 17.7722C8.49102 17.8771 8.90025 17.9046 9.3026 17.8513C9.70495 17.798 10.0918 17.6673 10.4443 17.4644L13.7663 15.5472L13.7749 15.5386C13.7772 15.5363 13.7789 15.5329 13.78 15.5283C13.7823 15.5249 13.7841 15.5214 13.7852 15.518V13.9017L9.77545 16.2212C9.73418 16.2453 9.6912 16.2625 9.64649 16.2763C9.60007 16.2883 9.55364 16.2935 9.5055 16.2935C9.45907 16.2935 9.41265 16.2883 9.36622 16.2763C9.32152 16.2625 9.27681 16.2453 9.23554 16.2212L5.94967 14.323C5.92044 14.3058 5.87746 14.28 5.85339 14.2645C5.82244 14.4416 5.80696 14.6204 5.80696 14.7993C5.80696 14.9781 5.82415 15.1569 5.85511 15.334C5.88605 15.5094 5.9342 15.6831 5.99438 15.8516C6.05628 16.0201 6.13194 16.1817 6.22135 16.3364V16.333ZM5.35818 9.1629C5.15529 9.51539 5.02461 9.90398 4.97131 10.3063C4.918 10.7087 4.94552 11.1162 5.0504 11.51C5.15529 11.902 5.33583 12.2682 5.58343 12.5915C5.83103 12.913 6.13881 13.183 6.48958 13.3859L9.80984 15.3048C9.81328 15.3059 9.81729 15.3071 9.82188 15.3082H9.83391C9.8385 15.3082 9.84251 15.3071 9.84595 15.3048C9.84939 15.3036 9.85283 15.3019 9.85627 15.2996L11.249 14.4949L7.23926 12.1805C7.19971 12.1565 7.16189 12.1272 7.1275 12.0946C7.09418 12.0611 7.06529 12.0236 7.04153 11.9828C7.01917 11.9415 7.00026 11.8985 6.98822 11.8521C6.97619 11.8074 6.96931 11.761 6.97103 11.7128V7.80797C6.80252 7.86987 6.63917 7.94553 6.48442 8.03494C6.32967 8.12607 6.18352 8.22924 6.04596 8.34444C5.91013 8.45965 5.78289 8.58688 5.66769 8.72444C5.55248 8.86028 5.45103 9.00815 5.36162 9.1629H5.35818ZM16.7633 11.8177C16.8046 11.8418 16.8424 11.8693 16.8768 11.9037C16.9094 11.9364 16.9387 11.9742 16.9628 12.0155C16.9851 12.0567 17.004 12.1014 17.0161 12.1461C17.0264 12.1926 17.0332 12.239 17.0315 12.2871V16.192C17.5835 15.9891 18.0649 15.6332 18.4208 15.1655C18.7785 14.6978 18.9934 14.139 19.0433 13.5544C19.0931 12.9698 18.9762 12.3817 18.7046 11.8607C18.4329 11.3397 18.0185 10.9064 17.5095 10.6141L14.1893 8.69521C14.1858 8.69406 14.1818 8.69292 14.1772 8.69177H14.1652C14.1618 8.69292 14.1578 8.69406 14.1532 8.69521C14.1497 8.69636 14.1463 8.69808 14.1429 8.70037L12.757 9.50163L16.7667 11.8177H16.7633ZM18.1475 9.7372H18.1457V9.73892L18.1475 9.7372ZM18.1457 9.73548C18.2455 9.15774 18.1784 8.56281 17.9514 8.02119C17.7262 7.47956 17.3496 7.01359 16.8682 6.67658C16.3867 6.34128 15.8193 6.1487 15.233 6.12291C14.6449 6.09884 14.0638 6.24155 13.5548 6.53386L10.2345 8.45105C10.2311 8.45334 10.2282 8.45621 10.2259 8.45965L10.2191 8.46996C10.2179 8.4734 10.2168 8.47741 10.2156 8.482C10.2145 8.48544 10.2139 8.48945 10.2139 8.49403V10.0966L14.2237 7.78046C14.2649 7.75639 14.3096 7.7392 14.3543 7.72544C14.4008 7.7134 14.4472 7.70825 14.4936 7.70825C14.5418 7.70825 14.5882 7.7134 14.6346 7.72544C14.6793 7.7392 14.7223 7.75639 14.7636 7.78046L18.0494 9.67874C18.0787 9.69593 18.1217 9.72 18.1457 9.73548ZM9.45735 7.96101C9.45735 7.91458 9.46423 7.86816 9.47627 7.82173C9.4883 7.77702 9.5055 7.73232 9.52957 7.69105C9.55364 7.6515 9.58115 7.61368 9.61554 7.57929C9.64821 7.54662 9.68604 7.51739 9.72731 7.49503L13.0132 5.59848C13.0441 5.57957 13.0871 5.55549 13.1112 5.54346C12.6607 5.1669 12.1105 4.92618 11.5276 4.85224C10.9447 4.77658 10.3532 4.86943 9.82188 5.11875C9.28885 5.36807 8.83835 5.76527 8.52369 6.26047C8.20903 6.75739 8.04224 7.33169 8.04224 7.91974V11.7541C8.04339 11.7587 8.04454 11.7627 8.04568 11.7661C8.04683 11.7696 8.04855 11.773 8.05084 11.7765C8.05313 11.7799 8.056 11.7833 8.05944 11.7868C8.06173 11.7891 8.06517 11.7914 8.06976 11.7937L9.45735 12.5949V7.96101ZM10.2105 13.0282L11.997 14.0599L13.7835 13.0282V10.9666L11.9987 9.93493L10.2122 10.9666L10.2105 13.0282Z", + "fill": "white" + }, + "children": [] + } + ] + }, + "name": "OpenaiBlue" +} diff --git a/web/app/components/base/icons/src/public/llm/OpenaiBlue.tsx b/web/app/components/base/icons/src/public/llm/OpenaiBlue.tsx new file mode 100644 index 0000000000..9934a77591 --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/OpenaiBlue.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './OpenaiBlue.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'OpenaiBlue' + +export default Icon diff --git a/web/app/components/base/icons/src/public/llm/OpenaiSmall.json b/web/app/components/base/icons/src/public/llm/OpenaiSmall.json new file mode 100644 index 0000000000..aa72f614bc --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/OpenaiSmall.json @@ -0,0 +1,128 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "26", + "height": "26", + "viewBox": "0 0 26 26", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "clip-path": "url(#clip0_3892_83671)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M1 13C1 9.27247 1 7.4087 1.60896 5.93853C2.42092 3.97831 3.97831 2.42092 5.93853 1.60896C7.4087 1 9.27247 1 13 1C16.7275 1 18.5913 1 20.0615 1.60896C22.0217 2.42092 23.5791 3.97831 24.391 5.93853C25 7.4087 25 9.27247 25 13C25 16.7275 25 18.5913 24.391 20.0615C23.5791 22.0217 22.0217 23.5791 20.0615 24.391C18.5913 25 16.7275 25 13 25C9.27247 25 7.4087 25 5.93853 24.391C3.97831 23.5791 2.42092 22.0217 1.60896 20.0615C1 18.5913 1 16.7275 1 13Z", + "fill": "white" + }, + "children": [] + }, + { + "type": "element", + "name": "rect", + "attributes": { + "width": "24", + "height": "24", + "transform": "translate(1 1)", + "fill": "url(#pattern0_3892_83671)" + }, + "children": [] + }, + { + "type": "element", + "name": "rect", + "attributes": { + "width": "24", + "height": "24", + "transform": "translate(1 1)", + "fill": "white", + "fill-opacity": "0.01" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M13 0.75C14.8603 0.75 16.2684 0.750313 17.3945 0.827148C18.5228 0.904144 19.3867 1.05876 20.1572 1.37793C22.1787 2.21525 23.7847 3.82133 24.6221 5.84277C24.9412 6.61333 25.0959 7.47723 25.1729 8.60547C25.2497 9.73161 25.25 11.1397 25.25 13C25.25 14.8603 25.2497 16.2684 25.1729 17.3945C25.0959 18.5228 24.9412 19.3867 24.6221 20.1572C23.7847 22.1787 22.1787 23.7847 20.1572 24.6221C19.3867 24.9412 18.5228 25.0959 17.3945 25.1729C16.2684 25.2497 14.8603 25.25 13 25.25C11.1397 25.25 9.73161 25.2497 8.60547 25.1729C7.47723 25.0959 6.61333 24.9412 5.84277 24.6221C3.82133 23.7847 2.21525 22.1787 1.37793 20.1572C1.05876 19.3867 0.904144 18.5228 0.827148 17.3945C0.750313 16.2684 0.75 14.8603 0.75 13C0.75 11.1397 0.750313 9.73161 0.827148 8.60547C0.904144 7.47723 1.05876 6.61333 1.37793 5.84277C2.21525 3.82133 3.82133 2.21525 5.84277 1.37793C6.61333 1.05876 7.47723 0.904144 8.60547 0.827148C9.73161 0.750313 11.1397 0.75 13 0.75Z", + "stroke": "#101828", + "stroke-opacity": "0.08", + "stroke-width": "0.5" + }, + "children": [] + }, + { + "type": "element", + "name": "defs", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "pattern", + "attributes": { + "id": "pattern0_3892_83671", + "patternContentUnits": "objectBoundingBox", + "width": "1", + "height": "1" + }, + "children": [ + { + "type": "element", + "name": "use", + "attributes": { + "xlink:href": "#image0_3892_83671", + "transform": "scale(0.00625)" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "clipPath", + "attributes": { + "id": "clip0_3892_83671" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M1 13C1 9.27247 1 7.4087 1.60896 5.93853C2.42092 3.97831 3.97831 2.42092 5.93853 1.60896C7.4087 1 9.27247 1 13 1C16.7275 1 18.5913 1 20.0615 1.60896C22.0217 2.42092 23.5791 3.97831 24.391 5.93853C25 7.4087 25 9.27247 25 13C25 16.7275 25 18.5913 24.391 20.0615C23.5791 22.0217 22.0217 23.5791 20.0615 24.391C18.5913 25 16.7275 25 13 25C9.27247 25 7.4087 25 5.93853 24.391C3.97831 23.5791 2.42092 22.0217 1.60896 20.0615C1 18.5913 1 16.7275 1 13Z", + "fill": "white" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "image", + "attributes": { + "id": "image0_3892_83671", + "width": "160", + "height": "160", + "preserveAspectRatio": "none", + "xlink:href": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAACgCAYAAACLz2ctAAAACXBIWXMAACxLAAAsSwGlPZapAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAA0ySURBVHgB7Z3vsdM6E8Z93nm/QwdABUAFgQqACgIVABUAFcCpAKgAOoBUwKECOBUAFfj6yYzumNx4pV2vtKtkfzO+3CHESezHWu0frS7GiSEIjPjfEASGhAADU0KAgSkhwMCUEGBgSggwMCUEGJgSAgxMCQEGpoQAA1NCgIEpIcDAlBBgYEoIMDAlBBiYEgIMTAkBBqaEAANTQoCBKSHAwJQQYGBKCDAwJQQYmPL/4cz5/fv38PXr12G32w0/f/4crq6u9n+P/0/cvHlzuH379v7Pe/fuDZvNZv8n/i5Yx8U5LkyH6D5+/Dh8/vx5Lz4pEOGDBw+G58+fhxiljGfEly9fxkkweODUD5wX5w94nIUAf/z4UU14x4SIzwvKOHkBvn79epzmbk3ENz9evHgx/vr1awxoTnYOCCfi2bNnq+Z4a8G8cDLLMT8kOEkBwpN9+PDh3tngMvd4EzgPBC05H3j37t3eUTkknffw3PjsdMDROWnGE+PDhw8sk4s526tXr/YORM5k4vVPnz6NT58+HSeRskwypgJ4/yRG9vsnEe5NOj771DgpAUJ8JTcUAoXovn37Nq7h/fv3zZyb+XeHgE/F4z4ZAUJMJTdwMoXqzgFGJu6IqHHgM/HQ9cxJCBBhj5wA8PraES8HRtXWIsTRc+jnJHLBcDjmqbNDttvtMImv+oT+4uLiL+elFfD079y5M7x582bojrFzMLkfiNEBo1JtMB+zMMHHDjgsPY2GXYdhMOrhyV9iEt8wCXSo+fnSWCNG41TQcOvWrb9eQ0jm+vp6H07CwQ3/dBV/HDsG3uCwMBI8fvx4rAWcmNzIOyzM1eA5c50gzF3fvn3LGmXhLdee82rQrQBh9gbC4ahlhiAgbmoPD4PW9+EUVPQgwm4FSI1+EIk2kkqamhUy+I0lI2LNh1GDLgWIC9rK9MJcUmJfGnlgMmuD61Dy3eCYeC2M6FKA1PxLc8SRVNLUCHTnKIk/IpXnkS4FiCd6yeRpIAmrWAeDS0ToMX3XnQAp87t27icpXIVQvdzYnAjx4HqjOwFCZEsXWDoCpbAKx9ymggZvcyuYWup7e8sddyNA3GiIb8n8Sp9uSVgFE3+vk3p8L2r6gNc84V6AMG/wbHMi4U6yvYRVMGpri5mKkXqbC7oVIFcgKPQshZvFgPi1Y4v4vvOHStuJoa6dlrOmgTsBSlewlVYLU05Mi3le7sGCedcQYm4U9DKFcCVAbjm9xKyUjn7aIxInoK1VaEoJnWMxauJGgDnvTUuAORHUCKtIl4auTaNREYOaxRoczAWIkQEXY434tASILIYmWnWCUrOMa7t0TjwQHjAVIC7QUlhl6aLVFKDWvKhGJwYIWWI2qe/hoUjBtCQfxZypGxUFGgChwHJK8A81WVtOj8JRlMXfv39ffUE8il+nacq+ABdNlUqhliGUXPvamAkQNyp3ISEIiA7igwg9MzkNe+GhAru0ghm/aZqnsbprQYhPnjzZP7zUOpjE3bt3F1/78+fPYM5oADU5TsextQ3U+zRMsARJQPtYuVZpadXhAQeHglqumntvC5oLEBc65xFut9uj8zFPAsT3k3juubgirg9nXjwMdNiGinuepQBzTznEt4QXAR5mMUoOblyxtOJ5fhzzlikB4t9b01SAOdObKyiwFiA+QzI6SeOKGCkli91THxoQI+CMXJVGboSwEiC+FzdWmdJ4Gkjmh8ksexdgMy8YXiLltcEb9LaOdR5W4YQ+0nvRKUEDXBdcnynfzfKWJ9Huu0ZQ5zVnbEQuAV9CyxFQK4tRo4GQZH645prVpIkAcxUopZPzFgKs1U9au2WGNGwzPzysGW5igi8vLxdfg5nwYnphbpFpKM1ipC6mJSDrgHOXBpBzJLM8CVEUpHfTfXVsAOU5ckMTQ8URkHOksvw1DoImXLPsZYFSdQFS5pdbmetBgEtl+dIVddpmkBO2OYswzOQ9Ll4AbnWHpQBL43laAeQ1cEZlaxFWFyCVruJ6YRYClJblS7pZaYuh1JO3rI6uLkDKLHFpLcC1bTY8zA9LC36tPOLqAtRcx9tKgHhoNG+IRIjaZjk3N4TwLRYqVRfgUtJesjSwtgBrt9mQzA/ned215Kp3LBoYVRfg0o+VLIrxWA8ogSNAbbOc89RbZ0fMKqItusn3SsrrIpC9Noidyye37rRvJkDpvmvnTGrKviabggcfGZQlkAVqucFjdQEuPW0a6ahzBZVFqHLBru8SkLqj0nctR8EYAR0BUcDUljA3y9xSMZAbBVvdn+oCXEp4r9n+9Bi73W7onVRgwKmNnK+S41xPnJ8aBakCEk3MTDDQnGtgOWSXW1UdASMbqlyw0U6pswazDCFywPmXaDUPrC5A6inTHrUgQqlJ8gh+D/a4KzXLXAcC92ZJ4K3McHUBbjabxdfw1HIoqV/jLtz2zrzur8Qsf//+feBAibvFKFhdgHjClkZBPGGcHwkBlhZfQtzd7iB5BIgPIoQYKbPMHbVaWqhjNPGCNV1+3IBSkwSSWZaGLLyB345gshaUhWpiQcYGUG3CcEiS7pK8KtJ/mtU5UgaiAKEE7aWWS9exRUPzJiMgZYYB5mtcJJ4inJOWUf5esEyLNgtE51x+qTC4nmLwXyyzVc0EmEv/cAOpc5KnCCF6W9zeA2cxAgJqFEzhkzXAS06eYgixHMu0aFMBYgREl88lYIZfvnw5rAXmGE0tqc86Rm1vGb8Pn+GNJQE2GRnHxuS2khoG3ZVaksZC2uXwKO8vWbJp5QVrb3/GwaRDKtW1c5iFTDTXKFgsl+Q2sbQS4NK5WuyoZNYlH8sWczekxhoNbr89SXd6ye6bVgKkdlRqsUbEdJuGUjFYLtyeC7FkXcaarloWAsTDtXSu0u3P1mC+UY1lKwnNndElzco9CNB6HxEXW3Vx2t566beXRmXOHnDeBEidp1XzIhcClOyjpr2ZoMQs43tzvzs14rcWIPV7W60RNhdgiUdcMhJpIdkmgfvAeBCgVtPQtZhu1QXWRuHXrhA7BBkUpPS0sik4B4LiODxlZ6gyOCQMWn1XcwFSCe/SSPzaFWLHwPkgRCp9SIHvjvdKO5jWBNeLqkbfbrdDK8wFeH19vfgaCi+lK8Q0KjkgInS656akUqkYKnW8geuS65zftLJoNIbawTzNQ/CnZFusNY19pCGaksD5YDgHzKUlERFoibkAKU/s2LZT3LwuN2wjabULT5hz46wEmHuILbbu6mIEPEQSt8ttk5DSZ5xz4pB0T7UQYC7EhIeolec7x7UAc00icfMl+dbDC91i88E5LQVYOqK3Nr0JcwFSPf9KcpHSvC7MET5b0tl+bYFEKwHi95U8WFp72kkwF6BWF32MlhrbV1EmSutG1RYgJy8taRSqibkAqYsp7aKqLcS0KY0G1BJVDQFyphKYF1v0hZ7jOheMv5cgMcvHjtKwCgfKE9UQYOmhXfArxYUAqdDKGgFIwjZJ+NqT8pK4YisBYvsJL7gQIFUUqTFH4ZhlSViFghNX1CokaPlgrcWFAHOtO7QEQZVCaZd3ScrySz+fKqNv9fu0cCFAQI0SmrVph/PDGvO80vDH4chbChU5OCa81lsvcHAjwNxT7WFz5RySsnyJQHIZG4gfUxfPwku4ESCg5mle9rc9hqQsf818bEnkuEY4pwfvthRXAqSyItqmWAPJPE+6++acpc/z5mCU4EqAIFcOb7m16BzNFXUcqPmfRycjhzsBlnh42qviOEjKtTQX2C/FNT1PUSjcCRCUFJ+23umb22Zjbm610FiM5A2XAsTNLlmZppmjpZCUa9X4bpzi3V5wKUCAC1oyx6qxUD3RKqxSArV81aKSWQu3AgS46KUjj+aNl+SQa6a5ci3teoj3LeFagIAjwjQiYq7GDVzjJksKVHFo548P0aig8coF/jM4Jy0l5C61xHJKtO3FgeWGN27c+Ot1LAnFOa+urvYHF6z3rd0OGAvIqeWdPwp3UHLL2Am1WmZIjhp9C4+RS7lZltJr0Y0AE9wGk5qHdlhliZKQT4tNZFrQnQCBJBisddSOt2HumhvpIb5ewy6HdCnARI31H6UmWLt7KGddcg+VQaV0LcAE5mPwBiV9BtccGIXXxiAhJk5BQ48FBxQnIcA5GJkwf+J2XYWA0TgdI5GkkTnej8/OhWPwOh4YiI4zjfBYTq9BF2GYNSC8gh6E6UggRIPwBY5j3a+m9Jt405wU/pmDz0bIR9IPEedDf8GSDbt74+QFuAZp/FGTFrFGS8z7A3omdUvlbvmlQWpw6a2zqjpjUETL0I/XFWw1CAEy0dgPhBJez4UFEmIOKATzwsvLy/0OmJI8cgJzvEePHu3b4lru22tFCFCBVNCw2+3+9boPPd40j0uFEZvNZi++cxTdnBBgYEp4wYEpIcDAlBBgYEoIMDAlBBiYEgIMTAkBBqaEAANTQoCBKSHAwJQQYGBKCDAwJQQYmBICDEwJAQamhAADU0KAgSkhwMCUEGBgSggwMCUEGJgSAgxMCQEGpvwDojzI2oXtJzYAAAAASUVORK5CYII=" + }, + "children": [] + } + ] + } + ] + }, + "name": "OpenaiSmall" +} diff --git a/web/app/components/base/icons/src/public/llm/OpenaiSmall.tsx b/web/app/components/base/icons/src/public/llm/OpenaiSmall.tsx new file mode 100644 index 0000000000..6307091e0b --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/OpenaiSmall.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './OpenaiSmall.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'OpenaiSmall' + +export default Icon diff --git a/web/app/components/base/icons/src/public/llm/OpenaiTeal.json b/web/app/components/base/icons/src/public/llm/OpenaiTeal.json new file mode 100644 index 0000000000..ffd0981512 --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/OpenaiTeal.json @@ -0,0 +1,37 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "24", + "height": "24", + "viewBox": "0 0 24 24", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "width": "24", + "height": "24", + "rx": "6", + "fill": "#009688" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M19.7758 11.5959C19.9546 11.9948 20.0681 12.4213 20.1145 12.8563C20.1592 13.2913 20.1369 13.7315 20.044 14.1596C19.9529 14.5878 19.7947 14.9987 19.5746 15.377C19.4302 15.6298 19.2599 15.867 19.0639 16.0854C18.8696 16.3021 18.653 16.4981 18.4174 16.67C18.1801 16.842 17.9274 16.9864 17.6591 17.105C17.3926 17.222 17.1141 17.3114 16.8286 17.3698C16.6945 17.7859 16.4951 18.1797 16.2371 18.5339C15.9809 18.8881 15.6697 19.1993 15.3155 19.4555C14.9613 19.7134 14.5693 19.9129 14.1532 20.047C13.7371 20.1829 13.302 20.2499 12.8636 20.2499C12.573 20.2516 12.2807 20.2207 11.9953 20.1622C11.7116 20.102 11.433 20.0109 11.1665 19.8923C10.9 19.7736 10.6472 19.6258 10.4116 19.4538C10.1778 19.2819 9.96115 19.0841 9.76857 18.8658C9.33871 18.9586 8.89853 18.981 8.46351 18.9363C8.02849 18.8898 7.60207 18.7763 7.20143 18.5975C6.80252 18.4204 6.43284 18.1797 6.10786 17.8857C5.78289 17.5916 5.50606 17.2478 5.28769 16.8695C5.14153 16.6167 5.02117 16.3502 4.93004 16.0734C4.83891 15.7965 4.77873 15.5111 4.74778 15.2205C4.71683 14.9317 4.71855 14.6393 4.7495 14.3488C4.78045 14.0599 4.84407 13.7745 4.9352 13.4976C4.64289 13.1727 4.40217 12.803 4.22335 12.4041C4.04624 12.0034 3.93104 11.5787 3.88634 11.1437C3.83991 10.7087 3.86398 10.2685 3.95511 9.84036C4.04624 9.41222 4.20443 9.00127 4.42452 8.62299C4.56896 8.37023 4.73918 8.13123 4.93348 7.91458C5.12778 7.69793 5.34615 7.50191 5.58171 7.32997C5.81728 7.15802 6.07176 7.01187 6.33827 6.89495C6.6065 6.7763 6.88506 6.68861 7.17048 6.63015C7.3046 6.21232 7.50406 5.82029 7.76026 5.46608C8.01817 5.11188 8.32939 4.80066 8.6836 4.54274C9.03781 4.28654 9.42984 4.08708 9.84595 3.95125C10.2621 3.81713 10.6971 3.74835 11.1355 3.75007C11.4261 3.74835 11.7184 3.77758 12.0039 3.83776C12.2893 3.89794 12.5678 3.98736 12.8344 4.106C13.1009 4.22636 13.3536 4.37251 13.5892 4.54446C13.8248 4.71812 14.0414 4.91414 14.234 5.13251C14.6621 5.04138 15.1023 5.01903 15.5373 5.06373C15.9723 5.10844 16.3971 5.22364 16.7977 5.40074C17.1966 5.57957 17.5663 5.81857 17.8913 6.1126C18.2162 6.4049 18.4931 6.74707 18.7114 7.12707C18.8576 7.37811 18.9779 7.64463 19.0691 7.92318C19.1602 8.20001 19.2221 8.48544 19.2513 8.77602C19.2823 9.06661 19.2823 9.35892 19.2496 9.64951C19.2187 9.94009 19.155 10.2255 19.0639 10.5024C19.3579 10.8273 19.5969 11.1953 19.7758 11.5959ZM14.0466 18.9363C14.4214 18.7815 14.7619 18.5528 15.049 18.2657C15.3362 17.9785 15.5648 17.6381 15.7196 17.2615C15.8743 16.8867 15.9552 16.4843 15.9552 16.0785V12.2442C15.954 12.2407 15.9529 12.2367 15.9517 12.2321C15.9506 12.2287 15.9488 12.2252 15.9466 12.2218C15.9443 12.2184 15.9414 12.2155 15.938 12.2132C15.9345 12.2098 15.9311 12.2075 15.9276 12.2063L14.54 11.4051V16.0373C14.54 16.0837 14.5332 16.1318 14.5211 16.1765C14.5091 16.223 14.4919 16.2659 14.4678 16.3072C14.4438 16.3485 14.4162 16.3863 14.3819 16.419C14.3484 16.4523 14.3109 16.4812 14.2701 16.505L10.9842 18.4015C10.9567 18.4187 10.9103 18.4428 10.8862 18.4565C11.0221 18.5717 11.1699 18.6732 11.3247 18.7626C11.4811 18.852 11.6428 18.9277 11.8113 18.9896C11.9798 19.0497 12.1535 19.0962 12.3288 19.1271C12.5059 19.1581 12.6848 19.1735 12.8636 19.1735C13.2694 19.1735 13.6717 19.0927 14.0466 18.9363ZM6.22135 16.333C6.42596 16.6855 6.69592 16.9916 7.01745 17.2392C7.34071 17.4868 7.70695 17.6673 8.09899 17.7722C8.49102 17.8771 8.90025 17.9046 9.3026 17.8513C9.70495 17.798 10.0918 17.6673 10.4443 17.4644L13.7663 15.5472L13.7749 15.5386C13.7772 15.5363 13.7789 15.5329 13.78 15.5283C13.7823 15.5249 13.7841 15.5214 13.7852 15.518V13.9017L9.77545 16.2212C9.73418 16.2453 9.6912 16.2625 9.64649 16.2763C9.60007 16.2883 9.55364 16.2935 9.5055 16.2935C9.45907 16.2935 9.41265 16.2883 9.36622 16.2763C9.32152 16.2625 9.27681 16.2453 9.23554 16.2212L5.94967 14.323C5.92044 14.3058 5.87746 14.28 5.85339 14.2645C5.82244 14.4416 5.80696 14.6204 5.80696 14.7993C5.80696 14.9781 5.82415 15.1569 5.85511 15.334C5.88605 15.5094 5.9342 15.6831 5.99438 15.8516C6.05628 16.0201 6.13194 16.1817 6.22135 16.3364V16.333ZM5.35818 9.1629C5.15529 9.51539 5.02461 9.90398 4.97131 10.3063C4.918 10.7087 4.94552 11.1162 5.0504 11.51C5.15529 11.902 5.33583 12.2682 5.58343 12.5915C5.83103 12.913 6.13881 13.183 6.48958 13.3859L9.80984 15.3048C9.81328 15.3059 9.81729 15.3071 9.82188 15.3082H9.83391C9.8385 15.3082 9.84251 15.3071 9.84595 15.3048C9.84939 15.3036 9.85283 15.3019 9.85627 15.2996L11.249 14.4949L7.23926 12.1805C7.19971 12.1565 7.16189 12.1272 7.1275 12.0946C7.09418 12.0611 7.06529 12.0236 7.04153 11.9828C7.01917 11.9415 7.00026 11.8985 6.98822 11.8521C6.97619 11.8074 6.96931 11.761 6.97103 11.7128V7.80797C6.80252 7.86987 6.63917 7.94553 6.48442 8.03494C6.32967 8.12607 6.18352 8.22924 6.04596 8.34444C5.91013 8.45965 5.78289 8.58688 5.66769 8.72444C5.55248 8.86028 5.45103 9.00815 5.36162 9.1629H5.35818ZM16.7633 11.8177C16.8046 11.8418 16.8424 11.8693 16.8768 11.9037C16.9094 11.9364 16.9387 11.9742 16.9628 12.0155C16.9851 12.0567 17.004 12.1014 17.0161 12.1461C17.0264 12.1926 17.0332 12.239 17.0315 12.2871V16.192C17.5835 15.9891 18.0649 15.6332 18.4208 15.1655C18.7785 14.6978 18.9934 14.139 19.0433 13.5544C19.0931 12.9698 18.9762 12.3817 18.7046 11.8607C18.4329 11.3397 18.0185 10.9064 17.5095 10.6141L14.1893 8.69521C14.1858 8.69406 14.1818 8.69292 14.1772 8.69177H14.1652C14.1618 8.69292 14.1578 8.69406 14.1532 8.69521C14.1497 8.69636 14.1463 8.69808 14.1429 8.70037L12.757 9.50163L16.7667 11.8177H16.7633ZM18.1475 9.7372H18.1457V9.73892L18.1475 9.7372ZM18.1457 9.73548C18.2455 9.15774 18.1784 8.56281 17.9514 8.02119C17.7262 7.47956 17.3496 7.01359 16.8682 6.67658C16.3867 6.34128 15.8193 6.1487 15.233 6.12291C14.6449 6.09884 14.0638 6.24155 13.5548 6.53386L10.2345 8.45105C10.2311 8.45334 10.2282 8.45621 10.2259 8.45965L10.2191 8.46996C10.2179 8.4734 10.2168 8.47741 10.2156 8.482C10.2145 8.48544 10.2139 8.48945 10.2139 8.49403V10.0966L14.2237 7.78046C14.2649 7.75639 14.3096 7.7392 14.3543 7.72544C14.4008 7.7134 14.4472 7.70825 14.4936 7.70825C14.5418 7.70825 14.5882 7.7134 14.6346 7.72544C14.6793 7.7392 14.7223 7.75639 14.7636 7.78046L18.0494 9.67874C18.0787 9.69593 18.1217 9.72 18.1457 9.73548ZM9.45735 7.96101C9.45735 7.91458 9.46423 7.86816 9.47627 7.82173C9.4883 7.77702 9.5055 7.73232 9.52957 7.69105C9.55364 7.6515 9.58115 7.61368 9.61554 7.57929C9.64821 7.54662 9.68604 7.51739 9.72731 7.49503L13.0132 5.59848C13.0441 5.57957 13.0871 5.55549 13.1112 5.54346C12.6607 5.1669 12.1105 4.92618 11.5276 4.85224C10.9447 4.77658 10.3532 4.86943 9.82188 5.11875C9.28885 5.36807 8.83835 5.76527 8.52369 6.26047C8.20903 6.75739 8.04224 7.33169 8.04224 7.91974V11.7541C8.04339 11.7587 8.04454 11.7627 8.04568 11.7661C8.04683 11.7696 8.04855 11.773 8.05084 11.7765C8.05313 11.7799 8.056 11.7833 8.05944 11.7868C8.06173 11.7891 8.06517 11.7914 8.06976 11.7937L9.45735 12.5949V7.96101ZM10.2105 13.0282L11.997 14.0599L13.7835 13.0282V10.9666L11.9987 9.93493L10.2122 10.9666L10.2105 13.0282Z", + "fill": "white" + }, + "children": [] + } + ] + }, + "name": "OpenaiTeal" +} diff --git a/web/app/components/base/icons/src/public/llm/OpenaiTeal.tsx b/web/app/components/base/icons/src/public/llm/OpenaiTeal.tsx new file mode 100644 index 0000000000..ef803ea52f --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/OpenaiTeal.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './OpenaiTeal.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'OpenaiTeal' + +export default Icon diff --git a/web/app/components/base/icons/src/public/llm/OpenaiViolet.json b/web/app/components/base/icons/src/public/llm/OpenaiViolet.json new file mode 100644 index 0000000000..e80a85507e --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/OpenaiViolet.json @@ -0,0 +1,37 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "24", + "height": "24", + "viewBox": "0 0 24 24", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "rect", + "attributes": { + "width": "24", + "height": "24", + "rx": "6", + "fill": "#AB68FF" + }, + "children": [] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M19.7758 11.5959C19.9546 11.9948 20.0681 12.4213 20.1145 12.8563C20.1592 13.2913 20.1369 13.7315 20.044 14.1596C19.9529 14.5878 19.7947 14.9987 19.5746 15.377C19.4302 15.6298 19.2599 15.867 19.0639 16.0854C18.8696 16.3021 18.653 16.4981 18.4174 16.67C18.1801 16.842 17.9274 16.9864 17.6591 17.105C17.3926 17.222 17.1141 17.3114 16.8286 17.3698C16.6945 17.7859 16.4951 18.1797 16.2371 18.5339C15.9809 18.8881 15.6697 19.1993 15.3155 19.4555C14.9613 19.7134 14.5693 19.9129 14.1532 20.047C13.7371 20.1829 13.302 20.2499 12.8636 20.2499C12.573 20.2516 12.2807 20.2207 11.9953 20.1622C11.7116 20.102 11.433 20.0109 11.1665 19.8923C10.9 19.7736 10.6472 19.6258 10.4116 19.4538C10.1778 19.2819 9.96115 19.0841 9.76857 18.8658C9.33871 18.9586 8.89853 18.981 8.46351 18.9363C8.02849 18.8898 7.60207 18.7763 7.20143 18.5975C6.80252 18.4204 6.43284 18.1797 6.10786 17.8857C5.78289 17.5916 5.50606 17.2478 5.28769 16.8695C5.14153 16.6167 5.02117 16.3502 4.93004 16.0734C4.83891 15.7965 4.77873 15.5111 4.74778 15.2205C4.71683 14.9317 4.71855 14.6393 4.7495 14.3488C4.78045 14.0599 4.84407 13.7745 4.9352 13.4976C4.64289 13.1727 4.40217 12.803 4.22335 12.4041C4.04624 12.0034 3.93104 11.5787 3.88634 11.1437C3.83991 10.7087 3.86398 10.2685 3.95511 9.84036C4.04624 9.41222 4.20443 9.00127 4.42452 8.62299C4.56896 8.37023 4.73918 8.13123 4.93348 7.91458C5.12778 7.69793 5.34615 7.50191 5.58171 7.32997C5.81728 7.15802 6.07176 7.01187 6.33827 6.89495C6.6065 6.7763 6.88506 6.68861 7.17048 6.63015C7.3046 6.21232 7.50406 5.82029 7.76026 5.46608C8.01817 5.11188 8.32939 4.80066 8.6836 4.54274C9.03781 4.28654 9.42984 4.08708 9.84595 3.95125C10.2621 3.81713 10.6971 3.74835 11.1355 3.75007C11.4261 3.74835 11.7184 3.77758 12.0039 3.83776C12.2893 3.89794 12.5678 3.98736 12.8344 4.106C13.1009 4.22636 13.3536 4.37251 13.5892 4.54446C13.8248 4.71812 14.0414 4.91414 14.234 5.13251C14.6621 5.04138 15.1023 5.01903 15.5373 5.06373C15.9723 5.10844 16.3971 5.22364 16.7977 5.40074C17.1966 5.57957 17.5663 5.81857 17.8913 6.1126C18.2162 6.4049 18.4931 6.74707 18.7114 7.12707C18.8576 7.37811 18.9779 7.64463 19.0691 7.92318C19.1602 8.20001 19.2221 8.48544 19.2513 8.77602C19.2823 9.06661 19.2823 9.35892 19.2496 9.64951C19.2187 9.94009 19.155 10.2255 19.0639 10.5024C19.3579 10.8273 19.5969 11.1953 19.7758 11.5959ZM14.0466 18.9363C14.4214 18.7815 14.7619 18.5528 15.049 18.2657C15.3362 17.9785 15.5648 17.6381 15.7196 17.2615C15.8743 16.8867 15.9552 16.4843 15.9552 16.0785V12.2442C15.954 12.2407 15.9529 12.2367 15.9517 12.2321C15.9506 12.2287 15.9488 12.2252 15.9466 12.2218C15.9443 12.2184 15.9414 12.2155 15.938 12.2132C15.9345 12.2098 15.9311 12.2075 15.9276 12.2063L14.54 11.4051V16.0373C14.54 16.0837 14.5332 16.1318 14.5211 16.1765C14.5091 16.223 14.4919 16.2659 14.4678 16.3072C14.4438 16.3485 14.4162 16.3863 14.3819 16.419C14.3484 16.4523 14.3109 16.4812 14.2701 16.505L10.9842 18.4015C10.9567 18.4187 10.9103 18.4428 10.8862 18.4565C11.0221 18.5717 11.1699 18.6732 11.3247 18.7626C11.4811 18.852 11.6428 18.9277 11.8113 18.9896C11.9798 19.0497 12.1535 19.0962 12.3288 19.1271C12.5059 19.1581 12.6848 19.1735 12.8636 19.1735C13.2694 19.1735 13.6717 19.0927 14.0466 18.9363ZM6.22135 16.333C6.42596 16.6855 6.69592 16.9916 7.01745 17.2392C7.34071 17.4868 7.70695 17.6673 8.09899 17.7722C8.49102 17.8771 8.90025 17.9046 9.3026 17.8513C9.70495 17.798 10.0918 17.6673 10.4443 17.4644L13.7663 15.5472L13.7749 15.5386C13.7772 15.5363 13.7789 15.5329 13.78 15.5283C13.7823 15.5249 13.7841 15.5214 13.7852 15.518V13.9017L9.77545 16.2212C9.73418 16.2453 9.6912 16.2625 9.64649 16.2763C9.60007 16.2883 9.55364 16.2935 9.5055 16.2935C9.45907 16.2935 9.41265 16.2883 9.36622 16.2763C9.32152 16.2625 9.27681 16.2453 9.23554 16.2212L5.94967 14.323C5.92044 14.3058 5.87746 14.28 5.85339 14.2645C5.82244 14.4416 5.80696 14.6204 5.80696 14.7993C5.80696 14.9781 5.82415 15.1569 5.85511 15.334C5.88605 15.5094 5.9342 15.6831 5.99438 15.8516C6.05628 16.0201 6.13194 16.1817 6.22135 16.3364V16.333ZM5.35818 9.1629C5.15529 9.51539 5.02461 9.90398 4.97131 10.3063C4.918 10.7087 4.94552 11.1162 5.0504 11.51C5.15529 11.902 5.33583 12.2682 5.58343 12.5915C5.83103 12.913 6.13881 13.183 6.48958 13.3859L9.80984 15.3048C9.81328 15.3059 9.81729 15.3071 9.82188 15.3082H9.83391C9.8385 15.3082 9.84251 15.3071 9.84595 15.3048C9.84939 15.3036 9.85283 15.3019 9.85627 15.2996L11.249 14.4949L7.23926 12.1805C7.19971 12.1565 7.16189 12.1272 7.1275 12.0946C7.09418 12.0611 7.06529 12.0236 7.04153 11.9828C7.01917 11.9415 7.00026 11.8985 6.98822 11.8521C6.97619 11.8074 6.96931 11.761 6.97103 11.7128V7.80797C6.80252 7.86987 6.63917 7.94553 6.48442 8.03494C6.32967 8.12607 6.18352 8.22924 6.04596 8.34444C5.91013 8.45965 5.78289 8.58688 5.66769 8.72444C5.55248 8.86028 5.45103 9.00815 5.36162 9.1629H5.35818ZM16.7633 11.8177C16.8046 11.8418 16.8424 11.8693 16.8768 11.9037C16.9094 11.9364 16.9387 11.9742 16.9628 12.0155C16.9851 12.0567 17.004 12.1014 17.0161 12.1461C17.0264 12.1926 17.0332 12.239 17.0315 12.2871V16.192C17.5835 15.9891 18.0649 15.6332 18.4208 15.1655C18.7785 14.6978 18.9934 14.139 19.0433 13.5544C19.0931 12.9698 18.9762 12.3817 18.7046 11.8607C18.4329 11.3397 18.0185 10.9064 17.5095 10.6141L14.1893 8.69521C14.1858 8.69406 14.1818 8.69292 14.1772 8.69177H14.1652C14.1618 8.69292 14.1578 8.69406 14.1532 8.69521C14.1497 8.69636 14.1463 8.69808 14.1429 8.70037L12.757 9.50163L16.7667 11.8177H16.7633ZM18.1475 9.7372H18.1457V9.73892L18.1475 9.7372ZM18.1457 9.73548C18.2455 9.15774 18.1784 8.56281 17.9514 8.02119C17.7262 7.47956 17.3496 7.01359 16.8682 6.67658C16.3867 6.34128 15.8193 6.1487 15.233 6.12291C14.6449 6.09884 14.0638 6.24155 13.5548 6.53386L10.2345 8.45105C10.2311 8.45334 10.2282 8.45621 10.2259 8.45965L10.2191 8.46996C10.2179 8.4734 10.2168 8.47741 10.2156 8.482C10.2145 8.48544 10.2139 8.48945 10.2139 8.49403V10.0966L14.2237 7.78046C14.2649 7.75639 14.3096 7.7392 14.3543 7.72544C14.4008 7.7134 14.4472 7.70825 14.4936 7.70825C14.5418 7.70825 14.5882 7.7134 14.6346 7.72544C14.6793 7.7392 14.7223 7.75639 14.7636 7.78046L18.0494 9.67874C18.0787 9.69593 18.1217 9.72 18.1457 9.73548ZM9.45735 7.96101C9.45735 7.91458 9.46423 7.86816 9.47627 7.82173C9.4883 7.77702 9.5055 7.73232 9.52957 7.69105C9.55364 7.6515 9.58115 7.61368 9.61554 7.57929C9.64821 7.54662 9.68604 7.51739 9.72731 7.49503L13.0132 5.59848C13.0441 5.57957 13.0871 5.55549 13.1112 5.54346C12.6607 5.1669 12.1105 4.92618 11.5276 4.85224C10.9447 4.77658 10.3532 4.86943 9.82188 5.11875C9.28885 5.36807 8.83835 5.76527 8.52369 6.26047C8.20903 6.75739 8.04224 7.33169 8.04224 7.91974V11.7541C8.04339 11.7587 8.04454 11.7627 8.04568 11.7661C8.04683 11.7696 8.04855 11.773 8.05084 11.7765C8.05313 11.7799 8.056 11.7833 8.05944 11.7868C8.06173 11.7891 8.06517 11.7914 8.06976 11.7937L9.45735 12.5949V7.96101ZM10.2105 13.0282L11.997 14.0599L13.7835 13.0282V10.9666L11.9987 9.93493L10.2122 10.9666L10.2105 13.0282Z", + "fill": "white" + }, + "children": [] + } + ] + }, + "name": "OpenaiViolet" +} diff --git a/web/app/components/base/icons/src/public/llm/OpenaiViolet.tsx b/web/app/components/base/icons/src/public/llm/OpenaiViolet.tsx new file mode 100644 index 0000000000..9aa08c0f3b --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/OpenaiViolet.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './OpenaiViolet.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'OpenaiViolet' + +export default Icon diff --git a/web/app/components/base/icons/src/public/llm/Tongyi.json b/web/app/components/base/icons/src/public/llm/Tongyi.json new file mode 100644 index 0000000000..9150ca226b --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/Tongyi.json @@ -0,0 +1,128 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "25", + "height": "25", + "viewBox": "0 0 25 25", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink" + }, + "children": [ + { + "type": "element", + "name": "g", + "attributes": { + "clip-path": "url(#clip0_6305_73327)" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M0.5 12.5C0.5 8.77247 0.5 6.9087 1.10896 5.43853C1.92092 3.47831 3.47831 1.92092 5.43853 1.10896C6.9087 0.5 8.77247 0.5 12.5 0.5C16.2275 0.5 18.0913 0.5 19.5615 1.10896C21.5217 1.92092 23.0791 3.47831 23.891 5.43853C24.5 6.9087 24.5 8.77247 24.5 12.5C24.5 16.2275 24.5 18.0913 23.891 19.5615C23.0791 21.5217 21.5217 23.0791 19.5615 23.891C18.0913 24.5 16.2275 24.5 12.5 24.5C8.77247 24.5 6.9087 24.5 5.43853 23.891C3.47831 23.0791 1.92092 21.5217 1.10896 19.5615C0.5 18.0913 0.5 16.2275 0.5 12.5Z", + "fill": "white" + }, + "children": [] + }, + { + "type": "element", + "name": "rect", + "attributes": { + "width": "24", + "height": "24", + "transform": "translate(0.5 0.5)", + "fill": "url(#pattern0_6305_73327)" + }, + "children": [] + }, + { + "type": "element", + "name": "rect", + "attributes": { + "width": "24", + "height": "24", + "transform": "translate(0.5 0.5)", + "fill": "white", + "fill-opacity": "0.01" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "path", + "attributes": { + "d": "M12.5 0.25C14.3603 0.25 15.7684 0.250313 16.8945 0.327148C18.0228 0.404144 18.8867 0.558755 19.6572 0.87793C21.6787 1.71525 23.2847 3.32133 24.1221 5.34277C24.4412 6.11333 24.5959 6.97723 24.6729 8.10547C24.7497 9.23161 24.75 10.6397 24.75 12.5C24.75 14.3603 24.7497 15.7684 24.6729 16.8945C24.5959 18.0228 24.4412 18.8867 24.1221 19.6572C23.2847 21.6787 21.6787 23.2847 19.6572 24.1221C18.8867 24.4412 18.0228 24.5959 16.8945 24.6729C15.7684 24.7497 14.3603 24.75 12.5 24.75C10.6397 24.75 9.23161 24.7497 8.10547 24.6729C6.97723 24.5959 6.11333 24.4412 5.34277 24.1221C3.32133 23.2847 1.71525 21.6787 0.87793 19.6572C0.558755 18.8867 0.404144 18.0228 0.327148 16.8945C0.250313 15.7684 0.25 14.3603 0.25 12.5C0.25 10.6397 0.250313 9.23161 0.327148 8.10547C0.404144 6.97723 0.558755 6.11333 0.87793 5.34277C1.71525 3.32133 3.32133 1.71525 5.34277 0.87793C6.11333 0.558755 6.97723 0.404144 8.10547 0.327148C9.23161 0.250313 10.6397 0.25 12.5 0.25Z", + "stroke": "#101828", + "stroke-opacity": "0.08", + "stroke-width": "0.5" + }, + "children": [] + }, + { + "type": "element", + "name": "defs", + "attributes": {}, + "children": [ + { + "type": "element", + "name": "pattern", + "attributes": { + "id": "pattern0_6305_73327", + "patternContentUnits": "objectBoundingBox", + "width": "1", + "height": "1" + }, + "children": [ + { + "type": "element", + "name": "use", + "attributes": { + "xlink:href": "#image0_6305_73327", + "transform": "scale(0.00625)" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "clipPath", + "attributes": { + "id": "clip0_6305_73327" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M0.5 12.5C0.5 8.77247 0.5 6.9087 1.10896 5.43853C1.92092 3.47831 3.47831 1.92092 5.43853 1.10896C6.9087 0.5 8.77247 0.5 12.5 0.5C16.2275 0.5 18.0913 0.5 19.5615 1.10896C21.5217 1.92092 23.0791 3.47831 23.891 5.43853C24.5 6.9087 24.5 8.77247 24.5 12.5C24.5 16.2275 24.5 18.0913 23.891 19.5615C23.0791 21.5217 21.5217 23.0791 19.5615 23.891C18.0913 24.5 16.2275 24.5 12.5 24.5C8.77247 24.5 6.9087 24.5 5.43853 23.891C3.47831 23.0791 1.92092 21.5217 1.10896 19.5615C0.5 18.0913 0.5 16.2275 0.5 12.5Z", + "fill": "white" + }, + "children": [] + } + ] + }, + { + "type": "element", + "name": "image", + "attributes": { + "id": "image0_6305_73327", + "width": "160", + "height": "160", + "preserveAspectRatio": "none", + "xlink:href": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAACgCAYAAACLz2ctAAAACXBIWXMAACxLAAAsSwGlPZapAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAm6SURBVHgB7Z09jFVFGIZn/Wf9I5FSKIVSKYndErolVJJQQUKliRWFNhY2UGxFItUmUJFARZaOQGekA0poF8slUcFFRVnPu9cDw/HuPWdmvpnvu3PeJ9m4MQH23H3P/DzzzczCVoMjRInXHCGKMIBEFQaQqMIAElUYQKIKA0hUYQCJKgwgUYUBJKowgEQVBpCowgASVRhAogoDSFRhAIkqDCBRhQEkqjCARBUGkKjCABJVGECiCgNIVGEAiSpvuJGyubnlLq0+cQ/u/739fSyLiwvu8JF33PKxXY6EM9oW8Mrl393dO8+Swgfw59euPXU//finI+GMMoCPNp43gflr+3u0YBJcubzZhPG5I2GMMoA/nH/84vvjJxbd/gPpIxG0hDdvsBUMZXQBRFf5cP2f7e8Pff729tfysUUnwa0bf7iNDbaCIYwugNeb8VpLO3FAC4ggpoJW8GoztiTDGVUAEb62hULg9ux5+fjoiiXGg5jYYGZNhjGaAGLicbPpIsFHTfC62gThW2p0igTXr206MozRBHDt2uYL5XK0CZ/f+rXA5037/6GgBaSWGcYoAuhrF7R+O4330Ap+cUJmQkItM4xRBNDXLkd7Viw+O/gWtUxBqg/gNO3SB7VMOaoP4DTt0gdaQIkJCbVMP1UXI8zSLn2gq77dtJ6pa8XQMheaIcCuxfLvOiZVe/e97ixTbQD7tEsfrZbxW9BYEEINMPw4880HImPaXFTbBQ/RLn1IaRlNLq4+cZapMoBDtUsfUpUymuCzWBNoxXNRZQBDtMss/DHkPIPZuFUnWV0AY7TLNCataB0eD0OR60ZbweoCGKNdpoExZE0OD1L84bq9IomqAuh3mUsJEwh/DFkTWB60RjUB7GqXwwki2R9D1gSKJKyVilUTQAntAvwxZI1Y0zJVBFBKuwCrg3UprGmZKgLY3WQUSy3apQ9LWmbuA9jVLiinisEfQ9aOJS1jYpEQA+NHG3HjLkntklp4ME9Ay+CF3btPNwLqAYQakGh5qF3CwWePYgVNVLtgdJ9S3d6Ci2+9atUufVjQMqotoN99Yuz26cE3XQzrzRgQQV46Eq5f0O0eFtoNNy/cu/PM3b0zafG1JyNqAeyWqz+4/8ydPI29ueGN8qHm6+dmmQmnXYV2Kah4kdiUPk/4L772GFClC54240zdxIN9HBZNvzWkliulUPnXd1roT9nEg6pffFkvwNREcrlSiuIBnDXjTN3Ec+r0e+5p83fcGonPC0VquVKS4j9BX0VGytkqeKvRrWCpiZvCX0VyuVKSogGEdmlnX7NIOVul7VZqX9MNRWq5UpqiARwaipSzVTCrxYoIJjTcFD5BarkyB8UCGDrBSDlbBa0gJiSXOCHZRmq5MgdFAhiz0E8tI4M17dKlyE8Tu7+CWiYNi9qlS/YApiz0U8ukYVG7dMn+E6W2QNQycVjVLl2yLwRKjMGgZfZHlg2h20ELiEnN/gNxxQ7ziD/mtqRdumQPIE5nSt3k02qZmLe41TII4Bhr/sC9xr1aUi8+C1sNLiMIzsXV9DPyEKSzKx9GVcuAlXO/zc2MGF3muZXdLhX/ma2ekpV9DIhWy8KRt1KnnpZAqsv0n9nqyf1FpkUWjrxttYx1JFcq/Ge2enJ/kQBauYkIWsb6kWvSKxX+M1u0AcXEEDyU9k1ErZaxSo6VCv+ZJ2LaVitYLICSv/zUahmLrWDOlQr/ma2d3F9UjVu4iajVMtbIuVLhP/NkU7qdCUnRAEr+8iWqZaxQYqXCf2b4UCtKqvjiILXM/ym1UmFRy6isTlPLvKRkgahFLaMSQGqZl5Qej1rTMmr1OdQyOgWi1rSMWgDHrmVStUtKmZslLaNaoThmLZN6jDBmshLPrK1lVAM4Vi0jdYxwyhjOipZRr9Eeo5aROkY4dQxnQcuY2CQwJi2Teoxw94BxqWfW0jImAjgmLZN6jHCX1DGctpYxs01qDFpmOWHiMWt3YcoYTlvLmAlg7VomdeKB8vpZSD1zaS1jaqOoFS2TY202Vbv0hUJKRZXWMqYCaEXLSM3MW0rd3jSPWsbcVvkatQwG+rGE3N40j1rG3lkNzo6WkahSSXmhYu51mzctYzKAVrQMxoKpExJp7dKHpJYpcXWZ2X2KuDNE4g1stUxMK9TOzGNPW82lXfrAn3u6+djtitzEjwAiyCWurTUbwKuCt3tLnC0Teo9cbu3SB168VGIvDgrBZBc8RDuEoKFlcmuXEhw/8a7LjbkAouvJccB4SS1Tw6XZ+PlLFMuaC2Cut7+klimlXXKBF6hUjaSpAOa+Tr6ElimtXXIgtSI1BFMBXMssP0tomdLaRZrSZ0mbCeBkopD/AMmc1TJa2kWSo4W3J5gJYMk7PXJUy2hrFwmgXUqfJW0igKW1A1rA2JPzd9Iy1C5xqAcwl3bpI6VypDvRoHaJRz2AWm+/pJahdolHNYDa2iFVy6A7pnZJQ3UteH1dpugRV0Hs3Rf3KLjCIEY7oOVGK0rtkoZqAGOvXHj171hwX379ftE3uB23Uruko9oFS+zF1TjgBy0XamPmXbvgs9O+wku9HAtT/++/+9XFgO6j9BvctlynTr+rrl1Snh9DFgxdtFEPID6Epf9q7kLR6D7Q+qVol0nFsszEA89v9RLCoZgQ0TGb0j9uglv6w29PpUrRLlL7bjWePwcmAojwhW5K/6qZeJQGLZcV7aLx/DkwdTTH0DGVhrVvx20WtIvWqkUOTD3FyQFdm8aBkhLaBRt8JLSL1XtOYjEVwCFaZl61y4Xzj50ES4qrFjkw9ySzKjI0tYuFaheN58+NuQC2WmYa1C51hQ+YbMunaRkN7YDqaWqXvJgM4DQto6EdsH+E2iUvZk9GQCt42xs7fXvmF6fB3oQja6ld+jH9VCcTuj4pYjcxUbsMY2GrwRkGv8gSpzR1weQBtYIAXfCZwLNl0GJLjP0QvhonHy3mA6jJhfNPmhZwEkJUvwydBEC7XFyN33/cgtn3uZXdrmbqHFgI4W9EH3q2DLVLGAzgDPyN6EM3MVG7hMEA9hByhQG1SzgMYA/+RvS+s2WoXcJhAAfgy+idtAy1SxwM4ED6rjBgtUscDOBA/PMBu2fLsNolHgYwAF/LtGfLULukwQAGME3LULukwZWQQBA8LLPhv+19GhKcbVY8xjT2a2ELGEhXy0gwJu3ShQGMAIFZFrpg+5NmcpPjeth5gV0wUYUtIFGFASSqMIBEFQaQqMIAElUYQKIKA0hUYQCJKgwgUYUBJKowgEQVBpCowgASVRhAogoDSFRhAIkqDCBRhQEkqjCARBUGkKjCABJVGECiCgNIVPkXGPWKHZj1nMYAAAAASUVORK5CYII=" + }, + "children": [] + } + ] + } + ] + }, + "name": "Tongyi" +} diff --git a/web/app/components/base/icons/src/public/llm/Tongyi.tsx b/web/app/components/base/icons/src/public/llm/Tongyi.tsx new file mode 100644 index 0000000000..9934dee856 --- /dev/null +++ b/web/app/components/base/icons/src/public/llm/Tongyi.tsx @@ -0,0 +1,20 @@ +// GENERATE BY script +// DON NOT EDIT IT MANUALLY + +import type { IconData } from '@/app/components/base/icons/IconBase' +import * as React from 'react' +import IconBase from '@/app/components/base/icons/IconBase' +import data from './Tongyi.json' + +const Icon = ( + { + ref, + ...props + }: React.SVGProps & { + ref?: React.RefObject> + }, +) => + +Icon.displayName = 'Tongyi' + +export default Icon diff --git a/web/app/components/base/icons/src/public/llm/index.ts b/web/app/components/base/icons/src/public/llm/index.ts index 3a4306391e..0c5cef4a36 100644 --- a/web/app/components/base/icons/src/public/llm/index.ts +++ b/web/app/components/base/icons/src/public/llm/index.ts @@ -1,6 +1,7 @@ export { default as Anthropic } from './Anthropic' export { default as AnthropicDark } from './AnthropicDark' export { default as AnthropicLight } from './AnthropicLight' +export { default as AnthropicShortLight } from './AnthropicShortLight' export { default as AnthropicText } from './AnthropicText' export { default as Azureai } from './Azureai' export { default as AzureaiText } from './AzureaiText' @@ -12,8 +13,11 @@ export { default as Chatglm } from './Chatglm' export { default as ChatglmText } from './ChatglmText' export { default as Cohere } from './Cohere' export { default as CohereText } from './CohereText' +export { default as Deepseek } from './Deepseek' +export { default as Gemini } from './Gemini' export { default as Gpt3 } from './Gpt3' export { default as Gpt4 } from './Gpt4' +export { default as Grok } from './Grok' export { default as Huggingface } from './Huggingface' export { default as HuggingfaceText } from './HuggingfaceText' export { default as HuggingfaceTextHub } from './HuggingfaceTextHub' @@ -26,14 +30,19 @@ export { default as Localai } from './Localai' export { default as LocalaiText } from './LocalaiText' export { default as Microsoft } from './Microsoft' export { default as OpenaiBlack } from './OpenaiBlack' +export { default as OpenaiBlue } from './OpenaiBlue' export { default as OpenaiGreen } from './OpenaiGreen' +export { default as OpenaiSmall } from './OpenaiSmall' +export { default as OpenaiTeal } from './OpenaiTeal' export { default as OpenaiText } from './OpenaiText' export { default as OpenaiTransparent } from './OpenaiTransparent' +export { default as OpenaiViolet } from './OpenaiViolet' export { default as OpenaiYellow } from './OpenaiYellow' export { default as Openllm } from './Openllm' export { default as OpenllmText } from './OpenllmText' export { default as Replicate } from './Replicate' export { default as ReplicateText } from './ReplicateText' +export { default as Tongyi } from './Tongyi' export { default as XorbitsInference } from './XorbitsInference' export { default as XorbitsInferenceText } from './XorbitsInferenceText' export { default as Zhipuai } from './Zhipuai' diff --git a/web/app/components/base/icons/src/public/tracing/DatabricksIcon.tsx b/web/app/components/base/icons/src/public/tracing/DatabricksIcon.tsx index 87abe453ec..a1e45d8bdf 100644 --- a/web/app/components/base/icons/src/public/tracing/DatabricksIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/DatabricksIcon.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject> + ref?: React.RefObject> }, ) => diff --git a/web/app/components/base/icons/src/public/tracing/DatabricksIconBig.tsx b/web/app/components/base/icons/src/public/tracing/DatabricksIconBig.tsx index bebaa1b40e..ef21c05a23 100644 --- a/web/app/components/base/icons/src/public/tracing/DatabricksIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/DatabricksIconBig.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject> + ref?: React.RefObject> }, ) => diff --git a/web/app/components/base/icons/src/public/tracing/MlflowIcon.tsx b/web/app/components/base/icons/src/public/tracing/MlflowIcon.tsx index 3c86ed61f4..09a31882c9 100644 --- a/web/app/components/base/icons/src/public/tracing/MlflowIcon.tsx +++ b/web/app/components/base/icons/src/public/tracing/MlflowIcon.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject> + ref?: React.RefObject> }, ) => diff --git a/web/app/components/base/icons/src/public/tracing/MlflowIconBig.tsx b/web/app/components/base/icons/src/public/tracing/MlflowIconBig.tsx index fbb288d46a..03fef44991 100644 --- a/web/app/components/base/icons/src/public/tracing/MlflowIconBig.tsx +++ b/web/app/components/base/icons/src/public/tracing/MlflowIconBig.tsx @@ -11,7 +11,7 @@ const Icon = ( ref, ...props }: React.SVGProps & { - ref?: React.RefObject> + ref?: React.RefObject> }, ) => diff --git a/web/app/components/base/icons/src/public/tracing/TencentIcon.json b/web/app/components/base/icons/src/public/tracing/TencentIcon.json index 642fa75a92..9fd54c0ce9 100644 --- a/web/app/components/base/icons/src/public/tracing/TencentIcon.json +++ b/web/app/components/base/icons/src/public/tracing/TencentIcon.json @@ -1,14 +1,16 @@ { "icon": { "type": "element", + "isRootNode": true, "name": "svg", "attributes": { "width": "80px", "height": "18px", "viewBox": "0 0 80 18", - "version": "1.1" + "version": "1.1", + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink" }, - "isRootNode": true, "children": [ { "type": "element", diff --git a/web/app/components/base/icons/src/public/tracing/TencentIconBig.json b/web/app/components/base/icons/src/public/tracing/TencentIconBig.json index d0582e7f8d..9abd81455f 100644 --- a/web/app/components/base/icons/src/public/tracing/TencentIconBig.json +++ b/web/app/components/base/icons/src/public/tracing/TencentIconBig.json @@ -1,14 +1,16 @@ { "icon": { "type": "element", + "isRootNode": true, "name": "svg", "attributes": { - "width": "80px", - "height": "18px", + "width": "120px", + "height": "27px", "viewBox": "0 0 80 18", - "version": "1.1" + "version": "1.1", + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink" }, - "isRootNode": true, "children": [ { "type": "element", diff --git a/web/app/components/billing/apps-full-in-dialog/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/index.spec.tsx index a11b582b0f..d006a3222d 100644 --- a/web/app/components/billing/apps-full-in-dialog/index.spec.tsx +++ b/web/app/components/billing/apps-full-in-dialog/index.spec.tsx @@ -75,6 +75,9 @@ const buildAppContext = (overrides: Partial = {}): AppContextVa created_at: 0, role: 'normal', providers: [], + trial_credits: 200, + trial_credits_used: 0, + next_credit_reset_date: 0, } const langGeniusVersionInfo: LangGeniusVersionResponse = { current_env: '', @@ -96,6 +99,7 @@ const buildAppContext = (overrides: Partial = {}): AppContextVa mutateCurrentWorkspace: vi.fn(), langGeniusVersionInfo, isLoadingCurrentWorkspace: false, + isValidatingCurrentWorkspace: false, } const useSelector: AppContextValue['useSelector'] = selector => selector({ ...base, useSelector }) return { diff --git a/web/app/components/header/account-setting/model-provider-page/index.tsx b/web/app/components/header/account-setting/model-provider-page/index.tsx index f456bcaaa6..57b464e0e7 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.tsx @@ -6,8 +6,10 @@ import { RiBrainLine, } from '@remixicon/react' import { useDebounce } from 'ahooks' -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { IS_CLOUD_EDITION } from '@/config' +import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { cn } from '@/utils/classnames' @@ -20,6 +22,7 @@ import { } from './hooks' import InstallFromMarketplace from './install-from-marketplace' import ProviderAddedCard from './provider-added-card' +import QuotaPanel from './provider-added-card/quota-panel' import SystemModelSelector from './system-model-selector' type Props = { @@ -31,6 +34,7 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an const ModelProviderPage = ({ searchText }: Props) => { const debouncedSearchText = useDebounce(searchText, { wait: 500 }) const { t } = useTranslation() + const { mutateCurrentWorkspace, isValidatingCurrentWorkspace } = useAppContext() const { data: textGenerationDefaultModel } = useDefaultModel(ModelTypeEnum.textGeneration) const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding) const { data: rerankDefaultModel } = useDefaultModel(ModelTypeEnum.rerank) @@ -39,6 +43,7 @@ const ModelProviderPage = ({ searchText }: Props) => { const { modelProviders: providers } = useProviderContext() const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const defaultModelNotConfigured = !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel + const [configuredProviders, notConfiguredProviders] = useMemo(() => { const configuredProviders: ModelProvider[] = [] const notConfiguredProviders: ModelProvider[] = [] @@ -83,6 +88,10 @@ const ModelProviderPage = ({ searchText }: Props) => { return [filteredConfiguredProviders, filteredNotConfiguredProviders] }, [configuredProviders, debouncedSearchText, notConfiguredProviders]) + useEffect(() => { + mutateCurrentWorkspace() + }, [mutateCurrentWorkspace]) + return (
@@ -109,6 +118,7 @@ const ModelProviderPage = ({ searchText }: Props) => { />
+ {IS_CLOUD_EDITION && } {!filteredConfiguredProviders?.length && (
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx index 59d7b2c0c8..cbaef21a70 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx @@ -7,6 +7,7 @@ import { useToastContext } from '@/app/components/base/toast' import { ConfigProvider } from '@/app/components/header/account-setting/model-provider-page/model-auth' import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks' import Indicator from '@/app/components/header/indicator' +import { IS_CLOUD_EDITION } from '@/config' import { useEventEmitterContextContext } from '@/context/event-emitter' import { changeModelProviderPriority } from '@/service/common' import { cn } from '@/utils/classnames' @@ -114,7 +115,7 @@ const CredentialPanel = ({ provider={provider} /> { - systemConfig.enabled && isCustomConfigured && ( + systemConfig.enabled && isCustomConfigured && IS_CLOUD_EDITION && ( = ({ const systemConfig = provider.system_configuration const hasModelList = fetched && !!modelList.length const { isCurrentWorkspaceManager } = useAppContext() - const showQuota = systemConfig.enabled && [...MODEL_PROVIDER_QUOTA_GET_PAID].includes(provider.provider) && !IS_CE_EDITION + const showModelProvider = systemConfig.enabled && [...MODEL_PROVIDER_QUOTA_GET_PAID].includes(provider.provider as ModelProviderQuotaGetPaid) && !IS_CE_EDITION const showCredential = configurationMethods.includes(ConfigurationMethodEnum.predefinedModel) && isCurrentWorkspaceManager const getModelList = async (providerName: string) => { @@ -104,13 +104,6 @@ const ProviderAddedCard: FC = ({ }
- { - showQuota && ( - - ) - } { showCredential && ( = ({ { collapsed && (
- {(showQuota || !notConfigured) && ( + {(showModelProvider || !notConfigured) && ( <>
{ @@ -150,7 +143,7 @@ const ProviderAddedCard: FC = ({
)} - {!showQuota && notConfigured && ( + {!showModelProvider && notConfigured && (
{t('modelProvider.configureTip', { ns: 'common' })} diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx index cd49148403..e296bc4555 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx @@ -1,66 +1,163 @@ import type { FC } from 'react' import type { ModelProvider } from '../declarations' +import type { Plugin } from '@/app/components/plugins/types' +import { useBoolean } from 'ahooks' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm' +import Loading from '@/app/components/base/loading' import Tooltip from '@/app/components/base/tooltip' +import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' +import { useAppContext } from '@/context/app-context' +import useTimestamp from '@/hooks/use-timestamp' +import { cn } from '@/utils/classnames' import { formatNumber } from '@/utils/format' -import { - CustomConfigurationStatusEnum, - PreferredProviderTypeEnum, - QuotaUnitEnum, -} from '../declarations' -import { - MODEL_PROVIDER_QUOTA_GET_PAID, -} from '../utils' -import PriorityUseTip from './priority-use-tip' +import { PreferredProviderTypeEnum } from '../declarations' +import { useMarketplaceAllPlugins } from '../hooks' +import { modelNameMap, ModelProviderQuotaGetPaid } from '../utils' + +const allProviders = [ + { key: ModelProviderQuotaGetPaid.OPENAI, Icon: OpenaiSmall }, + { key: ModelProviderQuotaGetPaid.ANTHROPIC, Icon: AnthropicShortLight }, + { key: ModelProviderQuotaGetPaid.GEMINI, Icon: Gemini }, + { key: ModelProviderQuotaGetPaid.X, Icon: Grok }, + { key: ModelProviderQuotaGetPaid.DEEPSEEK, Icon: Deepseek }, + { key: ModelProviderQuotaGetPaid.TONGYI, Icon: Tongyi }, +] as const + +// Map provider key to plugin ID +// provider key format: langgenius/provider/model, plugin ID format: langgenius/provider +const providerKeyToPluginId: Record = { + [ModelProviderQuotaGetPaid.OPENAI]: 'langgenius/openai', + [ModelProviderQuotaGetPaid.ANTHROPIC]: 'langgenius/anthropic', + [ModelProviderQuotaGetPaid.GEMINI]: 'langgenius/gemini', + [ModelProviderQuotaGetPaid.X]: 'langgenius/x', + [ModelProviderQuotaGetPaid.DEEPSEEK]: 'langgenius/deepseek', + [ModelProviderQuotaGetPaid.TONGYI]: 'langgenius/tongyi', +} type QuotaPanelProps = { - provider: ModelProvider + providers: ModelProvider[] + isLoading?: boolean } const QuotaPanel: FC = ({ - provider, + providers, + isLoading = false, }) => { const { t } = useTranslation() + const { currentWorkspace } = useAppContext() + const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0) + const providerMap = useMemo(() => new Map( + providers.map(p => [p.provider, p.preferred_provider_type]), + ), [providers]) + const { formatTime } = useTimestamp() + const { + plugins: allPlugins, + } = useMarketplaceAllPlugins(providers, '') + const [selectedPlugin, setSelectedPlugin] = useState(null) + const [isShowInstallModal, { + setTrue: showInstallFromMarketplace, + setFalse: hideInstallFromMarketplace, + }] = useBoolean(false) + const selectedPluginIdRef = useRef(null) - const customConfig = provider.custom_configuration - const priorityUseType = provider.preferred_provider_type - const systemConfig = provider.system_configuration - const currentQuota = systemConfig.enabled && systemConfig.quota_configurations.find(item => item.quota_type === systemConfig.current_quota_type) - const openaiOrAnthropic = MODEL_PROVIDER_QUOTA_GET_PAID.includes(provider.provider) + const handleIconClick = useCallback((key: string) => { + const providerType = providerMap.get(key) + if (!providerType && allPlugins) { + const pluginId = providerKeyToPluginId[key] + const plugin = allPlugins.find(p => p.plugin_id === pluginId) + if (plugin) { + setSelectedPlugin(plugin) + selectedPluginIdRef.current = pluginId + showInstallFromMarketplace() + } + } + }, [allPlugins, providerMap, showInstallFromMarketplace]) + + useEffect(() => { + if (isShowInstallModal && selectedPluginIdRef.current) { + const isInstalled = providers.some(p => p.provider.startsWith(selectedPluginIdRef.current!)) + if (isInstalled) { + hideInstallFromMarketplace() + selectedPluginIdRef.current = null + } + } + }, [providers, isShowInstallModal, hideInstallFromMarketplace]) + + if (isLoading) { + return ( +
+ +
+ ) + } return ( -
+
{t('modelProvider.quota', { ns: 'common' })} - +
- { - currentQuota && ( -
- {formatNumber(Math.max((currentQuota?.quota_limit || 0) - (currentQuota?.quota_used || 0), 0))} - { - currentQuota?.quota_unit === QuotaUnitEnum.tokens && 'Tokens' +
+
+ {formatNumber(credits)} + {t('modelProvider.credits', { ns: 'common' })} + {currentWorkspace.next_credit_reset_date + ? ( + <> + · + + {t('modelProvider.resetDate', { + ns: 'common', + date: formatTime(currentWorkspace.next_credit_reset_date, t('dateFormat', { ns: 'appLog' })), + interpolation: { escapeValue: false }, + })} + + + ) + : null} +
+
+ {allProviders.map(({ key, Icon }) => { + const providerType = providerMap.get(key) + const usingQuota = providerType === PreferredProviderTypeEnum.system + const getTooltipKey = () => { + if (usingQuota) + return 'modelProvider.card.modelSupported' + if (providerType === PreferredProviderTypeEnum.custom) + return 'modelProvider.card.modelAPI' + return 'modelProvider.card.modelNotSupported' } - { - currentQuota?.quota_unit === QuotaUnitEnum.times && t('modelProvider.callTimes', { ns: 'common' }) - } - { - currentQuota?.quota_unit === QuotaUnitEnum.credits && t('modelProvider.credits', { ns: 'common' }) - } -
- ) - } - { - priorityUseType === PreferredProviderTypeEnum.system && customConfig.status === CustomConfigurationStatusEnum.active && ( - - ) - } + return ( + +
handleIconClick(key)} + > + + {!usingQuota && ( +
+ )} +
+ + ) + })} +
+
+ {isShowInstallModal && selectedPlugin && ( + + )}
) } -export default QuotaPanel +export default React.memo(QuotaPanel) diff --git a/web/app/components/header/account-setting/model-provider-page/utils.ts b/web/app/components/header/account-setting/model-provider-page/utils.ts index b60d6a0c7b..d958f3eef3 100644 --- a/web/app/components/header/account-setting/model-provider-page/utils.ts +++ b/web/app/components/header/account-setting/model-provider-page/utils.ts @@ -17,7 +17,25 @@ import { ModelTypeEnum, } from './declarations' -export const MODEL_PROVIDER_QUOTA_GET_PAID = ['langgenius/anthropic/anthropic', 'langgenius/openai/openai', 'langgenius/azure_openai/azure_openai'] +export enum ModelProviderQuotaGetPaid { + ANTHROPIC = 'langgenius/anthropic/anthropic', + OPENAI = 'langgenius/openai/openai', + // AZURE_OPENAI = 'langgenius/azure_openai/azure_openai', + GEMINI = 'langgenius/gemini/google', + X = 'langgenius/x/x', + DEEPSEEK = 'langgenius/deepseek/deepseek', + TONGYI = 'langgenius/tongyi/tongyi', +} +export const MODEL_PROVIDER_QUOTA_GET_PAID = [ModelProviderQuotaGetPaid.ANTHROPIC, ModelProviderQuotaGetPaid.OPENAI, ModelProviderQuotaGetPaid.GEMINI, ModelProviderQuotaGetPaid.X, ModelProviderQuotaGetPaid.DEEPSEEK, ModelProviderQuotaGetPaid.TONGYI] + +export const modelNameMap = { + [ModelProviderQuotaGetPaid.OPENAI]: 'OpenAI', + [ModelProviderQuotaGetPaid.ANTHROPIC]: 'Anthropic', + [ModelProviderQuotaGetPaid.GEMINI]: 'Gemini', + [ModelProviderQuotaGetPaid.X]: 'xAI', + [ModelProviderQuotaGetPaid.DEEPSEEK]: 'DeepSeek', + [ModelProviderQuotaGetPaid.TONGYI]: 'TONGYI', +} export const isNullOrUndefined = (value: any) => { return value === undefined || value === null diff --git a/web/app/components/plugins/provider-card.tsx b/web/app/components/plugins/provider-card.tsx index a3bba8d774..d76e222c4a 100644 --- a/web/app/components/plugins/provider-card.tsx +++ b/web/app/components/plugins/provider-card.tsx @@ -92,7 +92,7 @@ const ProviderCardComponent: FC = ({ manifest={payload} uniqueIdentifier={payload.latest_package_identifier} onClose={hideInstallFromMarketplace} - onSuccess={() => hideInstallFromMarketplace()} + onSuccess={hideInstallFromMarketplace} /> ) } diff --git a/web/context/app-context.tsx b/web/context/app-context.tsx index 335f96fcce..12000044d6 100644 --- a/web/context/app-context.tsx +++ b/web/context/app-context.tsx @@ -29,6 +29,7 @@ export type AppContextValue = { langGeniusVersionInfo: LangGeniusVersionResponse useSelector: typeof useSelector isLoadingCurrentWorkspace: boolean + isValidatingCurrentWorkspace: boolean } const userProfilePlaceholder = { @@ -58,6 +59,9 @@ const initialWorkspaceInfo: ICurrentWorkspace = { created_at: 0, role: 'normal', providers: [], + trial_credits: 200, + trial_credits_used: 0, + next_credit_reset_date: 0, } const AppContext = createContext({ @@ -72,6 +76,7 @@ const AppContext = createContext({ langGeniusVersionInfo: initialLangGeniusVersionInfo, useSelector, isLoadingCurrentWorkspace: false, + isValidatingCurrentWorkspace: false, }) export function useSelector(selector: (value: AppContextValue) => T): T { @@ -86,7 +91,7 @@ export const AppContextProvider: FC = ({ children }) => const queryClient = useQueryClient() const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) const { data: userProfileResp } = useUserProfile() - const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace } = useCurrentWorkspace() + const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace, isFetching: isValidatingCurrentWorkspace } = useCurrentWorkspace() const langGeniusVersionQuery = useLangGeniusVersion( userProfileResp?.meta.currentVersion, !systemFeatures.branding.enabled, @@ -195,6 +200,7 @@ export const AppContextProvider: FC = ({ children }) => isCurrentWorkspaceDatasetOperator, mutateCurrentWorkspace, isLoadingCurrentWorkspace, + isValidatingCurrentWorkspace, }} >
diff --git a/web/i18n/en-US/billing.json b/web/i18n/en-US/billing.json index 1f10a49966..3242aa8e78 100644 --- a/web/i18n/en-US/billing.json +++ b/web/i18n/en-US/billing.json @@ -96,7 +96,7 @@ "plansCommon.memberAfter": "Member", "plansCommon.messageRequest.title": "{{count,number}} message credits", "plansCommon.messageRequest.titlePerMonth": "{{count,number}} message credits/month", - "plansCommon.messageRequest.tooltip": "Message credits are provided to help you easily try out different OpenAI models in Dify. Credits are consumed based on the model type. Once they’re used up, you can switch to your own OpenAI API key.", + "plansCommon.messageRequest.tooltip": "Message credits are provided to help you easily try out different models from OpenAI, Anthropic, Gemini, xAI, DeepSeek and Tongyi in Dify. Credits are consumed based on the model type. Once they're used up, you can switch to your own API key.", "plansCommon.modelProviders": "Support OpenAI/Anthropic/Llama2/Azure OpenAI/Hugging Face/Replicate", "plansCommon.month": "month", "plansCommon.mostPopular": "Popular", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index f971ff1668..64ac47d804 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -339,13 +339,16 @@ "modelProvider.callTimes": "Call times", "modelProvider.card.buyQuota": "Buy Quota", "modelProvider.card.callTimes": "Call times", + "modelProvider.card.modelAPI": "{{modelName}} models are using the API Key.", + "modelProvider.card.modelNotSupported": "{{modelName}} models are not installed.", + "modelProvider.card.modelSupported": "{{modelName}} models are using this quota.", "modelProvider.card.onTrial": "On Trial", "modelProvider.card.paid": "Paid", "modelProvider.card.priorityUse": "Priority use", "modelProvider.card.quota": "QUOTA", "modelProvider.card.quotaExhausted": "Quota exhausted", "modelProvider.card.removeKey": "Remove API Key", - "modelProvider.card.tip": "Priority will be given to the paid quota. The Trial quota will be used after the paid quota is exhausted.", + "modelProvider.card.tip": "Message Credits supports models from OpenAI, Anthropic, Gemini, xAI, DeepSeek and Tongyi. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.", "modelProvider.card.tokens": "Tokens", "modelProvider.collapse": "Collapse", "modelProvider.config": "Config", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Remaining available free tokens", "modelProvider.rerankModel.key": "Rerank Model", "modelProvider.rerankModel.tip": "Rerank model will reorder the candidate document list based on the semantic match with user query, improving the results of semantic ranking", + "modelProvider.resetDate": "Reset on {{date}}", "modelProvider.searchModel": "Search model", "modelProvider.selectModel": "Select your model", "modelProvider.selector.emptySetting": "Please go to settings to configure", diff --git a/web/i18n/ja-JP/billing.json b/web/i18n/ja-JP/billing.json index 344e934948..b23ae6c959 100644 --- a/web/i18n/ja-JP/billing.json +++ b/web/i18n/ja-JP/billing.json @@ -96,7 +96,7 @@ "plansCommon.memberAfter": "メンバー", "plansCommon.messageRequest.title": "{{count,number}}メッセージクレジット", "plansCommon.messageRequest.titlePerMonth": "{{count,number}}メッセージクレジット/月", - "plansCommon.messageRequest.tooltip": "メッセージクレジットは、Dify でさまざまな OpenAI モデルを簡単にお試しいただくためのものです。モデルタイプに応じてクレジットが消費され、使い切った後はご自身の OpenAI API キーに切り替えていただけます。", + "plansCommon.messageRequest.tooltip": "メッセージクレジットは、DifyでOpenAI、Anthropic、Gemini、xAI、DeepSeek、Tongyiなどのさまざまなモデルを簡単に試すために提供されています。クレジットはモデルの種類に基づいて消費されます。使い切ったら、独自のAPIキーに切り替えることができます。", "plansCommon.modelProviders": "OpenAI/Anthropic/Llama2/Azure OpenAI/Hugging Face/Replicateをサポート", "plansCommon.month": "月", "plansCommon.mostPopular": "人気", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index e7481830d8..11f543e7e5 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -339,13 +339,16 @@ "modelProvider.callTimes": "呼び出し回数", "modelProvider.card.buyQuota": "クォータを購入", "modelProvider.card.callTimes": "通話回数", + "modelProvider.card.modelAPI": "{{modelName}} は現在 APIキーを使用しています。", + "modelProvider.card.modelNotSupported": "{{modelName}} 未インストール。", + "modelProvider.card.modelSupported": "このクォータは現在{{modelName}}に使用されています。", "modelProvider.card.onTrial": "トライアル中", "modelProvider.card.paid": "有料", "modelProvider.card.priorityUse": "優先利用", "modelProvider.card.quota": "クォータ", "modelProvider.card.quotaExhausted": "クォータが使い果たされました", "modelProvider.card.removeKey": "API キーを削除", - "modelProvider.card.tip": "有料クォータは優先して使用されます。有料クォータを使用し終えた後、トライアルクォータが利用されます。", + "modelProvider.card.tip": "メッセージ枠はOpenAI、Anthropic、Gemini、xAI、DeepSeek、Tongyiのモデルを使用することをサポートしています。無料枠は有料枠が使い果たされた後に消費されます。", "modelProvider.card.tokens": "トークン", "modelProvider.collapse": "折り畳み", "modelProvider.config": "設定", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "残りの無料トークン", "modelProvider.rerankModel.key": "Rerank モデル", "modelProvider.rerankModel.tip": "Rerank モデルは、ユーザークエリとの意味的一致に基づいて候補文書リストを再配置し、意味的ランキングの結果を向上させます。", + "modelProvider.resetDate": "{{date}} にリセット", "modelProvider.searchModel": "検索モデル", "modelProvider.selectModel": "モデルを選択", "modelProvider.selector.emptySetting": "設定に移動して構成してください", diff --git a/web/i18n/zh-Hans/billing.json b/web/i18n/zh-Hans/billing.json index e42edf0dc6..9111c1a6d1 100644 --- a/web/i18n/zh-Hans/billing.json +++ b/web/i18n/zh-Hans/billing.json @@ -96,7 +96,7 @@ "plansCommon.memberAfter": "个成员", "plansCommon.messageRequest.title": "{{count,number}} 条消息额度", "plansCommon.messageRequest.titlePerMonth": "{{count,number}} 条消息额度/月", - "plansCommon.messageRequest.tooltip": "消息额度旨在帮助您便捷地试用 Dify 中的各类 OpenAI 模型。不同模型会消耗不同额度。额度用尽后,您可以切换为使用自己的 OpenAI API 密钥。", + "plansCommon.messageRequest.tooltip": "消息额度旨在帮助您便捷地试用 Dify 中来自 OpenAI、Anthropic、Gemini、xAI、深度求索、通义 的不同模型。不同模型会消耗不同额度。额度用尽后,您可以切换为使用自己的 API 密钥。", "plansCommon.modelProviders": "支持 OpenAI/Anthropic/Llama2/Azure OpenAI/Hugging Face/Replicate", "plansCommon.month": "月", "plansCommon.mostPopular": "最受欢迎", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index ca4ecce821..be7d4690af 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -339,13 +339,16 @@ "modelProvider.callTimes": "调用次数", "modelProvider.card.buyQuota": "购买额度", "modelProvider.card.callTimes": "调用次数", + "modelProvider.card.modelAPI": "{{modelName}} 模型正在使用 API Key。", + "modelProvider.card.modelNotSupported": "{{modelName}} 模型未安装。", + "modelProvider.card.modelSupported": "{{modelName}} 模型正在使用此额度。", "modelProvider.card.onTrial": "试用中", "modelProvider.card.paid": "已购买", "modelProvider.card.priorityUse": "优先使用", "modelProvider.card.quota": "额度", "modelProvider.card.quotaExhausted": "配额已用完", "modelProvider.card.removeKey": "删除 API 密钥", - "modelProvider.card.tip": "已付费额度将优先考虑。试用额度将在付费额度用完后使用。", + "modelProvider.card.tip": "消息额度支持使用 OpenAI、Anthropic、Gemini、xAI、深度求索、通义 的模型;免费额度会在付费额度用尽后才会消耗。", "modelProvider.card.tokens": "Tokens", "modelProvider.collapse": "收起", "modelProvider.config": "配置", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "剩余免费额度", "modelProvider.rerankModel.key": "Rerank 模型", "modelProvider.rerankModel.tip": "重排序模型将根据候选文档列表与用户问题语义匹配度进行重新排序,从而改进语义排序的结果", + "modelProvider.resetDate": "于 {{date}} 重置", "modelProvider.searchModel": "搜索模型", "modelProvider.selectModel": "选择您的模型", "modelProvider.selector.emptySetting": "请前往设置进行配置", diff --git a/web/models/common.ts b/web/models/common.ts index 0e034ffa33..62a543672b 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -142,6 +142,9 @@ export type IWorkspace = { export type ICurrentWorkspace = Omit & { role: 'owner' | 'admin' | 'editor' | 'dataset_operator' | 'normal' providers: Provider[] + trial_credits: number + trial_credits_used: number + next_credit_reset_date: number trial_end_reason?: string custom_config?: { remove_webapp_brand?: boolean From d4baf078f732c7e8f566ce18f37fd631a199db1b Mon Sep 17 00:00:00 2001 From: zhsama Date: Sun, 4 Jan 2026 16:07:04 +0800 Subject: [PATCH 05/33] fix(plugins): enhance search to match name, label and description (#30501) --- .../plugins/plugin-page/plugins-panel.tsx | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/web/app/components/plugins/plugin-page/plugins-panel.tsx b/web/app/components/plugins/plugin-page/plugins-panel.tsx index a065e735a8..ff765d39ab 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel.tsx @@ -1,10 +1,13 @@ 'use client' +import type { PluginDetail } from '../types' import type { FilterState } from './filter-management' import { useDebounceFn } from 'ahooks' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel' +import { useGetLanguage } from '@/context/i18n' +import { renderI18nObject } from '@/i18n-config' import { useInstalledLatestVersion, useInstalledPluginList, useInvalidateInstalledPluginList } from '@/service/use-plugins' import Loading from '../../base/loading' import { PluginSource } from '../types' @@ -13,8 +16,34 @@ import Empty from './empty' import FilterManagement from './filter-management' import List from './list' +const matchesSearchQuery = (plugin: PluginDetail & { latest_version: string }, query: string, locale: string): boolean => { + if (!query) + return true + const lowerQuery = query.toLowerCase() + const { declaration } = plugin + // Match plugin_id + if (plugin.plugin_id.toLowerCase().includes(lowerQuery)) + return true + // Match plugin name + if (plugin.name?.toLowerCase().includes(lowerQuery)) + return true + // Match declaration name + if (declaration.name?.toLowerCase().includes(lowerQuery)) + return true + // Match localized label + const label = renderI18nObject(declaration.label, locale) + if (label?.toLowerCase().includes(lowerQuery)) + return true + // Match localized description + const description = renderI18nObject(declaration.description, locale) + if (description?.toLowerCase().includes(lowerQuery)) + return true + return false +} + const PluginsPanel = () => { const { t } = useTranslation() + const locale = useGetLanguage() const filters = usePluginPageContext(v => v.filters) as FilterState const setFilters = usePluginPageContext(v => v.setFilters) const { data: pluginList, isLoading: isPluginListLoading, isFetching, isLastPage, loadNextPage } = useInstalledPluginList() @@ -48,11 +77,11 @@ const PluginsPanel = () => { return ( (categories.length === 0 || categories.includes(plugin.declaration.category)) && (tags.length === 0 || tags.some(tag => plugin.declaration.tags.includes(tag))) - && (searchQuery === '' || plugin.plugin_id.toLowerCase().includes(searchQuery.toLowerCase())) + && matchesSearchQuery(plugin, searchQuery, locale) ) }) return filteredList - }, [pluginListWithLatestVersion, filters]) + }, [pluginListWithLatestVersion, filters, locale]) const currentPluginDetail = useMemo(() => { const detail = pluginListWithLatestVersion.find(plugin => plugin.plugin_id === currentPluginID) From 9aaa08e19f8709b2b790b2a9c09fc2d9b1f29797 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Sun, 4 Jan 2026 16:34:23 +0800 Subject: [PATCH 06/33] ci: fix translate, allow manual dispatch (#30505) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../translate-i18n-base-on-english.yml | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/.github/workflows/translate-i18n-base-on-english.yml b/.github/workflows/translate-i18n-base-on-english.yml index a51350f630..16d36361fd 100644 --- a/.github/workflows/translate-i18n-base-on-english.yml +++ b/.github/workflows/translate-i18n-base-on-english.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'web/i18n/en-US/*.json' + workflow_dispatch: permissions: contents: write @@ -18,7 +19,8 @@ jobs: run: working-directory: web steps: - - uses: actions/checkout@v6 + # Keep use old checkout action version for https://github.com/peter-evans/create-pull-request/issues/4272 + - uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -26,21 +28,28 @@ jobs: - name: Check for file changes in i18n/en-US id: check_files run: | - git fetch origin "${{ github.event.before }}" || true - git fetch origin "${{ github.sha }}" || true - changed_files=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'i18n/en-US/*.json') - echo "Changed files: $changed_files" - if [ -n "$changed_files" ]; then + # Skip check for manual trigger, translate all files + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then echo "FILES_CHANGED=true" >> $GITHUB_ENV - file_args="" - for file in $changed_files; do - filename=$(basename "$file" .json) - file_args="$file_args --file $filename" - done - echo "FILE_ARGS=$file_args" >> $GITHUB_ENV - echo "File arguments: $file_args" + echo "FILE_ARGS=" >> $GITHUB_ENV + echo "Manual trigger: translating all files" else - echo "FILES_CHANGED=false" >> $GITHUB_ENV + git fetch origin "${{ github.event.before }}" || true + git fetch origin "${{ github.sha }}" || true + changed_files=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'i18n/en-US/*.json') + echo "Changed files: $changed_files" + if [ -n "$changed_files" ]; then + echo "FILES_CHANGED=true" >> $GITHUB_ENV + file_args="" + for file in $changed_files; do + filename=$(basename "$file" .json) + file_args="$file_args --file $filename" + done + echo "FILE_ARGS=$file_args" >> $GITHUB_ENV + echo "File arguments: $file_args" + else + echo "FILES_CHANGED=false" >> $GITHUB_ENV + fi fi - name: Install pnpm From 151101aaf5aba9a8007a90c2bfbd95d14e3dec7d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:11:40 +0800 Subject: [PATCH 07/33] chore(i18n): translate i18n files based on en-US changes (#30508) Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com> --- web/i18n/ar-TN/common.json | 4 ++++ web/i18n/de-DE/common.json | 4 ++++ web/i18n/es-ES/common.json | 4 ++++ web/i18n/fa-IR/common.json | 4 ++++ web/i18n/fr-FR/common.json | 4 ++++ web/i18n/hi-IN/common.json | 4 ++++ web/i18n/id-ID/common.json | 4 ++++ web/i18n/it-IT/common.json | 4 ++++ web/i18n/ko-KR/common.json | 4 ++++ web/i18n/pl-PL/common.json | 4 ++++ web/i18n/pt-BR/common.json | 4 ++++ web/i18n/ro-RO/common.json | 4 ++++ web/i18n/ru-RU/common.json | 4 ++++ web/i18n/sl-SI/common.json | 4 ++++ web/i18n/th-TH/common.json | 4 ++++ web/i18n/tr-TR/common.json | 4 ++++ web/i18n/uk-UA/common.json | 4 ++++ web/i18n/vi-VN/common.json | 4 ++++ web/i18n/zh-Hant/common.json | 4 ++++ 19 files changed, 76 insertions(+) diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index d015f1ae0b..beda6bb4c7 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "أوقات الاتصال", "modelProvider.card.buyQuota": "شراء حصة", "modelProvider.card.callTimes": "أوقات الاتصال", + "modelProvider.card.modelAPI": "النماذج {{modelName}} تستخدم مفتاح واجهة برمجة التطبيقات.", + "modelProvider.card.modelNotSupported": "النماذج {{modelName}} غير مثبتة.", + "modelProvider.card.modelSupported": "النماذج {{modelName}} تستخدم هذا الحصة.", "modelProvider.card.onTrial": "في التجربة", "modelProvider.card.paid": "مدفوع", "modelProvider.card.priorityUse": "أولوية الاستخدام", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "الرموز المجانية المتاحة المتبقية", "modelProvider.rerankModel.key": "نموذج إعادة الترتيب", "modelProvider.rerankModel.tip": "سيعيد نموذج إعادة الترتيب ترتيب قائمة المستندات المرشحة بناءً على المطابقة الدلالية مع استعلام المستخدم، مما يحسن نتائج الترتيب الدلالي", + "modelProvider.resetDate": "إعادة الضبط على {{date}}", "modelProvider.searchModel": "نموذج البحث", "modelProvider.selectModel": "اختر نموذجك", "modelProvider.selector.emptySetting": "يرجى الانتقال إلى الإعدادات للتكوين", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index f54f6a939f..1792c9b7ca 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Anrufzeiten", "modelProvider.card.buyQuota": "Kontingent kaufen", "modelProvider.card.callTimes": "Anrufzeiten", + "modelProvider.card.modelAPI": "{{modelName}}-Modelle verwenden den API-Schlüssel.", + "modelProvider.card.modelNotSupported": "{{modelName}}-Modelle sind nicht installiert.", + "modelProvider.card.modelSupported": "{{modelName}}-Modelle verwenden dieses Kontingent.", "modelProvider.card.onTrial": "In Probe", "modelProvider.card.paid": "Bezahlt", "modelProvider.card.priorityUse": "Priorisierte Nutzung", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Verbleibende verfügbare kostenlose Token", "modelProvider.rerankModel.key": "Rerank-Modell", "modelProvider.rerankModel.tip": "Rerank-Modell wird die Kandidatendokumentenliste basierend auf der semantischen Übereinstimmung mit der Benutzeranfrage neu ordnen und die Ergebnisse der semantischen Rangordnung verbessern", + "modelProvider.resetDate": "Zurücksetzen bei {{date}}", "modelProvider.searchModel": "Suchmodell", "modelProvider.selectModel": "Wählen Sie Ihr Modell", "modelProvider.selector.emptySetting": "Bitte gehen Sie zu den Einstellungen, um zu konfigurieren", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index ec08f11ed7..d99c36d9dd 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Tiempos de llamada", "modelProvider.card.buyQuota": "Comprar Cuota", "modelProvider.card.callTimes": "Tiempos de llamada", + "modelProvider.card.modelAPI": "Los modelos {{modelName}} están usando la clave de API.", + "modelProvider.card.modelNotSupported": "Los modelos {{modelName}} no están instalados.", + "modelProvider.card.modelSupported": "Los modelos {{modelName}} están utilizando esta cuota.", "modelProvider.card.onTrial": "En prueba", "modelProvider.card.paid": "Pagado", "modelProvider.card.priorityUse": "Uso prioritario", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Tokens gratuitos restantes disponibles", "modelProvider.rerankModel.key": "Modelo de Reordenar", "modelProvider.rerankModel.tip": "El modelo de reordenar reordenará la lista de documentos candidatos basada en la coincidencia semántica con la consulta del usuario, mejorando los resultados de clasificación semántica", + "modelProvider.resetDate": "Reiniciar en {{date}}", "modelProvider.searchModel": "Modelo de búsqueda", "modelProvider.selectModel": "Selecciona tu modelo", "modelProvider.selector.emptySetting": "Por favor ve a configuraciones para configurar", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index 78f9b9e388..588b37ee43 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "تعداد فراخوانی", "modelProvider.card.buyQuota": "خرید سهمیه", "modelProvider.card.callTimes": "تعداد فراخوانی", + "modelProvider.card.modelAPI": "مدل‌های {{modelName}} در حال استفاده از کلید API هستند.", + "modelProvider.card.modelNotSupported": "مدل‌های {{modelName}} نصب نشده‌اند.", + "modelProvider.card.modelSupported": "مدل‌های {{modelName}} از این سهمیه استفاده می‌کنند.", "modelProvider.card.onTrial": "در حال آزمایش", "modelProvider.card.paid": "پرداخت شده", "modelProvider.card.priorityUse": "استفاده با اولویت", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "توکن‌های رایگان باقی‌مانده در دسترس", "modelProvider.rerankModel.key": "مدل رتبه‌بندی مجدد", "modelProvider.rerankModel.tip": "مدل رتبه‌بندی مجدد، لیست اسناد کاندید را بر اساس تطابق معنایی با پرسش کاربر مرتب می‌کند و نتایج رتبه‌بندی معنایی را بهبود می‌بخشد", + "modelProvider.resetDate": "بازنشانی در {{date}}", "modelProvider.searchModel": "جستجوی مدل", "modelProvider.selectModel": "مدل خود را انتخاب کنید", "modelProvider.selector.emptySetting": "لطفاً به تنظیمات بروید تا پیکربندی کنید", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 7cc1af2d80..7df7d86272 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Temps d'appel", "modelProvider.card.buyQuota": "Acheter Quota", "modelProvider.card.callTimes": "Temps d'appel", + "modelProvider.card.modelAPI": "Les modèles {{modelName}} utilisent la clé API.", + "modelProvider.card.modelNotSupported": "Les modèles {{modelName}} ne sont pas installés.", + "modelProvider.card.modelSupported": "Les modèles {{modelName}} utilisent ce quota.", "modelProvider.card.onTrial": "En Essai", "modelProvider.card.paid": "Payé", "modelProvider.card.priorityUse": "Utilisation prioritaire", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Tokens gratuits restants disponibles", "modelProvider.rerankModel.key": "Modèle de Réorganisation", "modelProvider.rerankModel.tip": "Le modèle de réorganisation réorganisera la liste des documents candidats en fonction de la correspondance sémantique avec la requête de l'utilisateur, améliorant ainsi les résultats du classement sémantique.", + "modelProvider.resetDate": "Réinitialiser sur {{date}}", "modelProvider.searchModel": "Modèle de recherche", "modelProvider.selectModel": "Sélectionnez votre modèle", "modelProvider.selector.emptySetting": "Veuillez aller dans les paramètres pour configurer", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index 4670d5a545..996880d51c 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "कॉल समय", "modelProvider.card.buyQuota": "कोटा खरीदें", "modelProvider.card.callTimes": "कॉल समय", + "modelProvider.card.modelAPI": "{{modelName}} मॉडल एपीआई कुंजी का उपयोग कर रहे हैं।", + "modelProvider.card.modelNotSupported": "{{modelName}} मॉडल इंस्टॉल नहीं हैं।", + "modelProvider.card.modelSupported": "{{modelName}} मॉडल इस कोटा का उपयोग कर रहे हैं।", "modelProvider.card.onTrial": "परीक्षण पर", "modelProvider.card.paid": "भुगतान किया हुआ", "modelProvider.card.priorityUse": "प्राथमिकता उपयोग", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "बचे हुए उपलब्ध मुफ्त टोकन", "modelProvider.rerankModel.key": "रीरैंक मॉडल", "modelProvider.rerankModel.tip": "रीरैंक मॉडल उपयोगकर्ता प्रश्न के साथ सांविधिक मेल के आधार पर उम्मीदवार दस्तावेज़ सूची को पुनः क्रमित करेगा, सांविधिक रैंकिंग के परिणामों में सुधार करेगा।", + "modelProvider.resetDate": "{{date}} पर रीसेट करें", "modelProvider.searchModel": "खोज मॉडल", "modelProvider.selectModel": "अपने मॉडल का चयन करें", "modelProvider.selector.emptySetting": "कॉन्फ़िगर करने के लिए कृपया सेटिंग्स पर जाएं", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index ede4d3ae44..710136c7e2 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Waktu panggilan", "modelProvider.card.buyQuota": "Beli Kuota", "modelProvider.card.callTimes": "Waktu panggilan", + "modelProvider.card.modelAPI": "Model {{modelName}} sedang menggunakan API Key.", + "modelProvider.card.modelNotSupported": "Model {{modelName}} tidak terpasang.", + "modelProvider.card.modelSupported": "Model {{modelName}} sedang menggunakan kuota ini.", "modelProvider.card.onTrial": "Sedang Diadili", "modelProvider.card.paid": "Dibayar", "modelProvider.card.priorityUse": "Penggunaan prioritas", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Token gratis yang masih tersedia", "modelProvider.rerankModel.key": "Peringkat ulang Model", "modelProvider.rerankModel.tip": "Model rerank akan menyusun ulang daftar dokumen kandidat berdasarkan kecocokan semantik dengan kueri pengguna, meningkatkan hasil peringkat semantik", + "modelProvider.resetDate": "Atur ulang pada {{date}}", "modelProvider.searchModel": "Model pencarian", "modelProvider.selectModel": "Pilih model Anda", "modelProvider.selector.emptySetting": "Silakan buka pengaturan untuk mengonfigurasi", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index 737ef923b1..64bf2e3d1d 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Numero di chiamate", "modelProvider.card.buyQuota": "Acquista Quota", "modelProvider.card.callTimes": "Numero di chiamate", + "modelProvider.card.modelAPI": "I modelli {{modelName}} stanno utilizzando la chiave API.", + "modelProvider.card.modelNotSupported": "I modelli {{modelName}} non sono installati.", + "modelProvider.card.modelSupported": "I modelli {{modelName}} stanno utilizzando questa quota.", "modelProvider.card.onTrial": "In Prova", "modelProvider.card.paid": "Pagato", "modelProvider.card.priorityUse": "Uso prioritario", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Token gratuiti rimanenti disponibili", "modelProvider.rerankModel.key": "Modello di Rerank", "modelProvider.rerankModel.tip": "Il modello di rerank riordinerà la lista dei documenti candidati basandosi sulla corrispondenza semantica con la query dell'utente, migliorando i risultati del ranking semantico", + "modelProvider.resetDate": "Reimposta su {{date}}", "modelProvider.searchModel": "Modello di ricerca", "modelProvider.selectModel": "Seleziona il tuo modello", "modelProvider.selector.emptySetting": "Per favore vai alle impostazioni per configurare", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index 5640cb353d..fa3736e561 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "호출 횟수", "modelProvider.card.buyQuota": "Buy Quota", "modelProvider.card.callTimes": "호출 횟수", + "modelProvider.card.modelAPI": "{{modelName}} 모델이 API 키를 사용하고 있습니다.", + "modelProvider.card.modelNotSupported": "{{modelName}} 모델이 설치되지 않았습니다.", + "modelProvider.card.modelSupported": "{{modelName}} 모델이 이 할당량을 사용하고 있습니다.", "modelProvider.card.onTrial": "트라이얼 중", "modelProvider.card.paid": "유료", "modelProvider.card.priorityUse": "우선 사용", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "남은 무료 토큰 사용 가능", "modelProvider.rerankModel.key": "재랭크 모델", "modelProvider.rerankModel.tip": "재랭크 모델은 사용자 쿼리와의 의미적 일치를 기반으로 후보 문서 목록을 재배열하여 의미적 순위를 향상시킵니다.", + "modelProvider.resetDate": "{{date}}에서 재설정", "modelProvider.searchModel": "검색 모델", "modelProvider.selectModel": "모델 선택", "modelProvider.selector.emptySetting": "설정으로 이동하여 구성하세요", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index ae654e04ac..1a83dc517e 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Czasy wywołań", "modelProvider.card.buyQuota": "Kup limit", "modelProvider.card.callTimes": "Czasy wywołań", + "modelProvider.card.modelAPI": "Modele {{modelName}} używają klucza API.", + "modelProvider.card.modelNotSupported": "Modele {{modelName}} nie są zainstalowane.", + "modelProvider.card.modelSupported": "{{modelName}} modeli korzysta z tej kwoty.", "modelProvider.card.onTrial": "Na próbę", "modelProvider.card.paid": "Płatny", "modelProvider.card.priorityUse": "Używanie z priorytetem", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Pozostałe dostępne darmowe tokeny", "modelProvider.rerankModel.key": "Model ponownego rankingu", "modelProvider.rerankModel.tip": "Model ponownego rankingu zmieni kolejność listy dokumentów kandydatów na podstawie semantycznego dopasowania z zapytaniem użytkownika, poprawiając wyniki rankingu semantycznego", + "modelProvider.resetDate": "Reset na {{date}}", "modelProvider.searchModel": "Model wyszukiwania", "modelProvider.selectModel": "Wybierz swój model", "modelProvider.selector.emptySetting": "Przejdź do ustawień, aby skonfigurować", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index 2e7f49de7e..e97e4364ad 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Chamadas", "modelProvider.card.buyQuota": "Comprar Quota", "modelProvider.card.callTimes": "Chamadas", + "modelProvider.card.modelAPI": "Os modelos {{modelName}} estão usando a Chave de API.", + "modelProvider.card.modelNotSupported": "Modelos {{modelName}} não estão instalados.", + "modelProvider.card.modelSupported": "Modelos {{modelName}} estão usando esta cota.", "modelProvider.card.onTrial": "Em Teste", "modelProvider.card.paid": "Pago", "modelProvider.card.priorityUse": "Uso prioritário", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Tokens gratuitos disponíveis restantes", "modelProvider.rerankModel.key": "Modelo de Reordenação", "modelProvider.rerankModel.tip": "O modelo de reordenaenação reorganizará a lista de documentos candidatos com base na correspondência semântica com a consulta do usuário, melhorando os resultados da classificação semântica", + "modelProvider.resetDate": "Redefinir em {{date}}", "modelProvider.searchModel": "Modelo de pesquisa", "modelProvider.selectModel": "Selecione seu modelo", "modelProvider.selector.emptySetting": "Por favor, vá para configurações para configurar", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index c21e755b3c..785050d8ec 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Apeluri", "modelProvider.card.buyQuota": "Cumpără cotă", "modelProvider.card.callTimes": "Apeluri", + "modelProvider.card.modelAPI": "Modelele {{modelName}} folosesc cheia API.", + "modelProvider.card.modelNotSupported": "Modelele {{modelName}} nu sunt instalate.", + "modelProvider.card.modelSupported": "{{modelName}} modele utilizează această cotă.", "modelProvider.card.onTrial": "În probă", "modelProvider.card.paid": "Plătit", "modelProvider.card.priorityUse": "Utilizare prioritară", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Jetoane gratuite disponibile rămase", "modelProvider.rerankModel.key": "Model de reordonare", "modelProvider.rerankModel.tip": "Modelul de reordonare va reordona lista de documente candidate pe baza potrivirii semantice cu interogarea utilizatorului, îmbunătățind rezultatele clasificării semantice", + "modelProvider.resetDate": "Resetați la {{date}}", "modelProvider.searchModel": "Model de căutare", "modelProvider.selectModel": "Selectați modelul dvs.", "modelProvider.selector.emptySetting": "Vă rugăm să mergeți la setări pentru a configura", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index e763a7ec2a..63f3758185 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Количество вызовов", "modelProvider.card.buyQuota": "Купить квоту", "modelProvider.card.callTimes": "Количество вызовов", + "modelProvider.card.modelAPI": "{{modelName}} модели используют ключ API.", + "modelProvider.card.modelNotSupported": "Модели {{modelName}} не установлены.", + "modelProvider.card.modelSupported": "Эту квоту используют модели {{modelName}}.", "modelProvider.card.onTrial": "Пробная версия", "modelProvider.card.paid": "Платный", "modelProvider.card.priorityUse": "Приоритетное использование", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Оставшиеся доступные бесплатные токены", "modelProvider.rerankModel.key": "Модель повторного ранжирования", "modelProvider.rerankModel.tip": "Модель повторного ранжирования изменит порядок списка документов-кандидатов на основе семантического соответствия запросу пользователя, улучшая результаты семантического ранжирования", + "modelProvider.resetDate": "Сброс на {{date}}", "modelProvider.searchModel": "Поиск модели", "modelProvider.selectModel": "Выберите свою модель", "modelProvider.selector.emptySetting": "Пожалуйста, перейдите в настройки для настройки", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index d092fe10c8..be5a4e5320 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Število klicev", "modelProvider.card.buyQuota": "Kupi kvoto", "modelProvider.card.callTimes": "Časi klicev", + "modelProvider.card.modelAPI": "{{modelName}} modeli uporabljajo API ključ.", + "modelProvider.card.modelNotSupported": "{{modelName}} modeli niso nameščeni.", + "modelProvider.card.modelSupported": "{{modelName}} modeli uporabljajo to kvoto.", "modelProvider.card.onTrial": "Na preizkusu", "modelProvider.card.paid": "Plačano", "modelProvider.card.priorityUse": "Prednostna uporaba", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Preostali razpoložljivi brezplačni žetoni", "modelProvider.rerankModel.key": "Model za prerazvrstitev", "modelProvider.rerankModel.tip": "Model za prerazvrstitev bo prerazporedil seznam kandidatskih dokumentov na podlagi semantične ujemanja z uporabniško poizvedbo, s čimer se izboljšajo rezultati semantičnega razvrščanja.", + "modelProvider.resetDate": "Ponastavi na {{date}}", "modelProvider.searchModel": "Model iskanja", "modelProvider.selectModel": "Izberite svoj model", "modelProvider.selector.emptySetting": "Prosimo, pojdite v nastavitve za konfiguracijo", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index 9a38f7f683..c6dd9c9259 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "เวลาโทร", "modelProvider.card.buyQuota": "ซื้อโควต้า", "modelProvider.card.callTimes": "เวลาโทร", + "modelProvider.card.modelAPI": "{{modelName}} โมเดลกำลังใช้คีย์ API", + "modelProvider.card.modelNotSupported": "โมเดล {{modelName}} ยังไม่ได้ติดตั้ง", + "modelProvider.card.modelSupported": "โมเดล {{modelName}} กำลังใช้โควต้านี้อยู่", "modelProvider.card.onTrial": "ทดลองใช้", "modelProvider.card.paid": "จ่าย", "modelProvider.card.priorityUse": "ลําดับความสําคัญในการใช้งาน", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "โทเค็นฟรีที่เหลืออยู่", "modelProvider.rerankModel.key": "จัดอันดับโมเดลใหม่", "modelProvider.rerankModel.tip": "โมเดล Rerank จะจัดลําดับรายการเอกสารผู้สมัครใหม่ตามการจับคู่ความหมายกับการสืบค้นของผู้ใช้ ซึ่งช่วยปรับปรุงผลลัพธ์ของการจัดอันดับความหมาย", + "modelProvider.resetDate": "รีเซ็ตเมื่อ {{date}}", "modelProvider.searchModel": "ค้นหารุ่น", "modelProvider.selectModel": "เลือกรุ่นของคุณ", "modelProvider.selector.emptySetting": "โปรดไปที่การตั้งค่าเพื่อกําหนดค่า", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 0ee51e161c..68d4358281 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Çağrı Süreleri", "modelProvider.card.buyQuota": "Kota Satın Al", "modelProvider.card.callTimes": "Çağrı Süreleri", + "modelProvider.card.modelAPI": "{{modelName}} modelleri API Anahtarını kullanıyor.", + "modelProvider.card.modelNotSupported": "{{modelName}} modelleri yüklü değil.", + "modelProvider.card.modelSupported": "{{modelName}} modelleri bu kotayı kullanıyor.", "modelProvider.card.onTrial": "Deneme Sürümünde", "modelProvider.card.paid": "Ücretli", "modelProvider.card.priorityUse": "Öncelikli Kullan", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Kalan kullanılabilir ücretsiz tokenler", "modelProvider.rerankModel.key": "Yeniden Sıralama Modeli", "modelProvider.rerankModel.tip": "Yeniden sıralama modeli, kullanıcı sorgusuyla anlam eşleştirmesine dayalı olarak aday belge listesini yeniden sıralayacak ve anlam sıralama sonuçlarını iyileştirecektir.", + "modelProvider.resetDate": "{{date}} üzerine sıfırlama", "modelProvider.searchModel": "Model ara", "modelProvider.selectModel": "Modelinizi seçin", "modelProvider.selector.emptySetting": "Lütfen ayarlara gidip yapılandırın", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index ddec8637e1..7e3b7fe05f 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Кількість викликів", "modelProvider.card.buyQuota": "Придбати квоту", "modelProvider.card.callTimes": "Кількість викликів", + "modelProvider.card.modelAPI": "Моделі {{modelName}} використовують API-ключ.", + "modelProvider.card.modelNotSupported": "Моделі {{modelName}} не встановлені.", + "modelProvider.card.modelSupported": "Моделі {{modelName}} використовують цю квоту.", "modelProvider.card.onTrial": "У пробному періоді", "modelProvider.card.paid": "Оплачено", "modelProvider.card.priorityUse": "Пріоритетне використання", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Залишилося доступних безкоштовних токенів", "modelProvider.rerankModel.key": "Модель повторного ранжування", "modelProvider.rerankModel.tip": "Модель повторного ранжування змінить порядок списку документів-кандидатів на основі семантичної відповідності запиту користувача, покращуючи результати семантичного ранжування.", + "modelProvider.resetDate": "Скинути на {{date}}", "modelProvider.searchModel": "Пошукова модель", "modelProvider.selectModel": "Виберіть свою модель", "modelProvider.selector.emptySetting": "Перейдіть до налаштувань, щоб налаштувати", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index f8fa9c07d5..20364c3ee9 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "Số lần gọi", "modelProvider.card.buyQuota": "Mua Quota", "modelProvider.card.callTimes": "Số lần gọi", + "modelProvider.card.modelAPI": "Các mô hình {{modelName}} đang sử dụng Khóa API.", + "modelProvider.card.modelNotSupported": "Các mô hình {{modelName}} chưa được cài đặt.", + "modelProvider.card.modelSupported": "{{modelName}} mô hình đang sử dụng hạn mức này.", "modelProvider.card.onTrial": "Thử nghiệm", "modelProvider.card.paid": "Đã thanh toán", "modelProvider.card.priorityUse": "Ưu tiên sử dụng", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "Số lượng mã thông báo miễn phí còn lại", "modelProvider.rerankModel.key": "Mô hình Sắp xếp lại", "modelProvider.rerankModel.tip": "Mô hình sắp xếp lại sẽ sắp xếp lại danh sách tài liệu ứng cử viên dựa trên sự phù hợp ngữ nghĩa với truy vấn của người dùng, cải thiện kết quả của việc xếp hạng ngữ nghĩa", + "modelProvider.resetDate": "Đặt lại vào {{date}}", "modelProvider.searchModel": "Mô hình tìm kiếm", "modelProvider.selectModel": "Chọn mô hình của bạn", "modelProvider.selector.emptySetting": "Vui lòng vào cài đặt để cấu hình", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index 8fe3e5bd07..11846c9772 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -339,6 +339,9 @@ "modelProvider.callTimes": "呼叫次數", "modelProvider.card.buyQuota": "購買額度", "modelProvider.card.callTimes": "呼叫次數", + "modelProvider.card.modelAPI": "{{modelName}} 模型正在使用 API 金鑰。", + "modelProvider.card.modelNotSupported": "{{modelName}} 模型未安裝。", + "modelProvider.card.modelSupported": "{{modelName}} 模型正在使用這個配額。", "modelProvider.card.onTrial": "試用中", "modelProvider.card.paid": "已購買", "modelProvider.card.priorityUse": "優先使用", @@ -394,6 +397,7 @@ "modelProvider.quotaTip": "剩餘免費額度", "modelProvider.rerankModel.key": "Rerank 模型", "modelProvider.rerankModel.tip": "重排序模型將根據候選文件列表與使用者問題語義匹配度進行重新排序,從而改進語義排序的結果", + "modelProvider.resetDate": "在 {{date}} 重置", "modelProvider.searchModel": "搜尋模型", "modelProvider.selectModel": "選擇您的模型", "modelProvider.selector.emptySetting": "請前往設定進行配置", From 2cef8792092a597c77d4192e4868a4ccd2a74324 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sun, 4 Jan 2026 18:12:28 +0900 Subject: [PATCH 08/33] refactor: more ns.model to BaseModel (#30445) --- api/controllers/console/app/conversation.py | 7 +- .../console/explore/conversation.py | 33 +- api/controllers/console/explore/message.py | 20 +- .../console/explore/saved_message.py | 41 +- .../service_api/app/conversation.py | 32 +- api/controllers/service_api/app/message.py | 66 +-- api/controllers/web/conversation.py | 50 +- api/controllers/web/message.py | 58 +- api/controllers/web/saved_message.py | 50 +- api/fields/conversation_fields.py | 540 +++++++++++------- api/fields/message_fields.py | 188 +++--- .../controllers/web/test_message_list.py | 174 ++++++ 12 files changed, 764 insertions(+), 495 deletions(-) create mode 100644 api/tests/unit_tests/controllers/web/test_message_list.py diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index c16dcfd91f..ef2f86d4be 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -13,7 +13,6 @@ from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db -from fields.conversation_fields import MessageTextField from fields.raws import FilesContainedField from libs.datetime_utils import naive_utc_now, parse_time_range from libs.helper import TimestampField @@ -177,6 +176,12 @@ annotation_hit_history_model = console_ns.model( }, ) + +class MessageTextField(fields.Raw): + def format(self, value): + return value[0]["text"] if value else "" + + # Simple message detail model simple_message_detail_model = console_ns.model( "SimpleMessageDetail", diff --git a/api/controllers/console/explore/conversation.py b/api/controllers/console/explore/conversation.py index 51995b8b8a..933c80f509 100644 --- a/api/controllers/console/explore/conversation.py +++ b/api/controllers/console/explore/conversation.py @@ -1,8 +1,7 @@ from typing import Any from flask import request -from flask_restx import marshal_with -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, TypeAdapter, model_validator from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound @@ -11,7 +10,11 @@ from controllers.console.explore.error import NotChatAppError from controllers.console.explore.wraps import InstalledAppResource from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db -from fields.conversation_fields import conversation_infinite_scroll_pagination_fields, simple_conversation_fields +from fields.conversation_fields import ( + ConversationInfiniteScrollPagination, + ResultResponse, + SimpleConversation, +) from libs.helper import UUIDStrOrEmpty from libs.login import current_user from models import Account @@ -49,7 +52,6 @@ register_schema_models(console_ns, ConversationListQuery, ConversationRenamePayl endpoint="installed_app_conversations", ) class ConversationListApi(InstalledAppResource): - @marshal_with(conversation_infinite_scroll_pagination_fields) @console_ns.expect(console_ns.models[ConversationListQuery.__name__]) def get(self, installed_app): app_model = installed_app.app @@ -73,7 +75,7 @@ class ConversationListApi(InstalledAppResource): if not isinstance(current_user, Account): raise ValueError("current_user must be an Account instance") with Session(db.engine) as session: - return WebConversationService.pagination_by_last_id( + pagination = WebConversationService.pagination_by_last_id( session=session, app_model=app_model, user=current_user, @@ -82,6 +84,13 @@ class ConversationListApi(InstalledAppResource): invoke_from=InvokeFrom.EXPLORE, pinned=args.pinned, ) + adapter = TypeAdapter(SimpleConversation) + conversations = [adapter.validate_python(item, from_attributes=True) for item in pagination.data] + return ConversationInfiniteScrollPagination( + limit=pagination.limit, + has_more=pagination.has_more, + data=conversations, + ).model_dump(mode="json") except LastConversationNotExistsError: raise NotFound("Last Conversation Not Exists.") @@ -105,7 +114,7 @@ class ConversationApi(InstalledAppResource): except ConversationNotExistsError: raise NotFound("Conversation Not Exists.") - return {"result": "success"}, 204 + return ResultResponse(result="success").model_dump(mode="json"), 204 @console_ns.route( @@ -113,7 +122,6 @@ class ConversationApi(InstalledAppResource): endpoint="installed_app_conversation_rename", ) class ConversationRenameApi(InstalledAppResource): - @marshal_with(simple_conversation_fields) @console_ns.expect(console_ns.models[ConversationRenamePayload.__name__]) def post(self, installed_app, c_id): app_model = installed_app.app @@ -128,9 +136,14 @@ class ConversationRenameApi(InstalledAppResource): try: if not isinstance(current_user, Account): raise ValueError("current_user must be an Account instance") - return ConversationService.rename( + conversation = ConversationService.rename( app_model, conversation_id, current_user, payload.name, payload.auto_generate ) + return ( + TypeAdapter(SimpleConversation) + .validate_python(conversation, from_attributes=True) + .model_dump(mode="json") + ) except ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @@ -155,7 +168,7 @@ class ConversationPinApi(InstalledAppResource): except ConversationNotExistsError: raise NotFound("Conversation Not Exists.") - return {"result": "success"} + return ResultResponse(result="success").model_dump(mode="json") @console_ns.route( @@ -174,4 +187,4 @@ class ConversationUnPinApi(InstalledAppResource): raise ValueError("current_user must be an Account instance") WebConversationService.unpin(app_model, conversation_id, current_user) - return {"result": "success"} + return ResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/console/explore/message.py b/api/controllers/console/explore/message.py index d596d60b36..88487ac96f 100644 --- a/api/controllers/console/explore/message.py +++ b/api/controllers/console/explore/message.py @@ -2,8 +2,7 @@ import logging from typing import Literal from flask import request -from flask_restx import marshal_with -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, TypeAdapter from werkzeug.exceptions import InternalServerError, NotFound from controllers.common.schema import register_schema_models @@ -23,7 +22,8 @@ from controllers.console.explore.wraps import InstalledAppResource from core.app.entities.app_invoke_entities import InvokeFrom from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from core.model_runtime.errors.invoke import InvokeError -from fields.message_fields import message_infinite_scroll_pagination_fields +from fields.conversation_fields import ResultResponse +from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem, SuggestedQuestionsResponse from libs import helper from libs.helper import UUIDStrOrEmpty from libs.login import current_account_with_tenant @@ -66,7 +66,6 @@ register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, Mor endpoint="installed_app_messages", ) class MessageListApi(InstalledAppResource): - @marshal_with(message_infinite_scroll_pagination_fields) @console_ns.expect(console_ns.models[MessageListQuery.__name__]) def get(self, installed_app): current_user, _ = current_account_with_tenant() @@ -78,13 +77,20 @@ class MessageListApi(InstalledAppResource): args = MessageListQuery.model_validate(request.args.to_dict()) try: - return MessageService.pagination_by_first_id( + pagination = MessageService.pagination_by_first_id( app_model, current_user, str(args.conversation_id), str(args.first_id) if args.first_id else None, args.limit, ) + adapter = TypeAdapter(MessageListItem) + items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data] + return MessageInfiniteScrollPagination( + limit=pagination.limit, + has_more=pagination.has_more, + data=items, + ).model_dump(mode="json") except ConversationNotExistsError: raise NotFound("Conversation Not Exists.") except FirstMessageNotExistsError: @@ -116,7 +122,7 @@ class MessageFeedbackApi(InstalledAppResource): except MessageNotExistsError: raise NotFound("Message Not Exists.") - return {"result": "success"} + return ResultResponse(result="success").model_dump(mode="json") @console_ns.route( @@ -201,4 +207,4 @@ class MessageSuggestedQuestionApi(InstalledAppResource): logger.exception("internal server error.") raise InternalServerError() - return {"data": questions} + return SuggestedQuestionsResponse(data=questions).model_dump(mode="json") diff --git a/api/controllers/console/explore/saved_message.py b/api/controllers/console/explore/saved_message.py index bc7b8e7651..ea3de91741 100644 --- a/api/controllers/console/explore/saved_message.py +++ b/api/controllers/console/explore/saved_message.py @@ -1,14 +1,14 @@ from flask import request -from flask_restx import fields, marshal_with -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, TypeAdapter from werkzeug.exceptions import NotFound from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.explore.error import NotCompletionAppError from controllers.console.explore.wraps import InstalledAppResource -from fields.conversation_fields import message_file_fields -from libs.helper import TimestampField, UUIDStrOrEmpty +from fields.conversation_fields import ResultResponse +from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem +from libs.helper import UUIDStrOrEmpty from libs.login import current_account_with_tenant from services.errors.message import MessageNotExistsError from services.saved_message_service import SavedMessageService @@ -26,28 +26,8 @@ class SavedMessageCreatePayload(BaseModel): register_schema_models(console_ns, SavedMessageListQuery, SavedMessageCreatePayload) -feedback_fields = {"rating": fields.String} - -message_fields = { - "id": fields.String, - "inputs": fields.Raw, - "query": fields.String, - "answer": fields.String, - "message_files": fields.List(fields.Nested(message_file_fields)), - "feedback": fields.Nested(feedback_fields, attribute="user_feedback", allow_null=True), - "created_at": TimestampField, -} - - @console_ns.route("/installed-apps//saved-messages", endpoint="installed_app_saved_messages") class SavedMessageListApi(InstalledAppResource): - saved_message_infinite_scroll_pagination_fields = { - "limit": fields.Integer, - "has_more": fields.Boolean, - "data": fields.List(fields.Nested(message_fields)), - } - - @marshal_with(saved_message_infinite_scroll_pagination_fields) @console_ns.expect(console_ns.models[SavedMessageListQuery.__name__]) def get(self, installed_app): current_user, _ = current_account_with_tenant() @@ -57,12 +37,19 @@ class SavedMessageListApi(InstalledAppResource): args = SavedMessageListQuery.model_validate(request.args.to_dict()) - return SavedMessageService.pagination_by_last_id( + pagination = SavedMessageService.pagination_by_last_id( app_model, current_user, str(args.last_id) if args.last_id else None, args.limit, ) + adapter = TypeAdapter(SavedMessageItem) + items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data] + return SavedMessageInfiniteScrollPagination( + limit=pagination.limit, + has_more=pagination.has_more, + data=items, + ).model_dump(mode="json") @console_ns.expect(console_ns.models[SavedMessageCreatePayload.__name__]) def post(self, installed_app): @@ -78,7 +65,7 @@ class SavedMessageListApi(InstalledAppResource): except MessageNotExistsError: raise NotFound("Message Not Exists.") - return {"result": "success"} + return ResultResponse(result="success").model_dump(mode="json") @console_ns.route( @@ -96,4 +83,4 @@ class SavedMessageApi(InstalledAppResource): SavedMessageService.delete(app_model, current_user, message_id) - return {"result": "success"}, 204 + return ResultResponse(result="success").model_dump(mode="json"), 204 diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index 40e4bde389..62e8258e25 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -3,8 +3,7 @@ from uuid import UUID from flask import request from flask_restx import Resource -from flask_restx._http import HTTPStatus -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, NotFound @@ -16,9 +15,9 @@ from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from fields.conversation_fields import ( - build_conversation_delete_model, - build_conversation_infinite_scroll_pagination_model, - build_simple_conversation_model, + ConversationDelete, + ConversationInfiniteScrollPagination, + SimpleConversation, ) from fields.conversation_variable_fields import ( build_conversation_variable_infinite_scroll_pagination_model, @@ -105,7 +104,6 @@ class ConversationApi(Resource): } ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) - @service_api_ns.marshal_with(build_conversation_infinite_scroll_pagination_model(service_api_ns)) def get(self, app_model: App, end_user: EndUser): """List all conversations for the current user. @@ -120,7 +118,7 @@ class ConversationApi(Resource): try: with Session(db.engine) as session: - return ConversationService.pagination_by_last_id( + pagination = ConversationService.pagination_by_last_id( session=session, app_model=app_model, user=end_user, @@ -129,6 +127,13 @@ class ConversationApi(Resource): invoke_from=InvokeFrom.SERVICE_API, sort_by=query_args.sort_by, ) + adapter = TypeAdapter(SimpleConversation) + conversations = [adapter.validate_python(item, from_attributes=True) for item in pagination.data] + return ConversationInfiniteScrollPagination( + limit=pagination.limit, + has_more=pagination.has_more, + data=conversations, + ).model_dump(mode="json") except services.errors.conversation.LastConversationNotExistsError: raise NotFound("Last Conversation Not Exists.") @@ -146,7 +151,6 @@ class ConversationDetailApi(Resource): } ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) - @service_api_ns.marshal_with(build_conversation_delete_model(service_api_ns), code=HTTPStatus.NO_CONTENT) def delete(self, app_model: App, end_user: EndUser, c_id): """Delete a specific conversation.""" app_mode = AppMode.value_of(app_model.mode) @@ -159,7 +163,7 @@ class ConversationDetailApi(Resource): ConversationService.delete(app_model, conversation_id, end_user) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") - return {"result": "success"}, 204 + return ConversationDelete(result="success").model_dump(mode="json"), 204 @service_api_ns.route("/conversations//name") @@ -176,7 +180,6 @@ class ConversationRenameApi(Resource): } ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) - @service_api_ns.marshal_with(build_simple_conversation_model(service_api_ns)) def post(self, app_model: App, end_user: EndUser, c_id): """Rename a conversation or auto-generate a name.""" app_mode = AppMode.value_of(app_model.mode) @@ -188,7 +191,14 @@ class ConversationRenameApi(Resource): payload = ConversationRenamePayload.model_validate(service_api_ns.payload or {}) try: - return ConversationService.rename(app_model, conversation_id, end_user, payload.name, payload.auto_generate) + conversation = ConversationService.rename( + app_model, conversation_id, end_user, payload.name, payload.auto_generate + ) + return ( + TypeAdapter(SimpleConversation) + .validate_python(conversation, from_attributes=True) + .model_dump(mode="json") + ) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") diff --git a/api/controllers/service_api/app/message.py b/api/controllers/service_api/app/message.py index d342f4e661..8981bbd7d5 100644 --- a/api/controllers/service_api/app/message.py +++ b/api/controllers/service_api/app/message.py @@ -1,11 +1,10 @@ -import json import logging from typing import Literal from uuid import UUID from flask import request -from flask_restx import Namespace, Resource, fields -from pydantic import BaseModel, Field +from flask_restx import Resource +from pydantic import BaseModel, Field, TypeAdapter from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services @@ -14,10 +13,8 @@ from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotChatAppError from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.app.entities.app_invoke_entities import InvokeFrom -from fields.conversation_fields import build_message_file_model -from fields.message_fields import build_agent_thought_model, build_feedback_model -from fields.raws import FilesContainedField -from libs.helper import TimestampField +from fields.conversation_fields import ResultResponse +from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem from models.model import App, AppMode, EndUser from services.errors.message import ( FirstMessageNotExistsError, @@ -48,49 +45,6 @@ class FeedbackListQuery(BaseModel): register_schema_models(service_api_ns, MessageListQuery, MessageFeedbackPayload, FeedbackListQuery) -def build_message_model(api_or_ns: Namespace): - """Build the message model for the API or Namespace.""" - # First build the nested models - feedback_model = build_feedback_model(api_or_ns) - agent_thought_model = build_agent_thought_model(api_or_ns) - message_file_model = build_message_file_model(api_or_ns) - - # Then build the message fields with nested models - message_fields = { - "id": fields.String, - "conversation_id": fields.String, - "parent_message_id": fields.String, - "inputs": FilesContainedField, - "query": fields.String, - "answer": fields.String(attribute="re_sign_file_url_answer"), - "message_files": fields.List(fields.Nested(message_file_model)), - "feedback": fields.Nested(feedback_model, attribute="user_feedback", allow_null=True), - "retriever_resources": fields.Raw( - attribute=lambda obj: json.loads(obj.message_metadata).get("retriever_resources", []) - if obj.message_metadata - else [] - ), - "created_at": TimestampField, - "agent_thoughts": fields.List(fields.Nested(agent_thought_model)), - "status": fields.String, - "error": fields.String, - } - return api_or_ns.model("Message", message_fields) - - -def build_message_infinite_scroll_pagination_model(api_or_ns: Namespace): - """Build the message infinite scroll pagination model for the API or Namespace.""" - # Build the nested message model first - message_model = build_message_model(api_or_ns) - - message_infinite_scroll_pagination_fields = { - "limit": fields.Integer, - "has_more": fields.Boolean, - "data": fields.List(fields.Nested(message_model)), - } - return api_or_ns.model("MessageInfiniteScrollPagination", message_infinite_scroll_pagination_fields) - - @service_api_ns.route("/messages") class MessageListApi(Resource): @service_api_ns.expect(service_api_ns.models[MessageListQuery.__name__]) @@ -104,7 +58,6 @@ class MessageListApi(Resource): } ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) - @service_api_ns.marshal_with(build_message_infinite_scroll_pagination_model(service_api_ns)) def get(self, app_model: App, end_user: EndUser): """List messages in a conversation. @@ -119,9 +72,16 @@ class MessageListApi(Resource): first_id = str(query_args.first_id) if query_args.first_id else None try: - return MessageService.pagination_by_first_id( + pagination = MessageService.pagination_by_first_id( app_model, end_user, conversation_id, first_id, query_args.limit ) + adapter = TypeAdapter(MessageListItem) + items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data] + return MessageInfiniteScrollPagination( + limit=pagination.limit, + has_more=pagination.has_more, + data=items, + ).model_dump(mode="json") except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") except FirstMessageNotExistsError: @@ -162,7 +122,7 @@ class MessageFeedbackApi(Resource): except MessageNotExistsError: raise NotFound("Message Not Exists.") - return {"result": "success"} + return ResultResponse(result="success").model_dump(mode="json") @service_api_ns.route("/app/feedbacks") diff --git a/api/controllers/web/conversation.py b/api/controllers/web/conversation.py index 86e19423e5..527eef6094 100644 --- a/api/controllers/web/conversation.py +++ b/api/controllers/web/conversation.py @@ -1,5 +1,6 @@ -from flask_restx import fields, marshal_with, reqparse +from flask_restx import reqparse from flask_restx.inputs import int_range +from pydantic import TypeAdapter from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound @@ -8,7 +9,11 @@ from controllers.web.error import NotChatAppError from controllers.web.wraps import WebApiResource from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db -from fields.conversation_fields import conversation_infinite_scroll_pagination_fields, simple_conversation_fields +from fields.conversation_fields import ( + ConversationInfiniteScrollPagination, + ResultResponse, + SimpleConversation, +) from libs.helper import uuid_value from models.model import AppMode from services.conversation_service import ConversationService @@ -54,7 +59,6 @@ class ConversationListApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(conversation_infinite_scroll_pagination_fields) def get(self, app_model, end_user): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: @@ -82,7 +86,7 @@ class ConversationListApi(WebApiResource): try: with Session(db.engine) as session: - return WebConversationService.pagination_by_last_id( + pagination = WebConversationService.pagination_by_last_id( session=session, app_model=app_model, user=end_user, @@ -92,16 +96,19 @@ class ConversationListApi(WebApiResource): pinned=pinned, sort_by=args["sort_by"], ) + adapter = TypeAdapter(SimpleConversation) + conversations = [adapter.validate_python(item, from_attributes=True) for item in pagination.data] + return ConversationInfiniteScrollPagination( + limit=pagination.limit, + has_more=pagination.has_more, + data=conversations, + ).model_dump(mode="json") except LastConversationNotExistsError: raise NotFound("Last Conversation Not Exists.") @web_ns.route("/conversations/") class ConversationApi(WebApiResource): - delete_response_fields = { - "result": fields.String, - } - @web_ns.doc("Delete Conversation") @web_ns.doc(description="Delete a specific conversation.") @web_ns.doc(params={"c_id": {"description": "Conversation UUID", "type": "string", "required": True}}) @@ -115,7 +122,6 @@ class ConversationApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(delete_response_fields) def delete(self, app_model, end_user, c_id): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: @@ -126,7 +132,7 @@ class ConversationApi(WebApiResource): ConversationService.delete(app_model, conversation_id, end_user) except ConversationNotExistsError: raise NotFound("Conversation Not Exists.") - return {"result": "success"}, 204 + return ResultResponse(result="success").model_dump(mode="json"), 204 @web_ns.route("/conversations//name") @@ -155,7 +161,6 @@ class ConversationRenameApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(simple_conversation_fields) def post(self, app_model, end_user, c_id): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: @@ -171,17 +176,20 @@ class ConversationRenameApi(WebApiResource): args = parser.parse_args() try: - return ConversationService.rename(app_model, conversation_id, end_user, args["name"], args["auto_generate"]) + conversation = ConversationService.rename( + app_model, conversation_id, end_user, args["name"], args["auto_generate"] + ) + return ( + TypeAdapter(SimpleConversation) + .validate_python(conversation, from_attributes=True) + .model_dump(mode="json") + ) except ConversationNotExistsError: raise NotFound("Conversation Not Exists.") @web_ns.route("/conversations//pin") class ConversationPinApi(WebApiResource): - pin_response_fields = { - "result": fields.String, - } - @web_ns.doc("Pin Conversation") @web_ns.doc(description="Pin a specific conversation to keep it at the top of the list.") @web_ns.doc(params={"c_id": {"description": "Conversation UUID", "type": "string", "required": True}}) @@ -195,7 +203,6 @@ class ConversationPinApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(pin_response_fields) def patch(self, app_model, end_user, c_id): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: @@ -208,15 +215,11 @@ class ConversationPinApi(WebApiResource): except ConversationNotExistsError: raise NotFound("Conversation Not Exists.") - return {"result": "success"} + return ResultResponse(result="success").model_dump(mode="json") @web_ns.route("/conversations//unpin") class ConversationUnPinApi(WebApiResource): - unpin_response_fields = { - "result": fields.String, - } - @web_ns.doc("Unpin Conversation") @web_ns.doc(description="Unpin a specific conversation to remove it from the top of the list.") @web_ns.doc(params={"c_id": {"description": "Conversation UUID", "type": "string", "required": True}}) @@ -230,7 +233,6 @@ class ConversationUnPinApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(unpin_response_fields) def patch(self, app_model, end_user, c_id): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: @@ -239,4 +241,4 @@ class ConversationUnPinApi(WebApiResource): conversation_id = str(c_id) WebConversationService.unpin(app_model, conversation_id, end_user) - return {"result": "success"} + return ResultResponse(result="success").model_dump(mode="json") diff --git a/api/controllers/web/message.py b/api/controllers/web/message.py index 5c7ea9e69a..80035ba818 100644 --- a/api/controllers/web/message.py +++ b/api/controllers/web/message.py @@ -2,8 +2,7 @@ import logging from typing import Literal from flask import request -from flask_restx import fields, marshal_with -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, TypeAdapter, field_validator from werkzeug.exceptions import InternalServerError, NotFound from controllers.common.schema import register_schema_models @@ -22,11 +21,10 @@ from controllers.web.wraps import WebApiResource from core.app.entities.app_invoke_entities import InvokeFrom from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from core.model_runtime.errors.invoke import InvokeError -from fields.conversation_fields import message_file_fields -from fields.message_fields import agent_thought_fields, feedback_fields, retriever_resource_fields -from fields.raws import FilesContainedField +from fields.conversation_fields import ResultResponse +from fields.message_fields import SuggestedQuestionsResponse, WebMessageInfiniteScrollPagination, WebMessageListItem from libs import helper -from libs.helper import TimestampField, uuid_value +from libs.helper import uuid_value from models.model import AppMode from services.app_generate_service import AppGenerateService from services.errors.app import MoreLikeThisDisabledError @@ -70,29 +68,6 @@ register_schema_models(web_ns, MessageListQuery, MessageFeedbackPayload, Message @web_ns.route("/messages") class MessageListApi(WebApiResource): - message_fields = { - "id": fields.String, - "conversation_id": fields.String, - "parent_message_id": fields.String, - "inputs": FilesContainedField, - "query": fields.String, - "answer": fields.String(attribute="re_sign_file_url_answer"), - "message_files": fields.List(fields.Nested(message_file_fields)), - "feedback": fields.Nested(feedback_fields, attribute="user_feedback", allow_null=True), - "retriever_resources": fields.List(fields.Nested(retriever_resource_fields)), - "created_at": TimestampField, - "agent_thoughts": fields.List(fields.Nested(agent_thought_fields)), - "metadata": fields.Raw(attribute="message_metadata_dict"), - "status": fields.String, - "error": fields.String, - } - - message_infinite_scroll_pagination_fields = { - "limit": fields.Integer, - "has_more": fields.Boolean, - "data": fields.List(fields.Nested(message_fields)), - } - @web_ns.doc("Get Message List") @web_ns.doc(description="Retrieve paginated list of messages from a conversation in a chat application.") @web_ns.doc( @@ -121,7 +96,6 @@ class MessageListApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(message_infinite_scroll_pagination_fields) def get(self, app_model, end_user): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: @@ -131,9 +105,16 @@ class MessageListApi(WebApiResource): query = MessageListQuery.model_validate(raw_args) try: - return MessageService.pagination_by_first_id( + pagination = MessageService.pagination_by_first_id( app_model, end_user, query.conversation_id, query.first_id, query.limit ) + adapter = TypeAdapter(WebMessageListItem) + items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data] + return WebMessageInfiniteScrollPagination( + limit=pagination.limit, + has_more=pagination.has_more, + data=items, + ).model_dump(mode="json") except ConversationNotExistsError: raise NotFound("Conversation Not Exists.") except FirstMessageNotExistsError: @@ -142,10 +123,6 @@ class MessageListApi(WebApiResource): @web_ns.route("/messages//feedbacks") class MessageFeedbackApi(WebApiResource): - feedback_response_fields = { - "result": fields.String, - } - @web_ns.doc("Create Message Feedback") @web_ns.doc(description="Submit feedback (like/dislike) for a specific message.") @web_ns.doc(params={"message_id": {"description": "Message UUID", "type": "string", "required": True}}) @@ -170,7 +147,6 @@ class MessageFeedbackApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(feedback_response_fields) def post(self, app_model, end_user, message_id): message_id = str(message_id) @@ -187,7 +163,7 @@ class MessageFeedbackApi(WebApiResource): except MessageNotExistsError: raise NotFound("Message Not Exists.") - return {"result": "success"} + return ResultResponse(result="success").model_dump(mode="json") @web_ns.route("/messages//more-like-this") @@ -247,10 +223,6 @@ class MessageMoreLikeThisApi(WebApiResource): @web_ns.route("/messages//suggested-questions") class MessageSuggestedQuestionApi(WebApiResource): - suggested_questions_response_fields = { - "data": fields.List(fields.String), - } - @web_ns.doc("Get Suggested Questions") @web_ns.doc(description="Get suggested follow-up questions after a message (chat apps only).") @web_ns.doc(params={"message_id": {"description": "Message UUID", "type": "string", "required": True}}) @@ -264,7 +236,6 @@ class MessageSuggestedQuestionApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(suggested_questions_response_fields) def get(self, app_model, end_user, message_id): app_mode = AppMode.value_of(app_model.mode) if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}: @@ -277,7 +248,6 @@ class MessageSuggestedQuestionApi(WebApiResource): app_model=app_model, user=end_user, message_id=message_id, invoke_from=InvokeFrom.WEB_APP ) # questions is a list of strings, not a list of Message objects - # so we can directly return it except MessageNotExistsError: raise NotFound("Message not found") except ConversationNotExistsError: @@ -296,4 +266,4 @@ class MessageSuggestedQuestionApi(WebApiResource): logger.exception("internal server error.") raise InternalServerError() - return {"data": questions} + return SuggestedQuestionsResponse(data=questions).model_dump(mode="json") diff --git a/api/controllers/web/saved_message.py b/api/controllers/web/saved_message.py index 865f3610a7..4e20690e9e 100644 --- a/api/controllers/web/saved_message.py +++ b/api/controllers/web/saved_message.py @@ -1,40 +1,20 @@ -from flask_restx import fields, marshal_with, reqparse +from flask_restx import reqparse from flask_restx.inputs import int_range +from pydantic import TypeAdapter from werkzeug.exceptions import NotFound from controllers.web import web_ns from controllers.web.error import NotCompletionAppError from controllers.web.wraps import WebApiResource -from fields.conversation_fields import message_file_fields -from libs.helper import TimestampField, uuid_value +from fields.conversation_fields import ResultResponse +from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem +from libs.helper import uuid_value from services.errors.message import MessageNotExistsError from services.saved_message_service import SavedMessageService -feedback_fields = {"rating": fields.String} - -message_fields = { - "id": fields.String, - "inputs": fields.Raw, - "query": fields.String, - "answer": fields.String, - "message_files": fields.List(fields.Nested(message_file_fields)), - "feedback": fields.Nested(feedback_fields, attribute="user_feedback", allow_null=True), - "created_at": TimestampField, -} - @web_ns.route("/saved-messages") class SavedMessageListApi(WebApiResource): - saved_message_infinite_scroll_pagination_fields = { - "limit": fields.Integer, - "has_more": fields.Boolean, - "data": fields.List(fields.Nested(message_fields)), - } - - post_response_fields = { - "result": fields.String, - } - @web_ns.doc("Get Saved Messages") @web_ns.doc(description="Retrieve paginated list of saved messages for a completion application.") @web_ns.doc( @@ -58,7 +38,6 @@ class SavedMessageListApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(saved_message_infinite_scroll_pagination_fields) def get(self, app_model, end_user): if app_model.mode != "completion": raise NotCompletionAppError() @@ -70,7 +49,14 @@ class SavedMessageListApi(WebApiResource): ) args = parser.parse_args() - return SavedMessageService.pagination_by_last_id(app_model, end_user, args["last_id"], args["limit"]) + pagination = SavedMessageService.pagination_by_last_id(app_model, end_user, args["last_id"], args["limit"]) + adapter = TypeAdapter(SavedMessageItem) + items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data] + return SavedMessageInfiniteScrollPagination( + limit=pagination.limit, + has_more=pagination.has_more, + data=items, + ).model_dump(mode="json") @web_ns.doc("Save Message") @web_ns.doc(description="Save a specific message for later reference.") @@ -89,7 +75,6 @@ class SavedMessageListApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(post_response_fields) def post(self, app_model, end_user): if app_model.mode != "completion": raise NotCompletionAppError() @@ -102,15 +87,11 @@ class SavedMessageListApi(WebApiResource): except MessageNotExistsError: raise NotFound("Message Not Exists.") - return {"result": "success"} + return ResultResponse(result="success").model_dump(mode="json") @web_ns.route("/saved-messages/") class SavedMessageApi(WebApiResource): - delete_response_fields = { - "result": fields.String, - } - @web_ns.doc("Delete Saved Message") @web_ns.doc(description="Remove a message from saved messages.") @web_ns.doc(params={"message_id": {"description": "Message UUID to delete", "type": "string", "required": True}}) @@ -124,7 +105,6 @@ class SavedMessageApi(WebApiResource): 500: "Internal Server Error", } ) - @marshal_with(delete_response_fields) def delete(self, app_model, end_user, message_id): message_id = str(message_id) @@ -133,4 +113,4 @@ class SavedMessageApi(WebApiResource): SavedMessageService.delete(app_model, end_user, message_id) - return {"result": "success"}, 204 + return ResultResponse(result="success").model_dump(mode="json"), 204 diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index e4ca2e7a42..d8ae0ad8b8 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -1,236 +1,338 @@ -from flask_restx import Namespace, fields +from __future__ import annotations -from fields.member_fields import simple_account_fields -from libs.helper import TimestampField +from datetime import datetime +from typing import Any, TypeAlias -from .raws import FilesContainedField +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from core.file import File + +JSONValue: TypeAlias = Any -class MessageTextField(fields.Raw): - def format(self, value): - return value[0]["text"] if value else "" +class ResponseModel(BaseModel): + model_config = ConfigDict( + from_attributes=True, + extra="ignore", + populate_by_name=True, + serialize_by_alias=True, + protected_namespaces=(), + ) -feedback_fields = { - "rating": fields.String, - "content": fields.String, - "from_source": fields.String, - "from_end_user_id": fields.String, - "from_account": fields.Nested(simple_account_fields, allow_null=True), -} +class MessageFile(ResponseModel): + id: str + filename: str + type: str + url: str | None = None + mime_type: str | None = None + size: int | None = None + transfer_method: str + belongs_to: str | None = None + upload_file_id: str | None = None -annotation_fields = { - "id": fields.String, - "question": fields.String, - "content": fields.String, - "account": fields.Nested(simple_account_fields, allow_null=True), - "created_at": TimestampField, -} - -annotation_hit_history_fields = { - "annotation_id": fields.String(attribute="id"), - "annotation_create_account": fields.Nested(simple_account_fields, allow_null=True), - "created_at": TimestampField, -} - -message_file_fields = { - "id": fields.String, - "filename": fields.String, - "type": fields.String, - "url": fields.String, - "mime_type": fields.String, - "size": fields.Integer, - "transfer_method": fields.String, - "belongs_to": fields.String(default="user"), - "upload_file_id": fields.String(default=None), -} + @field_validator("transfer_method", mode="before") + @classmethod + def _normalize_transfer_method(cls, value: object) -> str: + if isinstance(value, str): + return value + return str(value) -def build_message_file_model(api_or_ns: Namespace): - """Build the message file fields for the API or Namespace.""" - return api_or_ns.model("MessageFile", message_file_fields) +class SimpleConversation(ResponseModel): + id: str + name: str + inputs: dict[str, JSONValue] + status: str + introduction: str | None = None + created_at: int | None = None + updated_at: int | None = None + + @field_validator("inputs", mode="before") + @classmethod + def _normalize_inputs(cls, value: JSONValue) -> JSONValue: + return format_files_contained(value) + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return to_timestamp(value) + return value -agent_thought_fields = { - "id": fields.String, - "chain_id": fields.String, - "message_id": fields.String, - "position": fields.Integer, - "thought": fields.String, - "tool": fields.String, - "tool_labels": fields.Raw, - "tool_input": fields.String, - "created_at": TimestampField, - "observation": fields.String, - "files": fields.List(fields.String), -} - -message_detail_fields = { - "id": fields.String, - "conversation_id": fields.String, - "inputs": FilesContainedField, - "query": fields.String, - "message": fields.Raw, - "message_tokens": fields.Integer, - "answer": fields.String(attribute="re_sign_file_url_answer"), - "answer_tokens": fields.Integer, - "provider_response_latency": fields.Float, - "from_source": fields.String, - "from_end_user_id": fields.String, - "from_account_id": fields.String, - "feedbacks": fields.List(fields.Nested(feedback_fields)), - "workflow_run_id": fields.String, - "annotation": fields.Nested(annotation_fields, allow_null=True), - "annotation_hit_history": fields.Nested(annotation_hit_history_fields, allow_null=True), - "created_at": TimestampField, - "agent_thoughts": fields.List(fields.Nested(agent_thought_fields)), - "message_files": fields.List(fields.Nested(message_file_fields)), - "metadata": fields.Raw(attribute="message_metadata_dict"), - "status": fields.String, - "error": fields.String, - "parent_message_id": fields.String, -} - -feedback_stat_fields = {"like": fields.Integer, "dislike": fields.Integer} -status_count_fields = {"success": fields.Integer, "failed": fields.Integer, "partial_success": fields.Integer} -model_config_fields = { - "opening_statement": fields.String, - "suggested_questions": fields.Raw, - "model": fields.Raw, - "user_input_form": fields.Raw, - "pre_prompt": fields.String, - "agent_mode": fields.Raw, -} - -simple_model_config_fields = { - "model": fields.Raw(attribute="model_dict"), - "pre_prompt": fields.String, -} - -simple_message_detail_fields = { - "inputs": FilesContainedField, - "query": fields.String, - "message": MessageTextField, - "answer": fields.String, -} - -conversation_fields = { - "id": fields.String, - "status": fields.String, - "from_source": fields.String, - "from_end_user_id": fields.String, - "from_end_user_session_id": fields.String(), - "from_account_id": fields.String, - "from_account_name": fields.String, - "read_at": TimestampField, - "created_at": TimestampField, - "updated_at": TimestampField, - "annotation": fields.Nested(annotation_fields, allow_null=True), - "model_config": fields.Nested(simple_model_config_fields), - "user_feedback_stats": fields.Nested(feedback_stat_fields), - "admin_feedback_stats": fields.Nested(feedback_stat_fields), - "message": fields.Nested(simple_message_detail_fields, attribute="first_message"), -} - -conversation_pagination_fields = { - "page": fields.Integer, - "limit": fields.Integer(attribute="per_page"), - "total": fields.Integer, - "has_more": fields.Boolean(attribute="has_next"), - "data": fields.List(fields.Nested(conversation_fields), attribute="items"), -} - -conversation_message_detail_fields = { - "id": fields.String, - "status": fields.String, - "from_source": fields.String, - "from_end_user_id": fields.String, - "from_account_id": fields.String, - "created_at": TimestampField, - "model_config": fields.Nested(model_config_fields), - "message": fields.Nested(message_detail_fields, attribute="first_message"), -} - -conversation_with_summary_fields = { - "id": fields.String, - "status": fields.String, - "from_source": fields.String, - "from_end_user_id": fields.String, - "from_end_user_session_id": fields.String, - "from_account_id": fields.String, - "from_account_name": fields.String, - "name": fields.String, - "summary": fields.String(attribute="summary_or_query"), - "read_at": TimestampField, - "created_at": TimestampField, - "updated_at": TimestampField, - "annotated": fields.Boolean, - "model_config": fields.Nested(simple_model_config_fields), - "message_count": fields.Integer, - "user_feedback_stats": fields.Nested(feedback_stat_fields), - "admin_feedback_stats": fields.Nested(feedback_stat_fields), - "status_count": fields.Nested(status_count_fields), -} - -conversation_with_summary_pagination_fields = { - "page": fields.Integer, - "limit": fields.Integer(attribute="per_page"), - "total": fields.Integer, - "has_more": fields.Boolean(attribute="has_next"), - "data": fields.List(fields.Nested(conversation_with_summary_fields), attribute="items"), -} - -conversation_detail_fields = { - "id": fields.String, - "status": fields.String, - "from_source": fields.String, - "from_end_user_id": fields.String, - "from_account_id": fields.String, - "created_at": TimestampField, - "updated_at": TimestampField, - "annotated": fields.Boolean, - "introduction": fields.String, - "model_config": fields.Nested(model_config_fields), - "message_count": fields.Integer, - "user_feedback_stats": fields.Nested(feedback_stat_fields), - "admin_feedback_stats": fields.Nested(feedback_stat_fields), -} - -simple_conversation_fields = { - "id": fields.String, - "name": fields.String, - "inputs": FilesContainedField, - "status": fields.String, - "introduction": fields.String, - "created_at": TimestampField, - "updated_at": TimestampField, -} - -conversation_delete_fields = { - "result": fields.String, -} - -conversation_infinite_scroll_pagination_fields = { - "limit": fields.Integer, - "has_more": fields.Boolean, - "data": fields.List(fields.Nested(simple_conversation_fields)), -} +class ConversationInfiniteScrollPagination(ResponseModel): + limit: int + has_more: bool + data: list[SimpleConversation] -def build_conversation_infinite_scroll_pagination_model(api_or_ns: Namespace): - """Build the conversation infinite scroll pagination model for the API or Namespace.""" - simple_conversation_model = build_simple_conversation_model(api_or_ns) - - copied_fields = conversation_infinite_scroll_pagination_fields.copy() - copied_fields["data"] = fields.List(fields.Nested(simple_conversation_model)) - return api_or_ns.model("ConversationInfiniteScrollPagination", copied_fields) +class ConversationDelete(ResponseModel): + result: str -def build_conversation_delete_model(api_or_ns: Namespace): - """Build the conversation delete model for the API or Namespace.""" - return api_or_ns.model("ConversationDelete", conversation_delete_fields) +class ResultResponse(ResponseModel): + result: str -def build_simple_conversation_model(api_or_ns: Namespace): - """Build the simple conversation model for the API or Namespace.""" - return api_or_ns.model("SimpleConversation", simple_conversation_fields) +class SimpleAccount(ResponseModel): + id: str + name: str + email: str + + +class Feedback(ResponseModel): + rating: str + content: str | None = None + from_source: str + from_end_user_id: str | None = None + from_account: SimpleAccount | None = None + + +class Annotation(ResponseModel): + id: str + question: str | None = None + content: str + account: SimpleAccount | None = None + created_at: int | None = None + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return to_timestamp(value) + return value + + +class AnnotationHitHistory(ResponseModel): + annotation_id: str + annotation_create_account: SimpleAccount | None = None + created_at: int | None = None + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return to_timestamp(value) + return value + + +class AgentThought(ResponseModel): + id: str + chain_id: str | None = None + message_chain_id: str | None = Field(default=None, exclude=True, validation_alias="message_chain_id") + message_id: str + position: int + thought: str | None = None + tool: str | None = None + tool_labels: JSONValue + tool_input: str | None = None + created_at: int | None = None + observation: str | None = None + files: list[str] + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return to_timestamp(value) + return value + + @model_validator(mode="after") + def _fallback_chain_id(self): + if self.chain_id is None and self.message_chain_id: + self.chain_id = self.message_chain_id + return self + + +class MessageDetail(ResponseModel): + id: str + conversation_id: str + inputs: dict[str, JSONValue] + query: str + message: JSONValue + message_tokens: int + answer: str + answer_tokens: int + provider_response_latency: float + from_source: str + from_end_user_id: str | None = None + from_account_id: str | None = None + feedbacks: list[Feedback] + workflow_run_id: str | None = None + annotation: Annotation | None = None + annotation_hit_history: AnnotationHitHistory | None = None + created_at: int | None = None + agent_thoughts: list[AgentThought] + message_files: list[MessageFile] + metadata: JSONValue + status: str + error: str | None = None + parent_message_id: str | None = None + + @field_validator("inputs", mode="before") + @classmethod + def _normalize_inputs(cls, value: JSONValue) -> JSONValue: + return format_files_contained(value) + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return to_timestamp(value) + return value + + +class FeedbackStat(ResponseModel): + like: int + dislike: int + + +class StatusCount(ResponseModel): + success: int + failed: int + partial_success: int + + +class ModelConfig(ResponseModel): + opening_statement: str | None = None + suggested_questions: JSONValue | None = None + model: JSONValue | None = None + user_input_form: JSONValue | None = None + pre_prompt: str | None = None + agent_mode: JSONValue | None = None + + +class SimpleModelConfig(ResponseModel): + model: JSONValue | None = None + pre_prompt: str | None = None + + +class SimpleMessageDetail(ResponseModel): + inputs: dict[str, JSONValue] + query: str + message: str + answer: str + + @field_validator("inputs", mode="before") + @classmethod + def _normalize_inputs(cls, value: JSONValue) -> JSONValue: + return format_files_contained(value) + + +class Conversation(ResponseModel): + id: str + status: str + from_source: str + from_end_user_id: str | None = None + from_end_user_session_id: str | None = None + from_account_id: str | None = None + from_account_name: str | None = None + read_at: int | None = None + created_at: int | None = None + updated_at: int | None = None + annotation: Annotation | None = None + model_config_: SimpleModelConfig | None = Field(default=None, alias="model_config") + user_feedback_stats: FeedbackStat | None = None + admin_feedback_stats: FeedbackStat | None = None + message: SimpleMessageDetail | None = None + + +class ConversationPagination(ResponseModel): + page: int + limit: int + total: int + has_more: bool + data: list[Conversation] + + +class ConversationMessageDetail(ResponseModel): + id: str + status: str + from_source: str + from_end_user_id: str | None = None + from_account_id: str | None = None + created_at: int | None = None + model_config_: ModelConfig | None = Field(default=None, alias="model_config") + message: MessageDetail | None = None + + +class ConversationWithSummary(ResponseModel): + id: str + status: str + from_source: str + from_end_user_id: str | None = None + from_end_user_session_id: str | None = None + from_account_id: str | None = None + from_account_name: str | None = None + name: str + summary: str + read_at: int | None = None + created_at: int | None = None + updated_at: int | None = None + annotated: bool + model_config_: SimpleModelConfig | None = Field(default=None, alias="model_config") + message_count: int + user_feedback_stats: FeedbackStat | None = None + admin_feedback_stats: FeedbackStat | None = None + status_count: StatusCount | None = None + + +class ConversationWithSummaryPagination(ResponseModel): + page: int + limit: int + total: int + has_more: bool + data: list[ConversationWithSummary] + + +class ConversationDetail(ResponseModel): + id: str + status: str + from_source: str + from_end_user_id: str | None = None + from_account_id: str | None = None + created_at: int | None = None + updated_at: int | None = None + annotated: bool + introduction: str | None = None + model_config_: ModelConfig | None = Field(default=None, alias="model_config") + message_count: int + user_feedback_stats: FeedbackStat | None = None + admin_feedback_stats: FeedbackStat | None = None + + +def to_timestamp(value: datetime | None) -> int | None: + if value is None: + return None + return int(value.timestamp()) + + +def format_files_contained(value: JSONValue) -> JSONValue: + if isinstance(value, File): + return value.model_dump() + if isinstance(value, dict): + return {k: format_files_contained(v) for k, v in value.items()} + if isinstance(value, list): + return [format_files_contained(v) for v in value] + return value + + +def message_text(value: JSONValue) -> str: + if isinstance(value, list) and value: + first = value[0] + if isinstance(first, dict): + text = first.get("text") + if isinstance(text, str): + return text + return "" + + +def extract_model_config(value: object | None) -> dict[str, JSONValue]: + if value is None: + return {} + if isinstance(value, dict): + return value + if hasattr(value, "to_dict"): + return value.to_dict() + return {} diff --git a/api/fields/message_fields.py b/api/fields/message_fields.py index 151ff6f826..2bba198fa8 100644 --- a/api/fields/message_fields.py +++ b/api/fields/message_fields.py @@ -1,77 +1,137 @@ -from flask_restx import Namespace, fields +from __future__ import annotations -from fields.conversation_fields import message_file_fields -from libs.helper import TimestampField +from datetime import datetime +from typing import TypeAlias -from .raws import FilesContainedField +from pydantic import BaseModel, ConfigDict, Field, field_validator -feedback_fields = { - "rating": fields.String, -} +from core.file import File +from fields.conversation_fields import AgentThought, JSONValue, MessageFile + +JSONValueType: TypeAlias = JSONValue -def build_feedback_model(api_or_ns: Namespace): - """Build the feedback model for the API or Namespace.""" - return api_or_ns.model("Feedback", feedback_fields) +class ResponseModel(BaseModel): + model_config = ConfigDict(from_attributes=True, extra="ignore") -agent_thought_fields = { - "id": fields.String, - "chain_id": fields.String, - "message_id": fields.String, - "position": fields.Integer, - "thought": fields.String, - "tool": fields.String, - "tool_labels": fields.Raw, - "tool_input": fields.String, - "created_at": TimestampField, - "observation": fields.String, - "files": fields.List(fields.String), -} +class SimpleFeedback(ResponseModel): + rating: str | None = None -def build_agent_thought_model(api_or_ns: Namespace): - """Build the agent thought model for the API or Namespace.""" - return api_or_ns.model("AgentThought", agent_thought_fields) +class RetrieverResource(ResponseModel): + id: str + message_id: str + position: int + dataset_id: str | None = None + dataset_name: str | None = None + document_id: str | None = None + document_name: str | None = None + data_source_type: str | None = None + segment_id: str | None = None + score: float | None = None + hit_count: int | None = None + word_count: int | None = None + segment_position: int | None = None + index_node_hash: str | None = None + content: str | None = None + created_at: int | None = None + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return to_timestamp(value) + return value -retriever_resource_fields = { - "id": fields.String, - "message_id": fields.String, - "position": fields.Integer, - "dataset_id": fields.String, - "dataset_name": fields.String, - "document_id": fields.String, - "document_name": fields.String, - "data_source_type": fields.String, - "segment_id": fields.String, - "score": fields.Float, - "hit_count": fields.Integer, - "word_count": fields.Integer, - "segment_position": fields.Integer, - "index_node_hash": fields.String, - "content": fields.String, - "created_at": TimestampField, -} +class MessageListItem(ResponseModel): + id: str + conversation_id: str + parent_message_id: str | None = None + inputs: dict[str, JSONValueType] + query: str + answer: str = Field(validation_alias="re_sign_file_url_answer") + feedback: SimpleFeedback | None = Field(default=None, validation_alias="user_feedback") + retriever_resources: list[RetrieverResource] + created_at: int | None = None + agent_thoughts: list[AgentThought] + message_files: list[MessageFile] + status: str + error: str | None = None -message_fields = { - "id": fields.String, - "conversation_id": fields.String, - "parent_message_id": fields.String, - "inputs": FilesContainedField, - "query": fields.String, - "answer": fields.String(attribute="re_sign_file_url_answer"), - "feedback": fields.Nested(feedback_fields, attribute="user_feedback", allow_null=True), - "retriever_resources": fields.List(fields.Nested(retriever_resource_fields)), - "created_at": TimestampField, - "agent_thoughts": fields.List(fields.Nested(agent_thought_fields)), - "message_files": fields.List(fields.Nested(message_file_fields)), - "status": fields.String, - "error": fields.String, -} + @field_validator("inputs", mode="before") + @classmethod + def _normalize_inputs(cls, value: JSONValueType) -> JSONValueType: + return format_files_contained(value) -message_infinite_scroll_pagination_fields = { - "limit": fields.Integer, - "has_more": fields.Boolean, - "data": fields.List(fields.Nested(message_fields)), -} + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return to_timestamp(value) + return value + + +class WebMessageListItem(MessageListItem): + metadata: JSONValueType | None = Field(default=None, validation_alias="message_metadata_dict") + + +class MessageInfiniteScrollPagination(ResponseModel): + limit: int + has_more: bool + data: list[MessageListItem] + + +class WebMessageInfiniteScrollPagination(ResponseModel): + limit: int + has_more: bool + data: list[WebMessageListItem] + + +class SavedMessageItem(ResponseModel): + id: str + inputs: dict[str, JSONValueType] + query: str + answer: str + message_files: list[MessageFile] + feedback: SimpleFeedback | None = Field(default=None, validation_alias="user_feedback") + created_at: int | None = None + + @field_validator("inputs", mode="before") + @classmethod + def _normalize_inputs(cls, value: JSONValueType) -> JSONValueType: + return format_files_contained(value) + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_created_at(cls, value: datetime | int | None) -> int | None: + if isinstance(value, datetime): + return to_timestamp(value) + return value + + +class SavedMessageInfiniteScrollPagination(ResponseModel): + limit: int + has_more: bool + data: list[SavedMessageItem] + + +class SuggestedQuestionsResponse(ResponseModel): + data: list[str] + + +def to_timestamp(value: datetime | None) -> int | None: + if value is None: + return None + return int(value.timestamp()) + + +def format_files_contained(value: JSONValueType) -> JSONValueType: + if isinstance(value, File): + return value.model_dump() + if isinstance(value, dict): + return {k: format_files_contained(v) for k, v in value.items()} + if isinstance(value, list): + return [format_files_contained(v) for v in value] + return value diff --git a/api/tests/unit_tests/controllers/web/test_message_list.py b/api/tests/unit_tests/controllers/web/test_message_list.py new file mode 100644 index 0000000000..2835f7ffbf --- /dev/null +++ b/api/tests/unit_tests/controllers/web/test_message_list.py @@ -0,0 +1,174 @@ +"""Unit tests for controllers.web.message message list mapping.""" + +from __future__ import annotations + +import builtins +from datetime import datetime +from types import ModuleType, SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from flask import Flask +from flask.views import MethodView + +# Ensure flask_restx.api finds MethodView during import. +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +def _load_controller_module(): + """Import controllers.web.message using a stub package.""" + + import importlib + import importlib.util + import sys + + parent_module_name = "controllers.web" + module_name = f"{parent_module_name}.message" + + if parent_module_name not in sys.modules: + from flask_restx import Namespace + + stub = ModuleType(parent_module_name) + stub.__file__ = "controllers/web/__init__.py" + stub.__path__ = ["controllers/web"] + stub.__package__ = "controllers" + stub.__spec__ = importlib.util.spec_from_loader(parent_module_name, loader=None, is_package=True) + stub.web_ns = Namespace("web", description="Web API", path="/") + sys.modules[parent_module_name] = stub + + wraps_module_name = f"{parent_module_name}.wraps" + if wraps_module_name not in sys.modules: + wraps_stub = ModuleType(wraps_module_name) + + class WebApiResource: + pass + + wraps_stub.WebApiResource = WebApiResource + sys.modules[wraps_module_name] = wraps_stub + + return importlib.import_module(module_name) + + +message_module = _load_controller_module() +MessageListApi = message_module.MessageListApi + + +@pytest.fixture +def app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + return app + + +def test_message_list_mapping(app: Flask) -> None: + conversation_id = str(uuid4()) + message_id = str(uuid4()) + + created_at = datetime(2024, 1, 1, 12, 0, 0) + resource_created_at = datetime(2024, 1, 1, 13, 0, 0) + thought_created_at = datetime(2024, 1, 1, 14, 0, 0) + + retriever_resource_obj = SimpleNamespace( + id="res-obj", + message_id=message_id, + position=2, + dataset_id="ds-1", + dataset_name="dataset", + document_id="doc-1", + document_name="document", + data_source_type="file", + segment_id="seg-1", + score=0.9, + hit_count=1, + word_count=10, + segment_position=0, + index_node_hash="hash", + content="content", + created_at=resource_created_at, + ) + + agent_thought = SimpleNamespace( + id="thought-1", + chain_id=None, + message_chain_id="chain-1", + message_id=message_id, + position=1, + thought="thinking", + tool="tool", + tool_labels={"label": "value"}, + tool_input="{}", + created_at=thought_created_at, + observation="observed", + files=["file-a"], + ) + + message_file_obj = SimpleNamespace( + id="file-obj", + filename="b.txt", + type="file", + url=None, + mime_type=None, + size=None, + transfer_method="local", + belongs_to=None, + upload_file_id=None, + ) + + message = SimpleNamespace( + id=message_id, + conversation_id=conversation_id, + parent_message_id=None, + inputs={"foo": "bar"}, + query="hello", + re_sign_file_url_answer="answer", + user_feedback=SimpleNamespace(rating="like"), + retriever_resources=[ + {"id": "res-dict", "message_id": message_id, "position": 1}, + retriever_resource_obj, + ], + created_at=created_at, + agent_thoughts=[agent_thought], + message_files=[ + {"id": "file-dict", "filename": "a.txt", "type": "file", "transfer_method": "local"}, + message_file_obj, + ], + status="success", + error=None, + message_metadata_dict={"meta": "value"}, + ) + + pagination = SimpleNamespace(limit=20, has_more=False, data=[message]) + app_model = SimpleNamespace(mode="chat") + end_user = SimpleNamespace() + + with ( + patch.object(message_module.MessageService, "pagination_by_first_id", return_value=pagination) as mock_page, + app.test_request_context(f"/messages?conversation_id={conversation_id}&limit=20"), + ): + response = MessageListApi().get(app_model, end_user) + + mock_page.assert_called_once_with(app_model, end_user, conversation_id, None, 20) + assert response["limit"] == 20 + assert response["has_more"] is False + assert len(response["data"]) == 1 + + item = response["data"][0] + assert item["id"] == message_id + assert item["conversation_id"] == conversation_id + assert item["inputs"] == {"foo": "bar"} + assert item["answer"] == "answer" + assert item["feedback"]["rating"] == "like" + assert item["metadata"] == {"meta": "value"} + assert item["created_at"] == int(created_at.timestamp()) + + assert item["retriever_resources"][0]["id"] == "res-dict" + assert item["retriever_resources"][1]["id"] == "res-obj" + assert item["retriever_resources"][1]["created_at"] == int(resource_created_at.timestamp()) + + assert item["agent_thoughts"][0]["chain_id"] == "chain-1" + assert item["agent_thoughts"][0]["created_at"] == int(thought_created_at.timestamp()) + + assert item["message_files"][0]["id"] == "file-dict" + assert item["message_files"][1]["id"] == "file-obj" From 83648feedfb20c463525281f3e740f6fc1eedfd9 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Sun, 4 Jan 2026 17:22:12 +0800 Subject: [PATCH 09/33] chore: upgrade fickling to 0.1.6 (#30495) --- api/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index 4ccd229eec..fa032fa8d4 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1953,14 +1953,14 @@ wheels = [ [[package]] name = "fickling" -version = "0.1.5" +version = "0.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "stdlib-list" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/94/0d0ce455952c036cfee235637f786c1d1d07d1b90f6a4dfb50e0eff929d6/fickling-0.1.5.tar.gz", hash = "sha256:92f9b49e717fa8dbc198b4b7b685587adb652d85aa9ede8131b3e44494efca05", size = 282462, upload-time = "2025-11-18T05:04:30.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/ab/7571453f9365c17c047b5a7b7e82692a7f6be51203f295030886758fd57a/fickling-0.1.6.tar.gz", hash = "sha256:03cb5d7bd09f9169c7583d2079fad4b3b88b25f865ed0049172e5cb68582311d", size = 284033, upload-time = "2025-12-15T18:14:58.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/a7/d25912b2e3a5b0a37e6f460050bbc396042b5906a6563a1962c484abc3c6/fickling-0.1.5-py3-none-any.whl", hash = "sha256:6aed7270bfa276e188b0abe043a27b3a042129d28ec1fa6ff389bdcc5ad178bb", size = 46240, upload-time = "2025-11-18T05:04:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/76/99/cc04258dda421bc612cdfe4be8c253f45b922f1c7f268b5a0b9962d9cd12/fickling-0.1.6-py3-none-any.whl", hash = "sha256:465d0069548bfc731bdd75a583cb4cf5a4b2666739c0f76287807d724b147ed3", size = 47922, upload-time = "2025-12-15T18:14:57.526Z" }, ] [[package]] From 47b8e979e004bc1e895e72550ce072df6d95a20f Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Sun, 4 Jan 2026 18:04:49 +0800 Subject: [PATCH 10/33] test: add unit tests for RagPipeline components (#30429) Co-authored-by: CodingOnStar --- .../components/chunk-card-list/index.spec.tsx | 1164 ++++++++ .../rag-pipeline/components/index.spec.tsx | 1390 +++++++++ .../components/panel/index.spec.tsx | 971 +++++++ .../input-field/editor/form/index.spec.tsx | 1744 +++++++++++ .../panel/input-field/editor/index.spec.tsx | 1455 ++++++++++ .../panel/input-field/editor/index.tsx | 1 + .../input-field/field-list/index.spec.tsx | 2557 +++++++++++++++++ .../panel/input-field/field-list/index.tsx | 1 + .../panel/input-field/index.spec.tsx | 1118 +++++++ .../panel/input-field/preview/index.spec.tsx | 1412 +++++++++ .../components/panel/test-run/index.spec.tsx | 937 ++++++ .../preparation/actions/index.spec.tsx | 549 ++++ .../data-source-options/index.spec.tsx | 1829 ++++++++++++ .../document-processing/index.spec.tsx | 1712 +++++++++++ .../panel/test-run/preparation/index.spec.tsx | 2221 ++++++++++++++ .../panel/test-run/result/index.spec.tsx | 1299 +++++++++ .../result/result-preview/index.spec.tsx | 1175 ++++++++ .../panel/test-run/result/tabs/index.spec.tsx | 1352 +++++++++ .../publish-as-knowledge-pipeline-modal.tsx | 6 +- .../rag-pipeline-header/index.spec.tsx | 1263 ++++++++ .../publisher/index.spec.tsx | 1348 +++++++++ 21 files changed, 25503 insertions(+), 1 deletion(-) create mode 100644 web/app/components/rag-pipeline/components/chunk-card-list/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/editor/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/field-list/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/preview/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/result/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/result/tabs/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/rag-pipeline-header/index.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.spec.tsx diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/index.spec.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/index.spec.tsx new file mode 100644 index 0000000000..e665cf134e --- /dev/null +++ b/web/app/components/rag-pipeline/components/chunk-card-list/index.spec.tsx @@ -0,0 +1,1164 @@ +import type { GeneralChunks, ParentChildChunk, ParentChildChunks, QAChunk, QAChunks } from './types' +import { render, screen } from '@testing-library/react' +import { ChunkingMode } from '@/models/datasets' +import ChunkCard from './chunk-card' +import { ChunkCardList } from './index' +import QAItem from './q-a-item' +import { QAItemType } from './types' + +// ============================================================================= +// Test Data Factories +// ============================================================================= + +const createGeneralChunks = (overrides: string[] = []): GeneralChunks => { + if (overrides.length > 0) + return overrides + return [ + 'This is the first chunk of text content.', + 'This is the second chunk with different content.', + 'Third chunk here with more text.', + ] +} + +const createParentChildChunk = (overrides: Partial = {}): ParentChildChunk => ({ + child_contents: ['Child content 1', 'Child content 2'], + parent_content: 'This is the parent content that contains the children.', + parent_mode: 'paragraph', + ...overrides, +}) + +const createParentChildChunks = (overrides: Partial = {}): ParentChildChunks => ({ + parent_child_chunks: [ + createParentChildChunk(), + createParentChildChunk({ + child_contents: ['Another child 1', 'Another child 2', 'Another child 3'], + parent_content: 'Another parent content here.', + }), + ], + parent_mode: 'paragraph', + ...overrides, +}) + +const createQAChunk = (overrides: Partial = {}): QAChunk => ({ + question: 'What is the answer to life?', + answer: 'The answer is 42.', + ...overrides, +}) + +const createQAChunks = (overrides: Partial = {}): QAChunks => ({ + qa_chunks: [ + createQAChunk(), + createQAChunk({ + question: 'How does this work?', + answer: 'It works by processing data.', + }), + ], + ...overrides, +}) + +// ============================================================================= +// QAItem Component Tests +// ============================================================================= + +describe('QAItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for basic rendering of QAItem component + describe('Rendering', () => { + it('should render question type with Q prefix', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('What is this?')).toBeInTheDocument() + }) + + it('should render answer type with A prefix', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.getByText('This is the answer.')).toBeInTheDocument() + }) + }) + + // Tests for different prop variations + describe('Props', () => { + it('should render with empty text', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Q')).toBeInTheDocument() + }) + + it('should render with long text content', () => { + // Arrange + const longText = 'A'.repeat(1000) + + // Act + render() + + // Assert + expect(screen.getByText(longText)).toBeInTheDocument() + }) + + it('should render with special characters in text', () => { + // Arrange + const specialText = ' & "quotes" \'apostrophe\'' + + // Act + render() + + // Assert + expect(screen.getByText(specialText)).toBeInTheDocument() + }) + }) + + // Tests for memoization behavior + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + // Arrange & Act + const { rerender } = render() + + // Assert - component should render consistently + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('Test')).toBeInTheDocument() + + // Rerender with same props - should not cause issues + rerender() + expect(screen.getByText('Q')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================= +// ChunkCard Component Tests +// ============================================================================= + +describe('ChunkCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for basic rendering with different chunk types + describe('Rendering', () => { + it('should render text chunk type correctly', () => { + // Arrange & Act + render( + , + ) + + // Assert + expect(screen.getByText('This is plain text content.')).toBeInTheDocument() + expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() + }) + + it('should render QA chunk type with question and answer', () => { + // Arrange + const qaContent: QAChunk = { + question: 'What is React?', + answer: 'React is a JavaScript library.', + } + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('What is React?')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.getByText('React is a JavaScript library.')).toBeInTheDocument() + }) + + it('should render parent-child chunk type with child contents', () => { + // Arrange + const childContents = ['Child 1 content', 'Child 2 content'] + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Child 1 content')).toBeInTheDocument() + expect(screen.getByText('Child 2 content')).toBeInTheDocument() + expect(screen.getByText('C-1')).toBeInTheDocument() + expect(screen.getByText('C-2')).toBeInTheDocument() + }) + }) + + // Tests for parent mode variations + describe('Parent Mode Variations', () => { + it('should show Parent-Chunk label prefix for paragraph mode', () => { + // Arrange & Act + render( + , + ) + + // Assert + expect(screen.getByText(/Parent-Chunk-01/)).toBeInTheDocument() + }) + + it('should hide segment index tag for full-doc mode', () => { + // Arrange & Act + render( + , + ) + + // Assert - should not show Chunk or Parent-Chunk label + expect(screen.queryByText(/Chunk/)).not.toBeInTheDocument() + expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument() + }) + + it('should show Chunk label prefix for text mode', () => { + // Arrange & Act + render( + , + ) + + // Assert + expect(screen.getByText(/Chunk-05/)).toBeInTheDocument() + }) + }) + + // Tests for word count display + describe('Word Count Display', () => { + it('should display formatted word count', () => { + // Arrange & Act + render( + , + ) + + // Assert - formatNumber(1234) returns '1,234' + expect(screen.getByText(/1,234/)).toBeInTheDocument() + }) + + it('should display word count with character translation key', () => { + // Arrange & Act + render( + , + ) + + // Assert - translation key is returned as-is by mock + expect(screen.getByText(/100\s+(?:\S.*)?characters/)).toBeInTheDocument() + }) + + it('should not display word count info for full-doc mode', () => { + // Arrange & Act + render( + , + ) + + // Assert - the header with word count should be hidden + expect(screen.queryByText(/500/)).not.toBeInTheDocument() + }) + }) + + // Tests for position ID variations + describe('Position ID', () => { + it('should handle numeric position ID', () => { + // Arrange & Act + render( + , + ) + + // Assert + expect(screen.getByText(/Chunk-42/)).toBeInTheDocument() + }) + + it('should handle string position ID', () => { + // Arrange & Act + render( + , + ) + + // Assert + expect(screen.getByText(/Chunk-99/)).toBeInTheDocument() + }) + + it('should pad single digit position ID', () => { + // Arrange & Act + render( + , + ) + + // Assert + expect(screen.getByText(/Chunk-03/)).toBeInTheDocument() + }) + }) + + // Tests for memoization dependencies + describe('Memoization', () => { + it('should update isFullDoc memo when parentMode changes', () => { + // Arrange + const { rerender } = render( + , + ) + + // Assert - paragraph mode shows label + expect(screen.getByText(/Parent-Chunk/)).toBeInTheDocument() + + // Act - change to full-doc + rerender( + , + ) + + // Assert - full-doc mode hides label + expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument() + }) + + it('should update contentElement memo when content changes', () => { + // Arrange + const { rerender } = render( + , + ) + + // Assert + expect(screen.getByText('Initial content')).toBeInTheDocument() + + // Act + rerender( + , + ) + + // Assert + expect(screen.getByText('Updated content')).toBeInTheDocument() + expect(screen.queryByText('Initial content')).not.toBeInTheDocument() + }) + + it('should update contentElement memo when chunkType changes', () => { + // Arrange + const { rerender } = render( + , + ) + + // Assert + expect(screen.getByText('Text content')).toBeInTheDocument() + + // Act - change to QA type + const qaContent: QAChunk = { question: 'Q?', answer: 'A.' } + rerender( + , + ) + + // Assert + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('Q?')).toBeInTheDocument() + }) + }) + + // Tests for edge cases + describe('Edge Cases', () => { + it('should handle empty child contents array', () => { + // Arrange & Act + render( + , + ) + + // Assert - should render without errors + expect(screen.getByText(/Parent-Chunk-01/)).toBeInTheDocument() + }) + + it('should handle QA chunk with empty strings', () => { + // Arrange + const emptyQA: QAChunk = { question: '', answer: '' } + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + }) + + it('should handle very long content', () => { + // Arrange + const longContent = 'A'.repeat(10000) + + // Act + render( + , + ) + + // Assert + expect(screen.getByText(longContent)).toBeInTheDocument() + }) + + it('should handle zero word count', () => { + // Arrange & Act + render( + , + ) + + // Assert - formatNumber returns falsy for 0, so it shows 0 + expect(screen.getByText(/0\s+(?:\S.*)?characters/)).toBeInTheDocument() + }) + }) +}) + +// ============================================================================= +// ChunkCardList Component Tests +// ============================================================================= + +describe('ChunkCardList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for rendering with different chunk types + describe('Rendering', () => { + it('should render text chunks correctly', () => { + // Arrange + const chunks = createGeneralChunks() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText(chunks[0])).toBeInTheDocument() + expect(screen.getByText(chunks[1])).toBeInTheDocument() + expect(screen.getByText(chunks[2])).toBeInTheDocument() + }) + + it('should render parent-child chunks correctly', () => { + // Arrange + const chunks = createParentChildChunks() + + // Act + render( + , + ) + + // Assert - should render child contents from parent-child chunks + expect(screen.getByText('Child content 1')).toBeInTheDocument() + expect(screen.getByText('Child content 2')).toBeInTheDocument() + expect(screen.getByText('Another child 1')).toBeInTheDocument() + }) + + it('should render QA chunks correctly', () => { + // Arrange + const chunks = createQAChunks() + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('What is the answer to life?')).toBeInTheDocument() + expect(screen.getByText('The answer is 42.')).toBeInTheDocument() + expect(screen.getByText('How does this work?')).toBeInTheDocument() + expect(screen.getByText('It works by processing data.')).toBeInTheDocument() + }) + }) + + // Tests for chunkList memoization + describe('Memoization - chunkList', () => { + it('should extract chunks from GeneralChunks for text mode', () => { + // Arrange + const chunks: GeneralChunks = ['Chunk 1', 'Chunk 2'] + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Chunk 1')).toBeInTheDocument() + expect(screen.getByText('Chunk 2')).toBeInTheDocument() + }) + + it('should extract parent_child_chunks from ParentChildChunks for parentChild mode', () => { + // Arrange + const chunks = createParentChildChunks({ + parent_child_chunks: [ + createParentChildChunk({ child_contents: ['Specific child'] }), + ], + }) + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Specific child')).toBeInTheDocument() + }) + + it('should extract qa_chunks from QAChunks for qa mode', () => { + // Arrange + const chunks: QAChunks = { + qa_chunks: [ + { question: 'Specific Q', answer: 'Specific A' }, + ], + } + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Specific Q')).toBeInTheDocument() + expect(screen.getByText('Specific A')).toBeInTheDocument() + }) + + it('should update chunkList when chunkInfo changes', () => { + // Arrange + const initialChunks = createGeneralChunks(['Initial chunk']) + + const { rerender } = render( + , + ) + + // Assert initial state + expect(screen.getByText('Initial chunk')).toBeInTheDocument() + + // Act - update chunks + const updatedChunks = createGeneralChunks(['Updated chunk']) + rerender( + , + ) + + // Assert updated state + expect(screen.getByText('Updated chunk')).toBeInTheDocument() + expect(screen.queryByText('Initial chunk')).not.toBeInTheDocument() + }) + }) + + // Tests for getWordCount function + describe('Word Count Calculation', () => { + it('should calculate word count for text chunks using string length', () => { + // Arrange - "Hello" has 5 characters + const chunks = createGeneralChunks(['Hello']) + + // Act + render( + , + ) + + // Assert - word count should be 5 (string length) + expect(screen.getByText(/5\s+(?:\S.*)?characters/)).toBeInTheDocument() + }) + + it('should calculate word count for parent-child chunks using parent_content length', () => { + // Arrange - parent_content length determines word count + const chunks = createParentChildChunks({ + parent_child_chunks: [ + createParentChildChunk({ + parent_content: 'Parent', // 6 characters + child_contents: ['Child'], + }), + ], + }) + + // Act + render( + , + ) + + // Assert - word count should be 6 (parent_content length) + expect(screen.getByText(/6\s+(?:\S.*)?characters/)).toBeInTheDocument() + }) + + it('should calculate word count for QA chunks using question + answer length', () => { + // Arrange - "Hi" (2) + "Bye" (3) = 5 + const chunks: QAChunks = { + qa_chunks: [ + { question: 'Hi', answer: 'Bye' }, + ], + } + + // Act + render( + , + ) + + // Assert - word count should be 5 (question.length + answer.length) + expect(screen.getByText(/5\s+(?:\S.*)?characters/)).toBeInTheDocument() + }) + }) + + // Tests for position ID assignment + describe('Position ID', () => { + it('should assign 1-based position IDs to chunks', () => { + // Arrange + const chunks = createGeneralChunks(['First', 'Second', 'Third']) + + // Act + render( + , + ) + + // Assert - position IDs should be 1, 2, 3 + expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() + expect(screen.getByText(/Chunk-02/)).toBeInTheDocument() + expect(screen.getByText(/Chunk-03/)).toBeInTheDocument() + }) + }) + + // Tests for className prop + describe('Custom className', () => { + it('should apply custom className to container', () => { + // Arrange + const chunks = createGeneralChunks(['Test']) + + // Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should merge custom className with default classes', () => { + // Arrange + const chunks = createGeneralChunks(['Test']) + + // Act + const { container } = render( + , + ) + + // Assert - should have both default and custom classes + expect(container.firstChild).toHaveClass('flex') + expect(container.firstChild).toHaveClass('w-full') + expect(container.firstChild).toHaveClass('flex-col') + expect(container.firstChild).toHaveClass('my-custom-class') + }) + + it('should render without className prop', () => { + // Arrange + const chunks = createGeneralChunks(['Test']) + + // Act + const { container } = render( + , + ) + + // Assert - should have default classes + expect(container.firstChild).toHaveClass('flex') + expect(container.firstChild).toHaveClass('w-full') + }) + }) + + // Tests for parentMode prop + describe('Parent Mode', () => { + it('should pass parentMode to ChunkCard for parent-child type', () => { + // Arrange + const chunks = createParentChildChunks() + + // Act + render( + , + ) + + // Assert - paragraph mode shows Parent-Chunk label + expect(screen.getAllByText(/Parent-Chunk/).length).toBeGreaterThan(0) + }) + + it('should handle full-doc parentMode', () => { + // Arrange + const chunks = createParentChildChunks() + + // Act + render( + , + ) + + // Assert - full-doc mode hides chunk labels + expect(screen.queryByText(/Parent-Chunk/)).not.toBeInTheDocument() + expect(screen.queryByText(/Chunk-/)).not.toBeInTheDocument() + }) + + it('should not use parentMode for text type', () => { + // Arrange + const chunks = createGeneralChunks(['Text']) + + // Act + render( + , + ) + + // Assert - should show Chunk label, not affected by parentMode + expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() + }) + }) + + // Tests for edge cases + describe('Edge Cases', () => { + it('should handle empty GeneralChunks array', () => { + // Arrange + const chunks: GeneralChunks = [] + + // Act + const { container } = render( + , + ) + + // Assert - should render empty container + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild?.childNodes.length).toBe(0) + }) + + it('should handle empty ParentChildChunks', () => { + // Arrange + const chunks: ParentChildChunks = { + parent_child_chunks: [], + parent_mode: 'paragraph', + } + + // Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild?.childNodes.length).toBe(0) + }) + + it('should handle empty QAChunks', () => { + // Arrange + const chunks: QAChunks = { + qa_chunks: [], + } + + // Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toBeInTheDocument() + expect(container.firstChild?.childNodes.length).toBe(0) + }) + + it('should handle single item in chunks', () => { + // Arrange + const chunks = createGeneralChunks(['Single chunk']) + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Single chunk')).toBeInTheDocument() + expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() + }) + + it('should handle large number of chunks', () => { + // Arrange + const chunks = Array.from({ length: 100 }, (_, i) => `Chunk number ${i + 1}`) + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('Chunk number 1')).toBeInTheDocument() + expect(screen.getByText('Chunk number 100')).toBeInTheDocument() + expect(screen.getByText(/Chunk-100/)).toBeInTheDocument() + }) + }) + + // Tests for key uniqueness + describe('Key Generation', () => { + it('should generate unique keys for chunks', () => { + // Arrange - chunks with same content + const chunks = createGeneralChunks(['Same content', 'Same content', 'Same content']) + + // Act + const { container } = render( + , + ) + + // Assert - all three should render (keys are based on chunkType-index) + const chunkCards = container.querySelectorAll('.bg-components-panel-bg') + expect(chunkCards.length).toBe(3) + }) + }) +}) + +// ============================================================================= +// Integration Tests +// ============================================================================= + +describe('ChunkCardList Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for complete workflow scenarios + describe('Complete Workflows', () => { + it('should render complete text chunking workflow', () => { + // Arrange + const textChunks = createGeneralChunks([ + 'First paragraph of the document.', + 'Second paragraph with more information.', + 'Final paragraph concluding the content.', + ]) + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('First paragraph of the document.')).toBeInTheDocument() + expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() + // "First paragraph of the document." = 32 characters + expect(screen.getByText(/32\s+(?:\S.*)?characters/)).toBeInTheDocument() + + expect(screen.getByText('Second paragraph with more information.')).toBeInTheDocument() + expect(screen.getByText(/Chunk-02/)).toBeInTheDocument() + + expect(screen.getByText('Final paragraph concluding the content.')).toBeInTheDocument() + expect(screen.getByText(/Chunk-03/)).toBeInTheDocument() + }) + + it('should render complete parent-child chunking workflow', () => { + // Arrange + const parentChildChunks = createParentChildChunks({ + parent_child_chunks: [ + { + parent_content: 'Main section about React components and their lifecycle.', + child_contents: [ + 'React components are building blocks.', + 'Lifecycle methods control component behavior.', + ], + parent_mode: 'paragraph', + }, + ], + }) + + // Act + render( + , + ) + + // Assert + expect(screen.getByText('React components are building blocks.')).toBeInTheDocument() + expect(screen.getByText('Lifecycle methods control component behavior.')).toBeInTheDocument() + expect(screen.getByText('C-1')).toBeInTheDocument() + expect(screen.getByText('C-2')).toBeInTheDocument() + expect(screen.getByText(/Parent-Chunk-01/)).toBeInTheDocument() + }) + + it('should render complete QA chunking workflow', () => { + // Arrange + const qaChunks = createQAChunks({ + qa_chunks: [ + { + question: 'What is Dify?', + answer: 'Dify is an open-source LLM application development platform.', + }, + { + question: 'How do I get started?', + answer: 'You can start by installing the platform using Docker.', + }, + ], + }) + + // Act + render( + , + ) + + // Assert + const qLabels = screen.getAllByText('Q') + const aLabels = screen.getAllByText('A') + expect(qLabels.length).toBe(2) + expect(aLabels.length).toBe(2) + + expect(screen.getByText('What is Dify?')).toBeInTheDocument() + expect(screen.getByText('Dify is an open-source LLM application development platform.')).toBeInTheDocument() + expect(screen.getByText('How do I get started?')).toBeInTheDocument() + expect(screen.getByText('You can start by installing the platform using Docker.')).toBeInTheDocument() + }) + }) + + // Tests for type switching scenarios + describe('Type Switching', () => { + it('should handle switching from text to QA type', () => { + // Arrange + const textChunks = createGeneralChunks(['Text content']) + const qaChunks = createQAChunks() + + const { rerender } = render( + , + ) + + // Assert initial text state + expect(screen.getByText('Text content')).toBeInTheDocument() + + // Act - switch to QA + rerender( + , + ) + + // Assert QA state + expect(screen.queryByText('Text content')).not.toBeInTheDocument() + expect(screen.getByText('What is the answer to life?')).toBeInTheDocument() + }) + + it('should handle switching from text to parent-child type', () => { + // Arrange + const textChunks = createGeneralChunks(['Simple text']) + const parentChildChunks = createParentChildChunks() + + const { rerender } = render( + , + ) + + // Assert initial state + expect(screen.getByText('Simple text')).toBeInTheDocument() + expect(screen.getByText(/Chunk-01/)).toBeInTheDocument() + + // Act - switch to parent-child + rerender( + , + ) + + // Assert parent-child state + expect(screen.queryByText('Simple text')).not.toBeInTheDocument() + // Multiple Parent-Chunk elements exist, so use getAllByText + expect(screen.getAllByText(/Parent-Chunk/).length).toBeGreaterThan(0) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/index.spec.tsx b/web/app/components/rag-pipeline/components/index.spec.tsx new file mode 100644 index 0000000000..3f6b0dccc2 --- /dev/null +++ b/web/app/components/rag-pipeline/components/index.spec.tsx @@ -0,0 +1,1390 @@ +import type { PropsWithChildren } from 'react' +import type { EnvironmentVariable } from '@/app/components/workflow/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' + +// ============================================================================ +// Import Components After Mocks Setup +// ============================================================================ + +import Conversion from './conversion' +import RagPipelinePanel from './panel' +import PublishAsKnowledgePipelineModal from './publish-as-knowledge-pipeline-modal' +import PublishToast from './publish-toast' +import RagPipelineChildren from './rag-pipeline-children' +import PipelineScreenShot from './screenshot' + +// ============================================================================ +// Mock External Dependencies - All vi.mock calls must come before any imports +// ============================================================================ + +// Mock next/navigation +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useParams: () => ({ datasetId: 'test-dataset-id' }), + useRouter: () => ({ push: mockPush }), +})) + +// Mock next/image +vi.mock('next/image', () => ({ + default: ({ src, alt, width, height }: { src: string, alt: string, width: number, height: number }) => ( + // eslint-disable-next-line next/no-img-element + {alt} + ), +})) + +// Mock next/dynamic +vi.mock('next/dynamic', () => ({ + default: (importFn: () => Promise<{ default: React.ComponentType }>, options?: { ssr?: boolean }) => { + const DynamicComponent = ({ children, ...props }: PropsWithChildren) => { + return
{children}
+ } + DynamicComponent.displayName = 'DynamicComponent' + return DynamicComponent + }, +})) + +// Mock workflow store - using controllable state +let mockShowImportDSLModal = false +const mockSetShowImportDSLModal = vi.fn((value: boolean) => { + mockShowImportDSLModal = value +}) +vi.mock('@/app/components/workflow/store', () => { + const mockSetShowInputFieldPanel = vi.fn() + const mockSetShowEnvPanel = vi.fn() + const mockSetShowDebugAndPreviewPanel = vi.fn() + const mockSetIsPreparingDataSource = vi.fn() + const mockSetPublishedAt = vi.fn() + const mockSetRagPipelineVariables = vi.fn() + const mockSetEnvironmentVariables = vi.fn() + + return { + useStore: (selector: (state: Record) => unknown) => { + const storeState = { + pipelineId: 'test-pipeline-id', + showDebugAndPreviewPanel: false, + showGlobalVariablePanel: false, + showInputFieldPanel: false, + showInputFieldPreviewPanel: false, + inputFieldEditPanelProps: null as null | object, + historyWorkflowData: null as null | object, + publishedAt: 0, + draftUpdatedAt: Date.now(), + knowledgeName: 'Test Knowledge', + knowledgeIcon: { + icon_type: 'emoji' as const, + icon: '📚', + icon_background: '#FFFFFF', + icon_url: '', + }, + showImportDSLModal: mockShowImportDSLModal, + setShowInputFieldPanel: mockSetShowInputFieldPanel, + setShowEnvPanel: mockSetShowEnvPanel, + setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel, + setIsPreparingDataSource: mockSetIsPreparingDataSource, + setPublishedAt: mockSetPublishedAt, + setRagPipelineVariables: mockSetRagPipelineVariables, + setEnvironmentVariables: mockSetEnvironmentVariables, + setShowImportDSLModal: mockSetShowImportDSLModal, + } + return selector(storeState) + }, + useWorkflowStore: () => ({ + getState: () => ({ + pipelineId: 'test-pipeline-id', + setIsPreparingDataSource: mockSetIsPreparingDataSource, + setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel, + setPublishedAt: mockSetPublishedAt, + setRagPipelineVariables: mockSetRagPipelineVariables, + setEnvironmentVariables: mockSetEnvironmentVariables, + }), + }), + } +}) + +// Mock workflow hooks - extract mock functions for assertions using vi.hoisted +const { + mockHandlePaneContextmenuCancel, + mockExportCheck, + mockHandleExportDSL, +} = vi.hoisted(() => ({ + mockHandlePaneContextmenuCancel: vi.fn(), + mockExportCheck: vi.fn(), + mockHandleExportDSL: vi.fn(), +})) +vi.mock('@/app/components/workflow/hooks', () => { + return { + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: vi.fn(), + syncWorkflowDraftWhenPageClose: vi.fn(), + handleSyncWorkflowDraft: vi.fn(), + }), + usePanelInteractions: () => ({ + handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, + }), + useDSL: () => ({ + exportCheck: mockExportCheck, + handleExportDSL: mockHandleExportDSL, + }), + useChecklistBeforePublish: () => ({ + handleCheckBeforePublish: vi.fn().mockResolvedValue(true), + }), + useWorkflowRun: () => ({ + handleStopRun: vi.fn(), + }), + useWorkflowStartRun: () => ({ + handleWorkflowStartRunInWorkflow: vi.fn(), + }), + } +}) + +// Mock rag-pipeline hooks +vi.mock('../hooks', () => ({ + useAvailableNodesMetaData: () => ({}), + useDSL: () => ({ + exportCheck: mockExportCheck, + handleExportDSL: mockHandleExportDSL, + }), + useNodesSyncDraft: () => ({ + doSyncWorkflowDraft: vi.fn(), + syncWorkflowDraftWhenPageClose: vi.fn(), + }), + usePipelineRefreshDraft: () => ({ + handleRefreshWorkflowDraft: vi.fn(), + }), + usePipelineRun: () => ({ + handleBackupDraft: vi.fn(), + handleLoadBackupDraft: vi.fn(), + handleRestoreFromPublishedWorkflow: vi.fn(), + handleRun: vi.fn(), + handleStopRun: vi.fn(), + }), + usePipelineStartRun: () => ({ + handleStartWorkflowRun: vi.fn(), + handleWorkflowStartRunInWorkflow: vi.fn(), + }), + useGetRunAndTraceUrl: () => ({ + getWorkflowRunAndTraceUrl: vi.fn(), + }), +})) + +// Mock rag-pipeline search hook +vi.mock('../hooks/use-rag-pipeline-search', () => ({ + useRagPipelineSearch: vi.fn(), +})) + +// Mock configs-map hook +vi.mock('../hooks/use-configs-map', () => ({ + useConfigsMap: () => ({}), +})) + +// Mock inspect-vars-crud hook +vi.mock('../hooks/use-inspect-vars-crud', () => ({ + useInspectVarsCrud: () => ({ + hasNodeInspectVars: vi.fn(), + hasSetInspectVar: vi.fn(), + fetchInspectVarValue: vi.fn(), + editInspectVarValue: vi.fn(), + renameInspectVarName: vi.fn(), + appendNodeInspectVars: vi.fn(), + deleteInspectVar: vi.fn(), + deleteNodeInspectorVars: vi.fn(), + deleteAllInspectorVars: vi.fn(), + isInspectVarEdited: vi.fn(), + resetToLastRunVar: vi.fn(), + invalidateSysVarValues: vi.fn(), + resetConversationVar: vi.fn(), + invalidateConversationVarValues: vi.fn(), + }), +})) + +// Mock workflow hooks for fetch-workflow-inspect-vars +vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({ + useSetWorkflowVarsWithValue: () => ({ + fetchInspectVars: vi.fn(), + }), +})) + +// Mock service hooks - with controllable convert function +let mockConvertFn = vi.fn() +let mockIsPending = false +vi.mock('@/service/use-pipeline', () => ({ + useConvertDatasetToPipeline: () => ({ + mutateAsync: mockConvertFn, + isPending: mockIsPending, + }), + useImportPipelineDSL: () => ({ + mutateAsync: vi.fn(), + }), + useImportPipelineDSLConfirm: () => ({ + mutateAsync: vi.fn(), + }), + publishedPipelineInfoQueryKeyPrefix: ['pipeline-info'], + useInvalidCustomizedTemplateList: () => vi.fn(), + usePublishAsCustomizedPipeline: () => ({ + mutateAsync: vi.fn(), + }), +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: () => vi.fn(), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + datasetDetailQueryKeyPrefix: ['dataset-detail'], + useInvalidDatasetList: () => vi.fn(), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: vi.fn().mockResolvedValue({ + graph: { nodes: [], edges: [], viewport: {} }, + hash: 'test-hash', + rag_pipeline_variables: [], + }), +})) + +// Mock event emitter context - with controllable subscription +let mockEventSubscriptionCallback: ((v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null +const mockUseSubscription = vi.fn((callback: (v: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => { + mockEventSubscriptionCallback = callback +}) +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: mockUseSubscription, + emit: vi.fn(), + }, + }), +})) + +// Mock toast +vi.mock('@/app/components/base/toast', () => ({ + default: { + notify: vi.fn(), + }, + useToastContext: () => ({ + notify: vi.fn(), + }), + ToastContext: { + Provider: ({ children }: PropsWithChildren) => children, + }, +})) + +// Mock useTheme hook +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ + theme: 'light', + }), +})) + +// Mock basePath +vi.mock('@/utils/var', () => ({ + basePath: '/public', +})) + +// Mock provider context +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => createMockProviderContextValue(), +})) + +// Mock WorkflowWithInnerContext +vi.mock('@/app/components/workflow', () => ({ + WorkflowWithInnerContext: ({ children }: PropsWithChildren) => ( +
{children}
+ ), +})) + +// Mock workflow panel +vi.mock('@/app/components/workflow/panel', () => ({ + default: ({ components }: { components?: { left?: React.ReactNode, right?: React.ReactNode } }) => ( +
+
{components?.left}
+
{components?.right}
+
+ ), +})) + +// Mock PluginDependency +vi.mock('../../workflow/plugin-dependency', () => ({ + default: () =>
, +})) + +// Mock plugin-dependency hooks +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: vi.fn().mockResolvedValue(undefined), + }), +})) + +// Mock DSLExportConfirmModal +vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ + default: ({ envList, onConfirm, onClose }: { envList: EnvironmentVariable[], onConfirm: () => void, onClose: () => void }) => ( +
+ {envList.length} + + +
+ ), +})) + +// Mock workflow constants +vi.mock('@/app/components/workflow/constants', () => ({ + DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', + WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', +})) + +// Mock workflow utils +vi.mock('@/app/components/workflow/utils', () => ({ + initialNodes: vi.fn(nodes => nodes), + initialEdges: vi.fn(edges => edges), + getKeyboardKeyCodeBySystem: (key: string) => key, + getKeyboardKeyNameBySystem: (key: string) => key, +})) + +// Mock Confirm component +vi.mock('@/app/components/base/confirm', () => ({ + default: ({ title, content, isShow, onConfirm, onCancel, isLoading, isDisabled }: { + title: string + content: string + isShow: boolean + onConfirm: () => void + onCancel: () => void + isLoading?: boolean + isDisabled?: boolean + }) => isShow + ? ( +
+
{title}
+
{content}
+ + +
+ ) + : null, +})) + +// Mock Modal component +vi.mock('@/app/components/base/modal', () => ({ + default: ({ children, isShow, onClose, className }: PropsWithChildren<{ + isShow: boolean + onClose: () => void + className?: string + }>) => isShow + ? ( +
e.target === e.currentTarget && onClose()}> + {children} +
+ ) + : null, +})) + +// Mock Input component +vi.mock('@/app/components/base/input', () => ({ + default: ({ value, onChange, placeholder }: { + value: string + onChange: (e: React.ChangeEvent) => void + placeholder?: string + }) => ( + + ), +})) + +// Mock Textarea component +vi.mock('@/app/components/base/textarea', () => ({ + default: ({ value, onChange, placeholder, className }: { + value: string + onChange: (e: React.ChangeEvent) => void + placeholder?: string + className?: string + }) => ( +