From c3037c5491cea34d13f6f13f55bf060d99bcddf7 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Thu, 17 Jul 2025 13:20:31 +0800 Subject: [PATCH 01/17] minor code fix: remove duplicate type check branch (#22536) --- api/core/variables/types.py | 2 -- api/core/workflow/entities/variable_pool.py | 1 - api/tests/unit_tests/core/variables/test_segment.py | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/api/core/variables/types.py b/api/core/variables/types.py index e39237dba5..e79b2410bf 100644 --- a/api/core/variables/types.py +++ b/api/core/variables/types.py @@ -91,8 +91,6 @@ class SegmentType(StrEnum): return SegmentType.OBJECT elif isinstance(value, File): return SegmentType.FILE - elif isinstance(value, str): - return SegmentType.STRING else: return None diff --git a/api/core/workflow/entities/variable_pool.py b/api/core/workflow/entities/variable_pool.py index 646a9d3402..fbb8df6b01 100644 --- a/api/core/workflow/entities/variable_pool.py +++ b/api/core/workflow/entities/variable_pool.py @@ -152,7 +152,6 @@ class VariablePool(BaseModel): self.variable_dictionary[selector[0]] = {} return key, hash_key = self._selector_to_keys(selector) - hash_key = hash(tuple(selector[1:])) self.variable_dictionary[key].pop(hash_key, None) def convert_template(self, template: str, /): diff --git a/api/tests/unit_tests/core/variables/test_segment.py b/api/tests/unit_tests/core/variables/test_segment.py index cdc261fd42..4c8d983d20 100644 --- a/api/tests/unit_tests/core/variables/test_segment.py +++ b/api/tests/unit_tests/core/variables/test_segment.py @@ -376,7 +376,7 @@ class TestSegmentDumpAndLoad: f"get_segment_discriminator failed for serialized form of type {type(variable)}" ) - def test_invlaid_value_for_discriminator(self): + def test_invalid_value_for_discriminator(self): # Test invalid cases assert get_segment_discriminator({"value_type": "invalid"}) is None assert get_segment_discriminator({}) is None From 10e6b11ff66901acb35f2fca5be4dbf554d8b749 Mon Sep 17 00:00:00 2001 From: quicksand Date: Thu, 17 Jul 2025 13:21:17 +0800 Subject: [PATCH 02/17] fix: code node check decimal precision (#22522) --- api/core/workflow/nodes/code/code_node.py | 5 ++- .../workflow/nodes/test_code.py | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/api/core/workflow/nodes/code/code_node.py b/api/core/workflow/nodes/code/code_node.py index 22ed9e2651..1adabf7247 100644 --- a/api/core/workflow/nodes/code/code_node.py +++ b/api/core/workflow/nodes/code/code_node.py @@ -1,4 +1,5 @@ from collections.abc import Mapping, Sequence +from decimal import Decimal from typing import Any, Optional from configs import dify_config @@ -114,8 +115,10 @@ class CodeNode(BaseNode[CodeNodeData]): ) if isinstance(value, float): + decimal_value = Decimal(str(value)).normalize() + precision = -decimal_value.as_tuple().exponent if decimal_value.as_tuple().exponent < 0 else 0 # type: ignore[operator] # raise error if precision is too high - if len(str(value).split(".")[1]) > dify_config.CODE_MAX_PRECISION: + if precision > dify_config.CODE_MAX_PRECISION: raise OutputValidationError( f"Output variable `{variable}` has too high precision," f" it must be less than {dify_config.CODE_MAX_PRECISION} digits." diff --git a/api/tests/integration_tests/workflow/nodes/test_code.py b/api/tests/integration_tests/workflow/nodes/test_code.py index 90bb04f649..daab974775 100644 --- a/api/tests/integration_tests/workflow/nodes/test_code.py +++ b/api/tests/integration_tests/workflow/nodes/test_code.py @@ -354,3 +354,35 @@ def test_execute_code_output_object_list(): # validate with pytest.raises(ValueError): node._transform_result(result, node.node_data.outputs) + + +def test_execute_code_scientific_notation(): + code = """ + def main() -> dict: + return { + "result": -8.0E-5 + } + """ + code = "\n".join([line[4:] for line in code.split("\n")]) + + code_config = { + "id": "code", + "data": { + "outputs": { + "result": { + "type": "number", + }, + }, + "title": "123", + "variables": [], + "answer": "123", + "code_language": "python3", + "code": code, + }, + } + + node = init_code_node(code_config) + # execute node + result = node._run() + assert isinstance(result, NodeRunResult) + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED From aeb68f99bdfbdf96ea00469ef146828bd2c725d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Jul 2025 13:25:39 +0800 Subject: [PATCH 03/17] chore: translate i18n files (#22526) Co-authored-by: JzoNgKVO <27049666+JzoNgKVO@users.noreply.github.com> --- web/i18n/de-DE/app.ts | 7 +++++- web/i18n/de-DE/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/de-DE/login.ts | 1 + web/i18n/de-DE/plugin.ts | 1 + web/i18n/de-DE/tools.ts | 5 +++++ web/i18n/de-DE/workflow.ts | 14 +++++++++++- web/i18n/es-ES/app.ts | 7 +++++- web/i18n/es-ES/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/es-ES/login.ts | 1 + web/i18n/es-ES/plugin.ts | 1 + web/i18n/es-ES/tools.ts | 5 +++++ web/i18n/es-ES/workflow.ts | 14 +++++++++++- web/i18n/fa-IR/app.ts | 7 +++++- web/i18n/fa-IR/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/fa-IR/login.ts | 1 + web/i18n/fa-IR/plugin.ts | 1 + web/i18n/fa-IR/tools.ts | 5 +++++ web/i18n/fa-IR/workflow.ts | 14 +++++++++++- web/i18n/fr-FR/app.ts | 7 +++++- web/i18n/fr-FR/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/fr-FR/login.ts | 1 + web/i18n/fr-FR/plugin.ts | 1 + web/i18n/fr-FR/tools.ts | 5 +++++ web/i18n/fr-FR/workflow.ts | 14 +++++++++++- web/i18n/hi-IN/app.ts | 7 +++++- web/i18n/hi-IN/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/hi-IN/login.ts | 1 + web/i18n/hi-IN/plugin.ts | 1 + web/i18n/hi-IN/tools.ts | 5 +++++ web/i18n/hi-IN/workflow.ts | 14 +++++++++++- web/i18n/it-IT/app.ts | 7 +++++- web/i18n/it-IT/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/it-IT/login.ts | 1 + web/i18n/it-IT/plugin.ts | 1 + web/i18n/it-IT/tools.ts | 5 +++++ web/i18n/it-IT/workflow.ts | 14 +++++++++++- web/i18n/ja-JP/app.ts | 7 +++++- web/i18n/ja-JP/common.ts | 1 + web/i18n/ja-JP/plugin.ts | 1 + web/i18n/ja-JP/tools.ts | 5 +++++ web/i18n/ja-JP/workflow.ts | 13 ++++++++++- web/i18n/ko-KR/app.ts | 7 +++++- web/i18n/ko-KR/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/ko-KR/login.ts | 1 + web/i18n/ko-KR/plugin.ts | 1 + web/i18n/ko-KR/tools.ts | 5 +++++ web/i18n/ko-KR/workflow.ts | 14 +++++++++++- web/i18n/pl-PL/app.ts | 7 +++++- web/i18n/pl-PL/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/pl-PL/login.ts | 1 + web/i18n/pl-PL/plugin.ts | 1 + web/i18n/pl-PL/tools.ts | 5 +++++ web/i18n/pl-PL/workflow.ts | 14 +++++++++++- web/i18n/pt-BR/app.ts | 7 +++++- web/i18n/pt-BR/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/pt-BR/login.ts | 1 + web/i18n/pt-BR/plugin.ts | 1 + web/i18n/pt-BR/tools.ts | 5 +++++ web/i18n/pt-BR/workflow.ts | 14 +++++++++++- web/i18n/ro-RO/app.ts | 3 +++ web/i18n/ro-RO/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/ro-RO/login.ts | 1 + web/i18n/ro-RO/plugin.ts | 1 + web/i18n/ro-RO/tools.ts | 5 +++++ web/i18n/ro-RO/workflow.ts | 14 +++++++++++- web/i18n/ru-RU/app.ts | 7 +++++- web/i18n/ru-RU/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/ru-RU/login.ts | 1 + web/i18n/ru-RU/plugin.ts | 1 + web/i18n/ru-RU/tools.ts | 5 +++++ web/i18n/ru-RU/workflow.ts | 14 +++++++++++- web/i18n/sl-SI/app.ts | 7 +++++- web/i18n/sl-SI/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/sl-SI/login.ts | 1 + web/i18n/sl-SI/plugin.ts | 1 + web/i18n/sl-SI/tools.ts | 5 +++++ web/i18n/sl-SI/workflow.ts | 14 +++++++++++- web/i18n/th-TH/app.ts | 7 +++++- web/i18n/th-TH/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/th-TH/login.ts | 1 + web/i18n/th-TH/plugin.ts | 1 + web/i18n/th-TH/tools.ts | 5 +++++ web/i18n/th-TH/workflow.ts | 14 +++++++++++- web/i18n/tr-TR/app.ts | 7 +++++- web/i18n/tr-TR/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/tr-TR/login.ts | 1 + web/i18n/tr-TR/plugin.ts | 1 + web/i18n/tr-TR/tools.ts | 5 +++++ web/i18n/tr-TR/workflow.ts | 14 +++++++++++- web/i18n/uk-UA/app.ts | 7 +++++- web/i18n/uk-UA/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/uk-UA/login.ts | 1 + web/i18n/uk-UA/plugin.ts | 1 + web/i18n/uk-UA/tools.ts | 5 +++++ web/i18n/uk-UA/workflow.ts | 14 +++++++++++- web/i18n/vi-VN/app.ts | 7 +++++- web/i18n/vi-VN/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/vi-VN/login.ts | 1 + web/i18n/vi-VN/plugin.ts | 1 + web/i18n/vi-VN/tools.ts | 5 +++++ web/i18n/vi-VN/workflow.ts | 14 +++++++++++- web/i18n/zh-Hans/common.ts | 1 + web/i18n/zh-Hans/workflow.ts | 1 + web/i18n/zh-Hant/app.ts | 7 +++++- web/i18n/zh-Hant/common.ts | 43 ++++++++++++++++++++++++++++++++++++ web/i18n/zh-Hant/login.ts | 1 + web/i18n/zh-Hant/plugin.ts | 1 + web/i18n/zh-Hant/tools.ts | 5 +++++ web/i18n/zh-Hant/workflow.ts | 13 ++++++++++- 109 files changed, 1196 insertions(+), 35 deletions(-) diff --git a/web/i18n/de-DE/app.ts b/web/i18n/de-DE/app.ts index 52819d0c7e..95f2722640 100644 --- a/web/i18n/de-DE/app.ts +++ b/web/i18n/de-DE/app.ts @@ -174,7 +174,10 @@ const translation = { title: 'Weben', description: 'Weave ist eine Open-Source-Plattform zur Bewertung, Testung und Überwachung von LLM-Anwendungen.', }, - aliyun: {}, + aliyun: { + title: 'Cloud-Monitor', + description: 'Die vollständig verwaltete und wartungsfreie Observability-Plattform von Alibaba Cloud ermöglicht eine sofortige Überwachung, Verfolgung und Bewertung von Dify-Anwendungen.', + }, }, answerIcon: { descriptionInExplore: 'Gibt an, ob das web app Symbol zum Ersetzen 🤖 in Explore verwendet werden soll', @@ -263,6 +266,8 @@ const translation = { }, accessControl: 'Zugriffskontrolle für Webanwendungen', noAccessPermission: 'Keine Berechtigung zum Zugriff auf die Webanwendung', + maxActiveRequests: 'Maximale gleichzeitige Anfragen', + maxActiveRequestsPlaceholder: 'Geben Sie 0 für unbegrenzt ein', } export default translation diff --git a/web/i18n/de-DE/common.ts b/web/i18n/de-DE/common.ts index 5f4682b2a1..92e4916755 100644 --- a/web/i18n/de-DE/common.ts +++ b/web/i18n/de-DE/common.ts @@ -216,6 +216,28 @@ const translation = { workspaceIcon: 'Arbeitsbereichssymbol', workspaceName: 'Arbeitsbereichsname', editWorkspaceInfo: 'Arbeitsbereichsinformationen bearbeiten', + changeEmail: { + codeLabel: 'Bestätigungscode', + sendVerifyCode: 'Überprüfungs-Code senden', + resend: 'Erneut senden', + continue: 'Fortsetzen', + verifyEmail: 'Überprüfen Sie Ihre aktuelle E-Mail', + newEmail: 'Richten Sie eine neue E-Mail-Adresse ein', + codePlaceholder: 'Geben Sie den 6-stelligen Code ein', + resendTip: 'Haben Sie keinen Code erhalten?', + title: 'E-Mail ändern', + content2: 'Ihre aktuelle E-Mail-Adresse lautet {{email}}. Der Bestätigungscode wurde an diese E-Mail-Adresse gesendet.', + verifyNew: 'Bestätigen Sie Ihre neue E-Mail', + changeTo: 'Ändern zu {{email}}', + content1: 'Wenn Sie fortfahren, senden wir einen Bestätigungscode an {{email}} zur erneuten Authentifizierung.', + resendCount: 'Erneut senden in {{count}}s', + content4: 'Wir haben Ihnen gerade einen vorübergehenden Verifizierungscode an {{email}} gesendet.', + emailPlaceholder: 'Geben Sie eine neue E-Mail-Adresse ein', + content3: 'Geben Sie eine neue E-Mail-Adresse ein, und wir senden Ihnen einen Bestätigungscode.', + existingEmail: 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits.', + emailLabel: 'Neue E-Mail', + authTip: 'Sobald Ihre E-Mail geändert wurde, können Google- oder GitHub-Konten, die mit Ihrer alten E-Mail verknüpft sind, nicht mehr auf dieses Konto zugreifen.', + }, }, members: { team: 'Team', @@ -257,6 +279,26 @@ const translation = { datasetOperatorTip: 'Kann die Wissensdatenbank nur verwalten', builder: 'Bauherr', builderTip: 'Kann eigene Apps erstellen und bearbeiten', + transferModal: { + verifyEmail: 'Überprüfen Sie Ihre aktuelle E-Mail', + resendTip: 'Haben Sie keinen Code erhalten?', + continue: 'Fortsetzen', + resend: 'Erneut senden', + sendVerifyCode: 'Überprüfungs-Code senden', + title: 'Übertragung des Besitzes des Arbeitsbereichs', + codePlaceholder: 'Geben Sie den 6-stelligen Code ein', + transfer: 'Übertragung des Besitzes des Arbeitsbereichs', + warningTip: 'Du wirst ein Administrationsmitglied, und der neue Eigentümer wird die volle Kontrolle haben.', + codeLabel: 'Bestätigungscode', + transferPlaceholder: 'Wählen Sie ein Arbeitsbereichsmitglied aus…', + verifyContent: 'Ihre aktuelle E-Mail ist {{email}}.', + resendCount: 'Erneut senden in {{count}}s', + transferLabel: 'Übertragen Sie die Eigentümerschaft des Arbeitsbereichs auf', + warning: 'Sie sind dabei, das Eigentum an „{{workspace}}“ zu übertragen. Dies tritt sofort in Kraft und kann nicht rückgängig gemacht werden.', + verifyContent2: 'Wir werden einen temporären Bestätigungscode an diese E-Mail senden, um die erneute Authentifizierung durchzuführen.', + sendTip: 'Wenn Sie fortfahren, senden wir einen Bestätigungscode an {{email}} zur erneuten Authentifizierung.', + }, + transferOwnership: 'Eigentum übertragen', }, integrations: { connected: 'Verbunden', @@ -448,6 +490,7 @@ const translation = { addPages: 'Seiten hinzufügen', preview: 'VORSCHAU', }, + integratedAlert: 'Notion ist über interne Anmeldeinformationen integriert, es ist keine erneute Autorisierung erforderlich.', }, website: { inactive: 'Inaktiv', diff --git a/web/i18n/de-DE/login.ts b/web/i18n/de-DE/login.ts index 23cd7ce11c..42af65d0f0 100644 --- a/web/i18n/de-DE/login.ts +++ b/web/i18n/de-DE/login.ts @@ -109,6 +109,7 @@ const translation = { noLoginMethod: 'Authentifizierungsmethode ist nicht für die Webanwendung konfiguriert', noLoginMethodTip: 'Bitte kontaktieren Sie den Systemadministrator, um eine Authentifizierungsmethode hinzuzufügen.', disabled: 'Die Webanmeldeauthentifizierung ist deaktiviert. Bitte kontaktieren Sie den Systemadministrator, um sie zu aktivieren. Sie können versuchen, die App direkt zu verwenden.', + login: 'Anmelden', }, } diff --git a/web/i18n/de-DE/plugin.ts b/web/i18n/de-DE/plugin.ts index 87f222be94..c443c5ee9c 100644 --- a/web/i18n/de-DE/plugin.ts +++ b/web/i18n/de-DE/plugin.ts @@ -63,6 +63,7 @@ const translation = { toolLabel: 'Werkzeug', uninstalledContent: 'Dieses Plugin wird aus dem lokalen/GitHub-Repository installiert. Bitte nach der Installation verwenden.', toolSetting: 'Werkzeugs Einstellungen', + unsupportedMCPTool: 'Die derzeit ausgewählte Agentenstrategie-Plugin-Version unterstützt keine MCP-Tools.', }, strategyNum: '{{num}} {{Strategie}} IINKLUSIVE', configureApp: 'App konfigurieren', diff --git a/web/i18n/de-DE/tools.ts b/web/i18n/de-DE/tools.ts index 6e6eda85e0..4e63cdd315 100644 --- a/web/i18n/de-DE/tools.ts +++ b/web/i18n/de-DE/tools.ts @@ -57,9 +57,14 @@ const translation = { api_key: 'API-Key', apiKeyPlaceholder: 'HTTP-Headername für API-Key', apiValuePlaceholder: 'API-Key eingeben', + api_key_header: 'Kopfzeile', + queryParamPlaceholder: 'Abfrageparametername für den API-Schlüssel', + api_key_query: 'Abfrageparameter', }, key: 'Schlüssel', value: 'Wert', + queryParam: 'Abfrageparameter', + queryParamTooltip: 'Der Name des API-Schlüssel-Abfrageparameters, der übergeben werden soll, z. B. "key" in "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'Auth-Typ', diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index ba49f72b69..de2c3ce38d 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -115,6 +115,7 @@ const translation = { addBlock: 'Knoten hinzufügen', needEndNode: 'Der Endknoten muss hinzugefügt werden.', needAnswerNode: 'Der Antwortknoten muss hinzugefügt werden.', + tagBound: 'Anzahl der Apps, die dieses Tag verwenden', }, env: { envPanelTitle: 'Umgebungsvariablen', @@ -234,6 +235,8 @@ const translation = { 'agent': 'Agenten-Strategie', 'searchBlock': 'Suchknoten', 'blocks': 'Knoten', + 'allAdded': 'Alle hinzugefügt', + 'addAll': 'Alles hinzufügen', }, blocks: { 'start': 'Start', @@ -364,7 +367,10 @@ const translation = { ms: 'Frau', retries: '{{num}} Wiederholungen', }, - typeSwitch: {}, + typeSwitch: { + input: 'Eingabewert', + variable: 'Verwende die Variable', + }, }, start: { required: 'erforderlich', @@ -551,6 +557,7 @@ const translation = { advancedDependencies: 'Erweiterte Abhängigkeiten', advancedDependenciesTip: 'Fügen Sie hier einige vorinstallierte Abhängigkeiten hinzu, die mehr Zeit in Anspruch nehmen oder nicht standardmäßig eingebaut sind', searchDependencies: 'Abhängigkeiten suchen', + syncFunctionSignature: 'Synchronisiere die Funktionssignatur mit dem Code', }, templateTransform: { inputVars: 'Eingabevariablen', @@ -670,6 +677,9 @@ const translation = { json: 'von einem Tool generiertes JSON', }, authorize: 'Autorisieren', + insertPlaceholder2: 'Fügen Sie die Variable ein.', + insertPlaceholder1: 'Tippen oder drücken', + settings: 'Einstellungen', }, questionClassifiers: { model: 'Modell', @@ -854,6 +864,8 @@ const translation = { learnMore: 'Weitere Informationen', configureModel: 'Modell konfigurieren', linkToPlugin: 'Link zu Plugins', + parameterSchema: 'Parameter-Schema', + clickToViewParameterSchema: 'Klicken Sie hier, um das Parameterschema anzuzeigen.', }, loop: { ErrorMethod: { diff --git a/web/i18n/es-ES/app.ts b/web/i18n/es-ES/app.ts index 4c9497e16d..add9a4318d 100644 --- a/web/i18n/es-ES/app.ts +++ b/web/i18n/es-ES/app.ts @@ -172,7 +172,10 @@ const translation = { description: 'Weave es una plataforma de código abierto para evaluar, probar y monitorear aplicaciones de LLM.', title: 'Tejer', }, - aliyun: {}, + aliyun: { + title: 'Monitor de Nubes', + description: 'La plataforma de observabilidad totalmente gestionada y sin mantenimiento proporcionada por Alibaba Cloud, permite la monitorización, trazado y evaluación de aplicaciones Dify de manera inmediata.', + }, }, answerIcon: { title: 'Usar el icono de la aplicación web para reemplazar 🤖', @@ -256,6 +259,8 @@ const translation = { }, accessControl: 'Control de Acceso a la Aplicación Web', noAccessPermission: 'No se permite el acceso a la aplicación web', + maxActiveRequestsPlaceholder: 'Introduce 0 para ilimitado', + maxActiveRequests: 'Máximas solicitudes concurrentes', } export default translation diff --git a/web/i18n/es-ES/common.ts b/web/i18n/es-ES/common.ts index 0b2579482e..337232bea0 100644 --- a/web/i18n/es-ES/common.ts +++ b/web/i18n/es-ES/common.ts @@ -220,6 +220,28 @@ const translation = { workspaceIcon: 'Icono de espacio de trabajo', editWorkspaceInfo: 'Editar información del espacio de trabajo', workspaceName: 'Nombre del espacio de trabajo', + changeEmail: { + continue: 'Continuar', + content3: 'Introduce un nuevo correo electrónico y te enviaremos un código de verificación.', + emailPlaceholder: 'Introduce un nuevo correo electrónico', + emailLabel: 'Nuevo correo electrónico', + sendVerifyCode: 'Enviar código de verificación', + changeTo: 'Cambia a {{email}}', + resendTip: '¿No recibiste un código?', + codePlaceholder: 'Pegue el código de 6 dígitos', + content4: 'Acabamos de enviarte un código de verificación temporal a {{email}}.', + newEmail: 'Configura una nueva dirección de correo electrónico', + resend: 'Reenviar', + resendCount: 'Reenviar en {{count}}s', + content2: 'Tu correo electrónico actual es {{email}}. Se ha enviado un código de verificación a esta dirección de correo electrónico.', + content1: 'Si continúas, enviaremos un código de verificación a {{email}} para la reautenticación.', + title: 'Cambiar Correo Electrónico', + verifyEmail: 'Verifica tu correo electrónico actual', + existingEmail: 'Ya existe un usuario con este correo electrónico.', + verifyNew: 'Verifica tu nuevo correo electrónico', + codeLabel: 'Código de verificación', + authTip: 'Una vez que tu correo electrónico sea cambiado, las cuentas de Google o GitHub vinculadas a tu antiguo correo electrónico ya no podrán iniciar sesión en esta cuenta.', + }, }, members: { team: 'Equipo', @@ -261,6 +283,26 @@ const translation = { disInvite: 'Cancelar la invitación', deleteMember: 'Eliminar miembro', you: '(Tú)', + transferModal: { + continue: 'Continuar', + codeLabel: 'Código de verificación', + verifyEmail: 'Verifica tu correo electrónico actual', + transfer: 'Transferir la propiedad del espacio de trabajo', + transferPlaceholder: 'Selecciona un miembro del espacio de trabajo...', + verifyContent: 'Tu correo electrónico actual es {{email}}.', + resendCount: 'Reenviar en {{count}}s', + resendTip: '¿No recibiste un código?', + sendVerifyCode: 'Enviar código de verificación', + title: 'Transferir la propiedad del espacio de trabajo', + verifyContent2: 'Enviaremos un código de verificación temporal a este correo electrónico para la re-autenticación.', + transferLabel: 'Transferir la propiedad del espacio de trabajo a', + resend: 'Reenviar', + sendTip: 'Si continúas, enviaremos un código de verificación a {{email}} para la reautenticación.', + warningTip: 'Te convertirás en un miembro administrador, y el nuevo propietario tendrá el control total.', + codePlaceholder: 'Pegue el código de 6 dígitos', + warning: 'Estás a punto de transferir la propiedad de “{{workspace}}”. Esto tendrá efecto inmediato y no se puede deshacer.', + }, + transferOwnership: 'Transferir propiedad', }, integrations: { connected: 'Conectado', @@ -453,6 +495,7 @@ const translation = { addPages: 'Agregar páginas', preview: 'VISTA PREVIA', }, + integratedAlert: 'Notion está integrado a través de credenciales internas, no es necesario volver a autorizar.', }, website: { title: 'Sitio web', diff --git a/web/i18n/es-ES/login.ts b/web/i18n/es-ES/login.ts index 8c575e58ee..9601bffa6a 100644 --- a/web/i18n/es-ES/login.ts +++ b/web/i18n/es-ES/login.ts @@ -109,6 +109,7 @@ const translation = { disabled: 'La autenticación de la aplicación web está desactivada. Por favor, contacte al administrador del sistema para habilitarla. Puede intentar usar la aplicación directamente.', noLoginMethodTip: 'Por favor, contacta al administrador del sistema para agregar un método de autenticación.', noLoginMethod: 'Método de autenticación no configurado para la aplicación web', + login: 'Iniciar sesión', }, } diff --git a/web/i18n/es-ES/plugin.ts b/web/i18n/es-ES/plugin.ts index 84e317add6..66e333c3b1 100644 --- a/web/i18n/es-ES/plugin.ts +++ b/web/i18n/es-ES/plugin.ts @@ -63,6 +63,7 @@ const translation = { params: 'CONFIGURACIÓN DE RAZONAMIENTO', uninstalledLink: 'Administrar en Plugins', toolSetting: 'Configuraciones de la herramienta', + unsupportedMCPTool: 'La versión actual del plugin de estrategia del agente seleccionado no es compatible con las herramientas MCP.', }, endpointDeleteContent: '¿Te gustaría eliminar {{nombre}}?', endpointDisableTip: 'Deshabilitar punto de conexión', diff --git a/web/i18n/es-ES/tools.ts b/web/i18n/es-ES/tools.ts index b503f9c41b..25cc1309e9 100644 --- a/web/i18n/es-ES/tools.ts +++ b/web/i18n/es-ES/tools.ts @@ -85,9 +85,14 @@ const translation = { api_key: 'Clave API', apiKeyPlaceholder: 'Nombre del encabezado HTTP para la Clave API', apiValuePlaceholder: 'Ingresa la Clave API', + api_key_header: 'Encabezado', + api_key_query: 'Parámetro de consulta', + queryParamPlaceholder: 'Nombre del parámetro de consulta para la clave de API', }, key: 'Clave', value: 'Valor', + queryParam: 'Parámetro de consulta', + queryParamTooltip: 'El nombre del parámetro de consulta de clave de API que se debe pasar, por ejemplo, "key" en "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'Tipo de Autenticación', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index 44516317e8..535f92b0b1 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -115,6 +115,7 @@ const translation = { needAnswerNode: 'Se debe agregar el nodo de respuesta', needEndNode: 'Se debe agregar el nodo Final', addBlock: 'Agregar nodo', + tagBound: 'Número de aplicaciones que utilizan esta etiqueta', }, env: { envPanelTitle: 'Variables de Entorno', @@ -234,6 +235,8 @@ const translation = { 'plugin': 'Plugin', 'searchBlock': 'Buscar nodo', 'blocks': 'Nodos', + 'addAll': 'Agregar todo', + 'allAdded': 'Todo añadido', }, blocks: { 'start': 'Inicio', @@ -364,7 +367,10 @@ const translation = { retries: '{{num}} Reintentos', retry: 'Reintentar', }, - typeSwitch: {}, + typeSwitch: { + input: 'Valor de entrada', + variable: 'Usa la variable', + }, }, start: { required: 'requerido', @@ -549,6 +555,7 @@ const translation = { advancedDependencies: 'Dependencias avanzadas', advancedDependenciesTip: 'Agrega algunas dependencias precargadas que consumen más tiempo o no son incorporadas por defecto aquí', searchDependencies: 'Buscar dependencias', + syncFunctionSignature: 'Sincronizar la firma de la función con el código', }, templateTransform: { inputVars: 'Variables de entrada', @@ -668,6 +675,9 @@ const translation = { json: 'JSON generado por la herramienta', }, authorize: 'autorizar', + insertPlaceholder2: 'insertar variable', + settings: 'Ajustes', + insertPlaceholder1: 'Escribe o presiona', }, questionClassifiers: { model: 'modelo', @@ -855,6 +865,8 @@ const translation = { strategyNotFoundDescAndSwitchVersion: 'La versión del plugin instalado no proporciona esta estrategia. Haga clic para cambiar de versión.', toolNotAuthorizedTooltip: '{{herramienta}} No autorizado', modelNotSelected: 'Modelo no seleccionado', + clickToViewParameterSchema: 'Haga clic para ver el esquema de parámetros', + parameterSchema: 'Esquema de Parámetros', }, loop: { ErrorMethod: { diff --git a/web/i18n/fa-IR/app.ts b/web/i18n/fa-IR/app.ts index bf2fa00c11..890dae5cae 100644 --- a/web/i18n/fa-IR/app.ts +++ b/web/i18n/fa-IR/app.ts @@ -176,7 +176,10 @@ const translation = { title: 'بافندگی', description: 'ویو یک پلتفرم متن باز برای ارزیابی، آزمایش و نظارت بر برنامه‌های LLM است.', }, - aliyun: {}, + aliyun: { + title: 'نظارت بر ابر', + description: 'پلتفرم مشاهده‌پذیری کاملاً مدیریت‌شده و بدون نیاز به نگهداری که توسط Alibaba Cloud ارائه شده، امکان نظارت، ردیابی و ارزیابی برنامه‌های Dify را به‌صورت آماده و با تنظیمات اولیه فراهم می‌کند.', + }, }, answerIcon: { descriptionInExplore: 'آیا از نماد web app برای جایگزینی 🤖 در Explore استفاده کنیم یا خیر', @@ -256,6 +259,8 @@ const translation = { }, accessControl: 'کنترل دسترسی به وب اپلیکیشن', noAccessPermission: 'دسترسی به برنامه وب مجاز نیست', + maxActiveRequests: 'بیشترین درخواست‌های همزمان', + maxActiveRequestsPlaceholder: 'برای نامحدود، 0 را وارد کنید', } export default translation diff --git a/web/i18n/fa-IR/common.ts b/web/i18n/fa-IR/common.ts index 7adbad8ca4..c30319b0d2 100644 --- a/web/i18n/fa-IR/common.ts +++ b/web/i18n/fa-IR/common.ts @@ -220,6 +220,28 @@ const translation = { editWorkspaceInfo: 'ویرایش اطلاعات فضای کار', workspaceName: 'نام فضای کاری', workspaceIcon: 'آیکون محیط کار', + changeEmail: { + changeTo: 'تغییر به {{email}}', + resendTip: 'کدی دریافت نکردید؟', + codeLabel: 'کد تأیید', + resend: 'دوباره ارسال کنید', + emailLabel: 'ایمیل جدید', + title: 'تغییر ایمیل', + verifyNew: 'ایمیل جدید خود را تأیید کنید', + sendVerifyCode: 'کد تأیید را ارسال کنید', + newEmail: 'یک آدرس ایمیل جدید راه‌اندازی کنید', + emailPlaceholder: 'یک ایمیل جدید وارد کنید', + codePlaceholder: 'کد ۶ رقمی را وارد کنید', + existingEmail: 'کاربری با این ایمیل از قبل وجود دارد.', + content2: 'ایمیل فعلی شما {{email}} است. کد تأیید به این آدرس ایمیل ارسال شده است.', + resendCount: 'دوباره ارسال کنید در {{count}} ثانیه', + continue: 'ادامه دهید', + verifyEmail: 'ایمیل فعلی خود را تأیید کنید', + content4: 'ما یک کد تأیید موقت برای شما به {{email}} ارسال کردیم.', + content1: 'اگر ادامه دهید، ما یک کد تأیید به {{email}} برای بازگشایی مجدد ارسال خواهیم کرد.', + content3: 'یک ایمیل جدید وارد کنید و ما یک کد تأیید برای شما ارسال خواهیم کرد.', + authTip: 'زمانی که ایمیل شما تغییر کند، حساب‌های گوگل یا گیت‌هاب مرتبط با ایمیل قدیمی شما دیگر قادر به ورود به این حساب نخواهند بود.', + }, }, members: { team: 'تیم', @@ -261,6 +283,26 @@ const translation = { disInvite: 'لغو دعوت', deleteMember: 'حذف عضو', you: '(شما)', + transferModal: { + resendTip: 'کدی دریافت نکردید؟', + resend: 'دوباره ارسال کنید', + continue: 'ادامه دهید', + codeLabel: 'کد تأیید', + sendVerifyCode: 'کد تأیید را ارسال کنید', + title: 'انتقال مالکیت فضای کاری', + verifyContent: 'ایمیل فعلی شما {{email}} است.', + transfer: 'انتقال مالکیت فضای کاری', + warning: 'شما در حال انتقال مالکیت "{{workspace}}" هستید. این بلافاصله اجرایی می‌شود و قابل بازگشت نیست.', + resendCount: 'دوباره ارسال کنید در {{count}} ثانیه', + warningTip: 'شما به یک عضو مدیر تبدیل خواهید شد و مالک جدید کنترل کامل خواهد داشت.', + transferLabel: 'مالکیت فضای کار را به منتقل کنید', + verifyEmail: 'ایمیل فعلی خود را تأیید کنید', + sendTip: 'اگر ادامه دهید، ما یک کد تأیید به {{email}} برای بازگشایی مجدد ارسال خواهیم کرد.', + codePlaceholder: 'کد ۶ رقمی را وارد کنید', + transferPlaceholder: 'یک عضو از فضای کاری را انتخاب کنید…', + verifyContent2: 'ما یک کد تأیید موقت به این ایمیل برای تأیید مجدد ارسال خواهیم کرد.', + }, + transferOwnership: 'انتقال مالکیت', }, integrations: { connected: 'متصل شده', @@ -453,6 +495,7 @@ const translation = { addPages: 'افزودن صفحات', preview: 'پیش‌نمایش', }, + integratedAlert: 'نوشته به طور داخلی از طریق اعتبارنامه یکپارچه شده است، نیازی به دوباره مجاز کردن نیست.', }, website: { title: 'وب‌سایت', diff --git a/web/i18n/fa-IR/login.ts b/web/i18n/fa-IR/login.ts index 7d853c7b2d..da2e5197eb 100644 --- a/web/i18n/fa-IR/login.ts +++ b/web/i18n/fa-IR/login.ts @@ -109,6 +109,7 @@ const translation = { disabled: 'احراز هویت وب اپ غیرفعال است. لطفاً با مدیر سیستم تماس بگیرید تا آن را فعال کند. می‌توانید سعی کنید مستقیماً از اپلیکیشن استفاده کنید.', noLoginMethodTip: 'لطفاً با مدیر سیستم تماس بگیرید تا یک روش احراز هویت اضافه کند.', noLoginMethod: 'روش احراز هویت برای برنامه وب پیکربندی نشده است', + login: 'ورود', }, } diff --git a/web/i18n/fa-IR/plugin.ts b/web/i18n/fa-IR/plugin.ts index 890d666f9f..6a4f77b99b 100644 --- a/web/i18n/fa-IR/plugin.ts +++ b/web/i18n/fa-IR/plugin.ts @@ -63,6 +63,7 @@ const translation = { unsupportedTitle: 'اکشن پشتیبانی نشده', unsupportedContent2: 'برای تغییر نسخه کلیک کنید.', toolSetting: 'تنظیمات ابزار', + unsupportedMCPTool: 'نسخه فعلی پلاگین استراتژی عامل انتخاب‌شده از ابزارهای MCP پشتیبانی نمی‌کند.', }, endpointDeleteTip: 'حذف نقطه پایانی', disabled: 'غیر فعال', diff --git a/web/i18n/fa-IR/tools.ts b/web/i18n/fa-IR/tools.ts index 942bde7932..6e7d941e7c 100644 --- a/web/i18n/fa-IR/tools.ts +++ b/web/i18n/fa-IR/tools.ts @@ -85,9 +85,14 @@ const translation = { api_key: 'کلید API', apiKeyPlaceholder: 'نام هدر HTTP برای کلید API', apiValuePlaceholder: 'کلید API را وارد کنید', + api_key_header: 'عنوان', + api_key_query: 'پارامتر جستجو', + queryParamPlaceholder: 'نام پارامتر جستجو برای کلید API', }, key: 'کلید', value: 'مقدار', + queryParam: 'پارامتر جستجو', + queryParamTooltip: 'نام پارامتر پرس و جو کلید API که باید ارسال شود، به عنوان مثال "key" در "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'نوع مجوز', diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index 800dba06b8..b1aa11d3bf 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -115,6 +115,7 @@ const translation = { needEndNode: 'باید گره پایان اضافه شود', needAnswerNode: 'باید گره پاسخ اضافه شود', addBlock: 'نود اضافه کنید', + tagBound: 'تعداد برنامه‌هایی که از این برچسب استفاده می‌کنند', }, env: { envPanelTitle: 'متغیرهای محیطی', @@ -234,6 +235,8 @@ const translation = { 'agent': 'استراتژی نمایندگی', 'blocks': 'گره‌ها', 'searchBlock': 'گره جستجو', + 'addAll': 'همه را اضافه کنید', + 'allAdded': 'همه اضافه شده است', }, blocks: { 'start': 'شروع', @@ -364,7 +367,10 @@ const translation = { retrySuccessful: 'امتحان مجدد با موفقیت انجام دهید', retryFailedTimes: '{{بار}} تلاش های مجدد ناموفق بود', }, - typeSwitch: {}, + typeSwitch: { + input: 'مقدار ورودی', + variable: 'از متغیر استفاده کن', + }, }, start: { required: 'الزامی', @@ -551,6 +557,7 @@ const translation = { advancedDependencies: 'وابستگی‌های پیشرفته', advancedDependenciesTip: 'برخی وابستگی‌های پیش‌بارگذاری شده که زمان بیشتری برای مصرف نیاز دارند یا به طور پیش‌فرض در اینجا موجود نیستند، اضافه کنید', searchDependencies: 'جستجوی وابستگی‌ها', + syncFunctionSignature: 'امضای تابع همگام‌سازی را به کد متصل کنید', }, templateTransform: { inputVars: 'متغیرهای ورودی', @@ -670,6 +677,9 @@ const translation = { json: 'json تولید شده توسط ابزار', }, authorize: 'مجوز دادن', + settings: 'تنظیمات', + insertPlaceholder2: 'متغیر را وارد کنید', + insertPlaceholder1: 'نوع کنید یا فشار دهید', }, questionClassifiers: { model: 'مدل', @@ -854,6 +864,8 @@ const translation = { strategyNotFoundDesc: 'نسخه افزونه نصب شده این استراتژی را ارائه نمی دهد.', strategyNotFoundDescAndSwitchVersion: 'نسخه افزونه نصب شده این استراتژی را ارائه نمی دهد. برای تغییر نسخه کلیک کنید.', model: 'مدل', + parameterSchema: 'طرح پارامتر', + clickToViewParameterSchema: 'برای مشاهده طرح پارامتر کلیک کنید', }, loop: { ErrorMethod: { diff --git a/web/i18n/fr-FR/app.ts b/web/i18n/fr-FR/app.ts index 18cd04a1e1..523934152f 100644 --- a/web/i18n/fr-FR/app.ts +++ b/web/i18n/fr-FR/app.ts @@ -172,7 +172,10 @@ const translation = { title: 'Tisser', description: 'Weave est une plateforme open-source pour évaluer, tester et surveiller les applications LLM.', }, - aliyun: {}, + aliyun: { + title: 'Surveillance Cloud', + description: 'La plateforme d\'observabilité entièrement gérée et sans maintenance fournie par Alibaba Cloud permet une surveillance, un traçage et une évaluation prêts à l\'emploi des applications Dify.', + }, }, answerIcon: { description: 'S’il faut utiliser l’icône web app pour remplacer 🤖 dans l’application partagée', @@ -256,6 +259,8 @@ const translation = { }, accessControl: 'Contrôle d\'accès à l\'application Web', noAccessPermission: 'Pas de permission d\'accéder à l\'application web', + maxActiveRequestsPlaceholder: 'Entrez 0 pour illimité', + maxActiveRequests: 'Nombre maximal de requêtes simultanées', } export default translation diff --git a/web/i18n/fr-FR/common.ts b/web/i18n/fr-FR/common.ts index 23d6e43fc3..136e7de2ef 100644 --- a/web/i18n/fr-FR/common.ts +++ b/web/i18n/fr-FR/common.ts @@ -216,6 +216,28 @@ const translation = { workspaceName: 'Nom de l\'espace de travail', workspaceIcon: 'Icône de l\'espace de travail', editWorkspaceInfo: 'Modifier les informations de l\'espace de travail', + changeEmail: { + codePlaceholder: 'Collez le code à 6 chiffres', + emailLabel: 'Nouveau courriel', + newEmail: 'Créez une nouvelle adresse email', + verifyNew: 'Vérifiez votre nouvel e-mail', + existingEmail: 'Un utilisateur avec cet email existe déjà.', + title: 'Changer l\'email', + resendTip: 'Vous n\'avez pas reçu de code ?', + emailPlaceholder: 'Entrez un nouvel e-mail', + sendVerifyCode: 'Envoyer le code de vérification', + continue: 'Continuer', + changeTo: 'Changer pour {{email}}', + authTip: 'Une fois que votre email est changé, les comptes Google ou GitHub liés à votre ancien email ne pourront plus se connecter à ce compte.', + content3: 'Entrez un nouvel e-mail et nous vous enverrons un code de vérification.', + resendCount: 'Renvoyer dans {{count}}s', + content4: 'Nous vous avons juste envoyé un code de vérification temporaire à {{email}}.', + resend: 'Renvoyer', + verifyEmail: 'Vérifiez votre adresse e-mail actuelle', + content2: 'Votre adresse e-mail actuelle est {{email}}. Un code de vérification a été envoyé à cette adresse e-mail.', + codeLabel: 'Code de vérification', + content1: 'Si vous continuez, nous enverrons un code de vérification à {{email}} pour une nouvelle authentification.', + }, }, members: { team: 'Équipe', @@ -257,6 +279,26 @@ const translation = { datasetOperator: 'Administrateur des connaissances', setBuilder: 'Définir en tant que constructeur', builderTip: 'Peut créer et modifier ses propres applications', + transferModal: { + resend: 'Renvoyer', + continue: 'Continuer', + verifyEmail: 'Vérifiez votre adresse e-mail actuelle', + resendCount: 'Renvoyer dans {{count}}s', + verifyContent2: 'Nous enverrons un code de vérification temporaire à cet email pour la ré-authentification.', + codePlaceholder: 'Collez le code à 6 chiffres', + transfer: 'Transférer la propriété de l\'espace de travail', + sendVerifyCode: 'Envoyer le code de vérification', + title: 'Transférer la propriété de l\'espace de travail', + codeLabel: 'Code de vérification', + transferLabel: 'Transférer la propriété de l\'espace de travail à', + verifyContent: 'Votre adresse e-mail actuelle est {{email}}.', + transferPlaceholder: 'Sélectionnez un membre de l\'espace de travail…', + warningTip: 'Vous deviendrez membre administrateur, et le nouveau propriétaire aura le contrôle total.', + resendTip: 'Vous n\'avez pas reçu de code ?', + sendTip: 'Si vous continuez, nous enverrons un code de vérification à {{email}} pour la ré-authentification.', + warning: 'Vous êtes sur le point de transférer la propriété de « {{workspace}} ». Cela prend effet immédiatement et ne peut pas être annulé.', + }, + transferOwnership: 'Transférer la propriété', }, integrations: { connected: 'Connecté', @@ -448,6 +490,7 @@ const translation = { addPages: 'Ajouter des pages', preview: 'APERÇU', }, + integratedAlert: 'Notion est intégré via des identifiants internes, aucune autorisation supplémentaire n\'est nécessaire.', }, website: { configuredCrawlers: 'Robots d’exploration configurés', diff --git a/web/i18n/fr-FR/login.ts b/web/i18n/fr-FR/login.ts index 68a642b3ea..9e718cad2d 100644 --- a/web/i18n/fr-FR/login.ts +++ b/web/i18n/fr-FR/login.ts @@ -109,6 +109,7 @@ const translation = { noLoginMethodTip: 'Veuillez contacter l\'administrateur système pour ajouter une méthode d\'authentification.', noLoginMethod: 'Méthode d\'authentification non configurée pour l\'application web', disabled: 'L\'authentification de l\'application web est désactivée. Veuillez contacter l\'administrateur du système pour l\'activer. Vous pouvez essayer d\'utiliser l\'application directement.', + login: 'Connexion', }, } diff --git a/web/i18n/fr-FR/plugin.ts b/web/i18n/fr-FR/plugin.ts index 60366e28cf..573c49dc46 100644 --- a/web/i18n/fr-FR/plugin.ts +++ b/web/i18n/fr-FR/plugin.ts @@ -63,6 +63,7 @@ const translation = { paramsTip2: 'Lorsque « Auto » est désactivé, la valeur par défaut est utilisée.', paramsTip1: 'Contrôle les paramètres d’inférence LLM.', toolSetting: 'Paramètres de l\'outil', + unsupportedMCPTool: 'La version actuelle du plugin de stratégie d\'agent sélectionné ne prend pas en charge les outils MCP.', }, modelNum: '{{num}} MODÈLES INCLUS', endpointDeleteTip: 'Supprimer le point de terminaison', diff --git a/web/i18n/fr-FR/tools.ts b/web/i18n/fr-FR/tools.ts index fdbe213df8..b6dc4f6307 100644 --- a/web/i18n/fr-FR/tools.ts +++ b/web/i18n/fr-FR/tools.ts @@ -57,9 +57,14 @@ const translation = { api_key: 'Clé API', apiKeyPlaceholder: 'Nom de l\'en-tête HTTP pour la clé API', apiValuePlaceholder: 'Entrez la clé API', + api_key_query: 'Paramètre de requête', + queryParamPlaceholder: 'Nom du paramètre de requête pour la clé API', + api_key_header: 'En-tête', }, key: 'Clé', value: 'Valeur', + queryParam: 'Paramètre de requête', + queryParamTooltip: 'Le nom du paramètre de requête de la clé API à passer, par exemple "key" dans "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'Type d\'Authentification', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index 8c8180abff..96bead7ff2 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -115,6 +115,7 @@ const translation = { needEndNode: 'Le nœud de fin doit être ajouté', needAnswerNode: 'Le nœud de réponse doit être ajouté.', addBlock: 'Ajouter un nœud', + tagBound: 'Nombre d\'applications utilisant cette étiquette', }, env: { envPanelTitle: 'Variables d\'Environnement', @@ -234,6 +235,8 @@ const translation = { 'agent': 'Stratégie d’agent', 'blocks': 'Nœuds', 'searchBlock': 'Nœud de recherche', + 'addAll': 'Ajouter tout', + 'allAdded': 'Tout ajouté', }, blocks: { 'start': 'Début', @@ -364,7 +367,10 @@ const translation = { ms: 'ms', retries: '{{num}} Tentatives', }, - typeSwitch: {}, + typeSwitch: { + input: 'Valeur d\'entrée', + variable: 'Utilisez une variable', + }, }, start: { required: 'requis', @@ -551,6 +557,7 @@ const translation = { advancedDependencies: 'Dépendances avancées', advancedDependenciesTip: 'Ajoutez quelques dépendances préchargées qui prennent plus de temps à consommer ou ne sont pas par défaut ici', searchDependencies: 'Rechercher des dépendances', + syncFunctionSignature: 'Synchroniser la signature de fonction avec le code', }, templateTransform: { inputVars: 'Variables de saisie', @@ -670,6 +677,9 @@ const translation = { json: 'JSON généré par un outil', }, authorize: 'Autoriser', + insertPlaceholder2: 'insérer une variable', + settings: 'Paramètres', + insertPlaceholder1: 'Tapez ou appuyez', }, questionClassifiers: { model: 'modèle', @@ -854,6 +864,8 @@ const translation = { pluginNotInstalledDesc: 'Ce plugin est installé à partir de GitHub. Veuillez aller dans Plugins pour réinstaller', maxIterations: 'Nombre maximal d’itérations', toolNotAuthorizedTooltip: '{{outil}} Non autorisé', + clickToViewParameterSchema: 'Cliquez pour voir le schéma des paramètres', + parameterSchema: 'Schéma de Paramètres', }, loop: { ErrorMethod: { diff --git a/web/i18n/hi-IN/app.ts b/web/i18n/hi-IN/app.ts index e9073722f7..f1fd1a54fa 100644 --- a/web/i18n/hi-IN/app.ts +++ b/web/i18n/hi-IN/app.ts @@ -172,7 +172,10 @@ const translation = { title: 'बुनना', description: 'वीव एक ओपन-सोर्स प्लेटफ़ॉर्म है जो LLM अनुप्रयोगों का मूल्यांकन, परीक्षण और निगरानी करने के लिए है।', }, - aliyun: {}, + aliyun: { + title: 'क्लाउड मॉनिटर', + description: 'अलीबाबा क्लाउड द्वारा प्रदान की गई पूरी तरह से प्रबंधित और रखरखाव-मुक्त अवलोकन प्लेटफ़ॉर्म, Dify अनुप्रयोगों की स्वचालित निगरानी, ट्रेसिंग और मूल्यांकन का सक्षम बनाता है।', + }, }, answerIcon: { title: 'बदलने 🤖 के लिए web app चिह्न का उपयोग करें', @@ -256,6 +259,8 @@ const translation = { }, accessControl: 'वेब एप्लिकेशन पहुँच नियंत्रण', noAccessPermission: 'वेब एप्लिकेशन तक पहुँचने की अनुमति नहीं है', + maxActiveRequests: 'अधिकतम समवर्ती अनुरोध', + maxActiveRequestsPlaceholder: 'असीमित के लिए 0 दर्ज करें', } export default translation diff --git a/web/i18n/hi-IN/common.ts b/web/i18n/hi-IN/common.ts index 4efc698d7c..51e59449d5 100644 --- a/web/i18n/hi-IN/common.ts +++ b/web/i18n/hi-IN/common.ts @@ -226,6 +226,28 @@ const translation = { workspaceIcon: 'कार्यस्थल आइकन', editWorkspaceInfo: 'कार्यक्षेत्र की जानकारी संपादित करें', workspaceName: 'कार्यस्थल का नाम', + changeEmail: { + title: 'ईमेल बदलें', + codePlaceholder: '6 अंकों का कोड पेस्ट करें', + continue: 'जारी रखें', + emailPlaceholder: 'नई ईमेल दर्ज करें', + changeTo: '{{email}} में परिवर्तन करें', + resendCount: '{{count}} सेकंड में दोबारा भेजें', + resend: 'फिर से भेजें', + newEmail: 'एक नया ईमेल पता सेट करें', + codeLabel: 'पुष्टि कोड', + verifyNew: 'अपने नए ईमेल की पुष्टि करें', + resendTip: 'कोई कोड नहीं मिला?', + verifyEmail: 'अपने वर्तमान ईमेल की पुष्टि करें', + existingEmail: 'इस ईमेल के साथ एक उपयोगकर्ता पहले से मौजूद है।', + sendVerifyCode: 'सत्यापन कोड भेजें', + content3: 'एक नया ईमेल दर्ज करें और हम आपको एक सत्यापन कोड भेजेंगे।', + emailLabel: 'नया ईमेल', + content4: 'हमने आपको {{email}} पर एक अस्थायी सत्यापन कोड भेजा है।', + content2: 'आपका वर्तमान ईमेल है {{email}}. सत्यापन कोड इस ईमेल पते पर भेजा गया है।', + authTip: 'एक बार जब आपका ईमेल बदल दिया जाता है, तो आपके पुराने ईमेल से जुड़े Google या GitHub खाते इस खाते में लॉग इन नहीं कर सकेंगे।', + content1: 'अगर आप जारी रखते हैं, तो हम सत्यापन के लिए {{email}} पर एक सत्यापन कोड भेजेंगे।', + }, }, members: { team: 'टीम', @@ -270,6 +292,26 @@ const translation = { you: '(आप)', datasetOperator: 'ज्ञान व्यवस्थापक', datasetOperatorTip: 'केवल नॉलेज बेस प्रबंधित कर सकते हैं', + transferModal: { + codePlaceholder: '6 अंकों का कोड पेस्ट करें', + transferPlaceholder: 'एक कार्यक्षेत्र सदस्य चुनें…', + resendTip: 'कोड प्राप्त नहीं हुआ?', + verifyContent: 'आपका वर्तमान ईमेल {{email}} है।', + sendVerifyCode: 'सत्यापन कोड भेजें', + verifyEmail: 'अपने वर्तमान ईमेल की पुष्टि करें', + codeLabel: 'पुष्टिकरण कोड', + warning: 'आप "{{workspace}}" की स्वामित्व स्थानांतरित करने वाले हैं। यह तुरंत प्रभावी होता है और इसे पूर्ववत नहीं किया जा सकता।', + title: 'वर्कस्पेस का मालिकाना हक स्थानांतरित करें', + resend: 'फिर से भेजें', + resendCount: '{{count}} सेकंड में दोबारा भेजें', + transferLabel: 'कार्यक्षेत्र की स्वामित्व स्थानांतरित करें', + sendTip: 'अगर आप जारी रखते हैं, तो हम सत्यापन के लिए {{email}} पर एक कोड भेजेंगे।', + continue: 'जारी रखें', + transfer: 'कार्यस्थान स्वामित्व स्थानांतरित करें', + verifyContent2: 'हम इस ईमेल पर पुनः प्रमाणन के लिए एक अस्थायी सत्यापन कोड भेजेंगे।', + warningTip: 'आप एक प्रशासनिक सदस्य बन जाएंगे, और नए मालिक के पास पूरी नियंत्रण होगा।', + }, + transferOwnership: 'स्वामित्व हस्तांतरित करें', }, integrations: { connected: 'कनेक्टेड', @@ -469,6 +511,7 @@ const translation = { addPages: 'पृष्ठ जोड़ें', preview: 'पूर्वावलोकन', }, + integratedAlert: 'नोट्शन आंतरिक प्रमाण पत्र के माध्यम से एकीकृत है, फिर से प्रमाणित करने की आवश्यकता नहीं है।', }, website: { title: 'वेबसाइट', diff --git a/web/i18n/hi-IN/login.ts b/web/i18n/hi-IN/login.ts index 0c9f4451b6..06019042b5 100644 --- a/web/i18n/hi-IN/login.ts +++ b/web/i18n/hi-IN/login.ts @@ -114,6 +114,7 @@ const translation = { noLoginMethodTip: 'कृपया एक प्रमाणीकरण विधि जोड़ने के लिए सिस्टम प्रशासक से संपर्क करें।', noLoginMethod: 'वेब ऐप के लिए प्रमाणीकरण विधि कॉन्फ़िगर नहीं की गई है', disabled: 'वेब ऐप प्रमाणीकरण अक्षम है। कृपया इसे सक्षम करने के लिए सिस्टम प्रशासक से संपर्क करें। आप सीधे ऐप का उपयोग करने की कोशिश कर सकते हैं।', + login: 'लॉगइन', }, } diff --git a/web/i18n/hi-IN/plugin.ts b/web/i18n/hi-IN/plugin.ts index 0834f5aa07..8d2d41b032 100644 --- a/web/i18n/hi-IN/plugin.ts +++ b/web/i18n/hi-IN/plugin.ts @@ -63,6 +63,7 @@ const translation = { descriptionPlaceholder: 'उपकरण के उद्देश्य का संक्षिप्त विवरण, जैसे, किसी विशेष स्थान के लिए तापमान प्राप्त करना।', paramsTip1: 'एलएलएम अनुमान पैरामीटर को नियंत्रित करता है।', toolSetting: 'टूल सेटिंग्स', + unsupportedMCPTool: 'वर्तमान में चयनित एजेंट रणनीति प्लगइन संस्करण MCP टूल का समर्थन नहीं करता है।', }, switchVersion: 'स्विच संस्करण', endpointModalDesc: 'एक बार कॉन्फ़िगर होने के बाद, प्लगइन द्वारा API एंडपॉइंट्स के माध्यम से प्रदान की गई सुविधाओं का उपयोग किया जा सकता है।', diff --git a/web/i18n/hi-IN/tools.ts b/web/i18n/hi-IN/tools.ts index 8f721da44e..b3d5a343f1 100644 --- a/web/i18n/hi-IN/tools.ts +++ b/web/i18n/hi-IN/tools.ts @@ -89,9 +89,14 @@ const translation = { api_key: 'API कुंजी', apiKeyPlaceholder: 'API कुंजी के लिए HTTP हैडर नाम', apiValuePlaceholder: 'API कुंजी दर्ज करें', + api_key_query: 'अनुक्रमणिका पैरामीटर', + api_key_header: 'हेडर', + queryParamPlaceholder: 'एपीआई कुंजी के लिए क्वेरी पैरामीटर नाम', }, key: 'कुंजी', value: 'मूल्य', + queryParam: 'अनुक्रमणिका पैरामीटर', + queryParamTooltip: 'API कुंजी प्रश्न पैरा मीटर का नाम, जो पास करने के लिए है, जैसे कि "key" "https://example.com/test?key=API_KEY" में।', }, authHeaderPrefix: { title: 'अधिकृति प्रकार', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index ffddacaf3a..9689cbf9c5 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -118,6 +118,7 @@ const translation = { needAnswerNode: 'उत्तर नोड जोड़ा जाना चाहिए', addBlock: 'नोड जोड़ें', needEndNode: 'अंत नोड जोड़ा जाना चाहिए', + tagBound: 'इस टैग का उपयोग करने वाले ऐप्स की संख्या', }, env: { envPanelTitle: 'पर्यावरण चर', @@ -237,6 +238,8 @@ const translation = { 'agent': 'एजेंट रणनीति', 'searchBlock': 'खोज नोड', 'blocks': 'नोड्स', + 'addAll': 'सभी जोड़ें', + 'allAdded': 'सभी जोड़े गए', }, blocks: { 'start': 'प्रारंभ', @@ -376,7 +379,10 @@ const translation = { retry: 'पुनर्प्रयास', retryOnFailure: 'विफलता पर पुनः प्रयास करें', }, - typeSwitch: {}, + typeSwitch: { + input: 'इनपुट मान', + variable: 'चर चर का प्रयोग करें', + }, }, start: { required: 'आवश्यक', @@ -565,6 +571,7 @@ const translation = { advancedDependenciesTip: 'कुछ प्रीलोडेड निर्भरताएँ जोड़ें जिनका उपयोग करने में अधिक समय लगता है या जो डिफ़ॉल्ट निर्मित में नहीं हैं', searchDependencies: 'निर्भरताएँ खोजें', + syncFunctionSignature: 'कोड के साथ फ़ंक्शन हस्ताक्षर को सिंक करें', }, templateTransform: { inputVars: 'इनपुट वेरिएबल्स', @@ -686,6 +693,9 @@ const translation = { json: 'उपकरण द्वारा उत्पन्न JSON', }, authorize: 'अधिकृत करें', + insertPlaceholder1: 'टाइप करें या दबाएँ', + settings: 'सेटिंग्स', + insertPlaceholder2: 'चरित्र डालें', }, questionClassifiers: { model: 'मॉडल', @@ -874,6 +884,8 @@ const translation = { maxIterations: 'अधिकतम पुनरावृत्तियाँ', strategyNotSet: 'एजेंटिक रणनीति सेट नहीं की गई', strategyNotFoundDescAndSwitchVersion: 'स्थापित प्लगइन संस्करण इस रणनीति को प्रदान नहीं करता है। संस्करण बदलने के लिए क्लिक करें।', + parameterSchema: 'पैरामीटर स्कीमा', + clickToViewParameterSchema: 'पैरामीटर स्कीमा देखने के लिए क्लिक करें', }, loop: { ErrorMethod: { diff --git a/web/i18n/it-IT/app.ts b/web/i18n/it-IT/app.ts index a08714df71..a874d2b71f 100644 --- a/web/i18n/it-IT/app.ts +++ b/web/i18n/it-IT/app.ts @@ -184,7 +184,10 @@ const translation = { title: 'Intrecciare', description: 'Weave è una piattaforma open-source per valutare, testare e monitorare le applicazioni LLM.', }, - aliyun: {}, + aliyun: { + title: 'Monitoraggio Cloud', + description: 'La piattaforma di osservabilità completamente gestita e senza manutenzione fornita da Alibaba Cloud consente il monitoraggio, il tracciamento e la valutazione delle applicazioni Dify fin da subito.', + }, }, answerIcon: { description: 'Se utilizzare l\'icona web app per la sostituzione 🤖 nell\'applicazione condivisa', @@ -267,6 +270,8 @@ const translation = { }, accessControl: 'Controllo di accesso all\'app web', noAccessPermission: 'Nessun permesso per accedere all\'app web', + maxActiveRequestsPlaceholder: 'Inserisci 0 per illimitato', + maxActiveRequests: 'Massimo numero di richieste concorrenti', } export default translation diff --git a/web/i18n/it-IT/common.ts b/web/i18n/it-IT/common.ts index bdf8da1a98..16991a94d8 100644 --- a/web/i18n/it-IT/common.ts +++ b/web/i18n/it-IT/common.ts @@ -228,6 +228,28 @@ const translation = { workspaceIcon: 'Icona della workspace', editWorkspaceInfo: 'Modifica informazioni dello spazio di lavoro', workspaceName: 'Nome del Workspace', + changeEmail: { + changeTo: 'Cambia in {{email}}', + verifyEmail: 'Verifica la tua email attuale', + codePlaceholder: 'Inserisci il codice di 6 cifre', + resendTip: 'Non hai ricevuto un codice?', + newEmail: 'Crea un nuovo indirizzo email', + resend: 'Rimanda', + continue: 'Continua', + verifyNew: 'Verifica la tua nuova email', + resendCount: 'Reinvia in {{count}}s', + title: 'Cambia Email', + emailPlaceholder: 'Inserisci una nuova email', + emailLabel: 'Nuova email', + codeLabel: 'Codice di verifica', + existingEmail: 'Un utente con questa email esiste già.', + content2: 'La tua email attuale è {{email}}. Il codice di verifica è stato inviato a questo indirizzo email.', + authTip: 'Una volta che la tua email è cambiata, gli account Google o GitHub collegati alla tua vecchia email non potranno più accedere a questo account.', + content3: 'Inserisci una nuova email e ti invieremo un codice di verifica.', + content4: 'Ti abbiamo appena inviato un codice di verifica temporaneo a {{email}}.', + content1: 'Se continui, invieremo un codice di verifica a {{email}} per la riautenticazione.', + sendVerifyCode: 'Invia codice di verifica', + }, }, members: { team: 'Team', @@ -272,6 +294,26 @@ const translation = { disInvite: 'Annulla l\'invito', deleteMember: 'Elimina Membro', you: '(Tu)', + transferModal: { + continue: 'Continua', + resendTip: 'Non hai ricevuto un codice?', + title: 'Trasferire la proprietà dello spazio di lavoro', + codeLabel: 'Codice di verifica', + verifyEmail: 'Verifica la tua email attuale', + transferPlaceholder: 'Seleziona un membro del team...', + transfer: 'Trasferire la proprietà dello spazio di lavoro', + codePlaceholder: 'Inserisci il codice di 6 cifre', + warningTip: 'Diventerai un membro amministratore e il nuovo proprietario avrà il pieno controllo.', + resendCount: 'Reinvia in {{count}}s', + sendVerifyCode: 'Invia codice di verifica', + verifyContent2: 'Invieremo un codice di verifica temporaneo a questa email per la re-autenticazione.', + verifyContent: 'La tua email attuale è {{email}}.', + sendTip: 'Se continui, invieremo un codice di verifica a {{email}} per la riautenticazione.', + warning: 'Stai per trasferire la proprietà di "{{workspace}}". Questo avrà effetto immediato e non può essere annullato.', + transferLabel: 'Trasferisci la proprietà dello spazio di lavoro a', + resend: 'Rimanda', + }, + transferOwnership: 'Trasferisci Proprietà', }, integrations: { connected: 'Connesso', @@ -476,6 +518,7 @@ const translation = { addPages: 'Aggiungi pagine', preview: 'ANTEPRIMA', }, + integratedAlert: 'Notion è integrato tramite credenziali interne, non è necessario ri-autorizzare.', }, website: { title: 'Sito web', diff --git a/web/i18n/it-IT/login.ts b/web/i18n/it-IT/login.ts index cbc05d60c1..47ae79bdd9 100644 --- a/web/i18n/it-IT/login.ts +++ b/web/i18n/it-IT/login.ts @@ -119,6 +119,7 @@ const translation = { noLoginMethod: 'Metodo di autenticazione non configurato per l\'app web', noLoginMethodTip: 'Si prega di contattare l\'amministratore del sistema per aggiungere un metodo di autenticazione.', disabled: 'L\'autenticazione dell\'app web è disabilitata. Si prega di contattare l\'amministratore di sistema per abilitarla. Puoi provare a utilizzare l\'app direttamente.', + login: 'Accesso', }, } diff --git a/web/i18n/it-IT/plugin.ts b/web/i18n/it-IT/plugin.ts index 5f2a6d9dc7..522c11470c 100644 --- a/web/i18n/it-IT/plugin.ts +++ b/web/i18n/it-IT/plugin.ts @@ -63,6 +63,7 @@ const translation = { auto: 'Automatico', paramsTip2: 'Quando \'Automatico\' è disattivato, viene utilizzato il valore predefinito.', toolSetting: 'Impostazioni degli strumenti', + unsupportedMCPTool: 'La versione attualmente selezionata del plugin strategia agente non supporta gli strumenti MCP.', }, modelNum: '{{num}} MODELLI INCLUSI', endpointModalTitle: 'Endpoint di configurazione', diff --git a/web/i18n/it-IT/tools.ts b/web/i18n/it-IT/tools.ts index 8aa119b45a..8d64061104 100644 --- a/web/i18n/it-IT/tools.ts +++ b/web/i18n/it-IT/tools.ts @@ -89,9 +89,14 @@ const translation = { api_key: 'API Key', apiKeyPlaceholder: 'Nome dell\'intestazione HTTP per API Key', apiValuePlaceholder: 'Inserisci API Key', + api_key_query: 'Parametro di query', + api_key_header: 'Intestazione', + queryParamPlaceholder: 'Nome del parametro di query per la chiave API', }, key: 'Chiave', value: 'Valore', + queryParam: 'Parametro di query', + queryParamTooltip: 'Il nome del parametro di query della chiave API da passare, ad esempio "key" in "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'Tipo di Auth', diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index d7e85dcc19..024ee8b90c 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -119,6 +119,7 @@ const translation = { needEndNode: 'Deve essere aggiunto il nodo finale', addBlock: 'Aggiungi nodo', needAnswerNode: 'Deve essere aggiunto il nodo di risposta', + tagBound: 'Numero di app che utilizzano questo tag', }, env: { envPanelTitle: 'Variabili d\'Ambiente', @@ -239,6 +240,8 @@ const translation = { 'plugin': 'Plugin', 'searchBlock': 'Cerca nodo', 'blocks': 'Nodi', + 'allAdded': 'Tutto aggiunto', + 'addAll': 'Aggiungi tutto', }, blocks: { 'start': 'Inizio', @@ -379,7 +382,10 @@ const translation = { retryFailed: 'Nuovo tentativo non riuscito', ms: 'ms', }, - typeSwitch: {}, + typeSwitch: { + input: 'Valore di input', + variable: 'Usa la variabile', + }, }, start: { required: 'richiesto', @@ -568,6 +574,7 @@ const translation = { advancedDependenciesTip: 'Aggiungi alcune dipendenze precaricate che richiedono più tempo per essere consumate o che non sono predefinite qui', searchDependencies: 'Cerca Dipendenze', + syncFunctionSignature: 'Sincronizza la firma della funzione con il codice', }, templateTransform: { inputVars: 'Variabili di Input', @@ -689,6 +696,9 @@ const translation = { json: 'json generato dallo strumento', }, authorize: 'Autorizza', + insertPlaceholder1: 'Digita o premi', + insertPlaceholder2: 'inserisci variabile', + settings: 'Impostazioni', }, questionClassifiers: { model: 'modello', @@ -878,6 +888,8 @@ const translation = { strategyNotFoundDescAndSwitchVersion: 'La versione del plugin installata non fornisce questa strategia. Fare clic per cambiare versione.', pluginNotInstalled: 'Questo plugin non è installato', pluginNotFoundDesc: 'Questo plugin viene installato da GitHub. Vai su Plugin per reinstallare', + parameterSchema: 'Schema dei parametri', + clickToViewParameterSchema: 'Clicca per visualizzare lo schema dei parametri', }, loop: { ErrorMethod: { diff --git a/web/i18n/ja-JP/app.ts b/web/i18n/ja-JP/app.ts index 2058b29d9e..7b6afc99f5 100644 --- a/web/i18n/ja-JP/app.ts +++ b/web/i18n/ja-JP/app.ts @@ -180,7 +180,10 @@ const translation = { title: '織る', description: 'Weave は、LLM アプリケーションを評価、テスト、および監視するためのオープンソースプラットフォームです。', }, - aliyun: {}, + aliyun: { + title: 'クラウドモニター', + description: 'Alibaba Cloud が提供する完全管理型でメンテナンスフリーの可観測性プラットフォームは、Dify アプリケーションの即時監視、トレース、評価を可能にします。', + }, }, answerIcon: { title: 'Web アプリアイコンを使用して🤖を置き換える', @@ -255,6 +258,8 @@ const translation = { notSetDesc: '現在この Web アプリには誰もアクセスできません。権限を設定してください。', }, noAccessPermission: 'Web アプリにアクセス権限がありません', + maxActiveRequestsPlaceholder: '無制限のために0を入力してください', + maxActiveRequests: '最大同時リクエスト数', } export default translation diff --git a/web/i18n/ja-JP/common.ts b/web/i18n/ja-JP/common.ts index 85afc3d477..74c84e616a 100644 --- a/web/i18n/ja-JP/common.ts +++ b/web/i18n/ja-JP/common.ts @@ -509,6 +509,7 @@ const translation = { addPages: 'ページの追加', preview: 'プレビュー', }, + integratedAlert: 'Notionは内部資格情報を通じて統合されており、再認証する必要はありません。', }, website: { title: 'ウェブサイト', diff --git a/web/i18n/ja-JP/plugin.ts b/web/i18n/ja-JP/plugin.ts index b886c4955c..13f29051e1 100644 --- a/web/i18n/ja-JP/plugin.ts +++ b/web/i18n/ja-JP/plugin.ts @@ -63,6 +63,7 @@ const translation = { toolLabel: '道具', unsupportedTitle: 'サポートされていないアクション', toolSetting: 'ツール設定', + unsupportedMCPTool: '現在選択されているエージェント戦略プラグインのバージョンはMCPツールをサポートしていません。', }, endpointDisableTip: 'エンドポイントを無効にする', endpointModalDesc: '設定が完了すると、API エンドポイントを介してプラグインが提供する機能を使用できます。', diff --git a/web/i18n/ja-JP/tools.ts b/web/i18n/ja-JP/tools.ts index f96a5f4182..305cfc30cd 100644 --- a/web/i18n/ja-JP/tools.ts +++ b/web/i18n/ja-JP/tools.ts @@ -85,9 +85,14 @@ const translation = { api_key: 'API キー', apiKeyPlaceholder: 'API キーの HTTP ヘッダー名', apiValuePlaceholder: 'API キーを入力してください', + api_key_query: 'クエリパラメータ', + queryParamPlaceholder: 'APIキーのクエリパラメータ名', + api_key_header: 'ヘッダー', }, key: 'キー', value: '値', + queryParam: 'クエリパラメータ', + queryParamTooltip: 'APIキーのクエリパラメータとして渡す名前、例えば「https://example.com/test?key=API_KEY」の「key」。', }, authHeaderPrefix: { title: '認証タイプ', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 04702194f8..58cd2e3f58 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -113,6 +113,7 @@ const translation = { addFailureBranch: '失敗ブランチを追加', loadMore: 'さらに読み込む', noHistory: '履歴がありません', + tagBound: 'このタグを使用しているアプリの数', }, env: { envPanelTitle: '環境変数', @@ -232,6 +233,8 @@ const translation = { 'noResult': '該当なし', 'plugin': 'プラグイン', 'agent': 'エージェント戦略', + 'addAll': 'すべてを追加する', + 'allAdded': 'すべて追加されました', }, blocks: { 'start': '開始', @@ -369,7 +372,10 @@ const translation = { ms: 'ミリ秒', retries: '再試行回数:{{num}}', }, - typeSwitch: {}, + typeSwitch: { + input: '入力値', + variable: '変数を使用する', + }, }, start: { required: '必須', @@ -677,6 +683,9 @@ const translation = { json: 'ツールで生成された JSON', }, authorize: '認証する', + settings: '設定', + insertPlaceholder1: 'タイプするか押してください', + insertPlaceholder2: '変数を挿入する', }, questionClassifiers: { model: 'モデル', @@ -893,6 +902,8 @@ const translation = { unsupportedStrategy: 'サポートされていない戦略', pluginNotFoundDesc: 'このプラグインは GitHub からインストールされています。再インストールするにはプラグインに移動してください。', strategyNotFoundDesc: 'インストールされたプラグインのバージョンは、この戦略を提供していません。', + parameterSchema: 'パラメータスキーマ', + clickToViewParameterSchema: 'パラメータースキーマを見るにはクリックしてください', }, }, tracing: { diff --git a/web/i18n/ko-KR/app.ts b/web/i18n/ko-KR/app.ts index 96407f829b..40c3183d91 100644 --- a/web/i18n/ko-KR/app.ts +++ b/web/i18n/ko-KR/app.ts @@ -192,7 +192,10 @@ const translation = { description: 'Weave 는 LLM 애플리케이션을 평가하고 테스트하며 모니터링하기 위한 오픈 소스 플랫폼입니다.', }, - aliyun: {}, + aliyun: { + title: '클라우드 모니터', + description: '알리바바 클라우드에서 제공하는 완전 관리형 및 유지보수가 필요 없는 가시성 플랫폼은 Dify 애플리케이션의 모니터링, 추적 및 평가를 즉시 사용할 수 있도록 지원합니다.', + }, }, answerIcon: { description: @@ -281,6 +284,8 @@ const translation = { }, accessControl: '웹 애플리케이션 접근 제어', noAccessPermission: '웹 앱에 대한 접근 권한이 없습니다.', + maxActiveRequests: '동시 최대 요청 수', + maxActiveRequestsPlaceholder: '무제한 사용을 원하시면 0을 입력하세요.', } export default translation diff --git a/web/i18n/ko-KR/common.ts b/web/i18n/ko-KR/common.ts index 360eb5183b..a5ae3fd733 100644 --- a/web/i18n/ko-KR/common.ts +++ b/web/i18n/ko-KR/common.ts @@ -212,6 +212,28 @@ const translation = { workspaceIcon: '작업 공간 아이콘', editWorkspaceInfo: '작업 공간 정보 편집', workspaceName: '작업 공간 이름', + changeEmail: { + codeLabel: '검증 코드', + codePlaceholder: '6자리 코드를 붙여넣으세요', + title: '이메일 변경', + emailLabel: '새 이메일', + verifyEmail: '현재 이메일을 확인하세요', + sendVerifyCode: '인증 코드를 보내다', + continue: '계속하다', + resendCount: '{{count}}초 후에 다시 보내기', + verifyNew: '새 이메일 확인하기', + emailPlaceholder: '새 이메일을 입력하세요', + resend: '다시 보내기', + newEmail: '새 이메일 주소를 설정하세요', + existingEmail: '이미 이 이메일을 가진 사용자가 존재합니다.', + content4: '우리는 방금 귀하에게 임시 인증 코드를 {{email}}로 보냈습니다.', + changeTo: '{{email}}로 변경', + content2: '현재 이메일은 {{email}}입니다. 이 이메일 주소로 인증 코드가 전송되었습니다.', + resendTip: '코드를 받지 못하셨나요?', + content3: '새로운 이메일을 입력하시면 인증 코드를 보내드립니다.', + content1: '계속 진행하면, 재인증을 위해 {{email}}로 인증 코드를 전송하겠습니다.', + authTip: '이메일이 변경되면, 이전 이메일에 연결된 Google 또는 GitHub 계정은 더 이상 이 계정에 로그인할 수 없습니다.', + }, }, members: { team: '팀', @@ -253,6 +275,26 @@ const translation = { builder: '건설자', builderTip: '자신의 앱을 구축 및 편집할 수 있습니다.', datasetOperatorTip: '기술 자료만 관리할 수 있습니다.', + transferModal: { + codeLabel: '검증 코드', + sendVerifyCode: '인증 코드를 보내다', + verifyContent: '현재 이메일은 {{email}}입니다.', + verifyEmail: '현재 이메일을 확인하세요', + continue: '계속하다', + title: '작업 공간 소유권 이전', + resend: '다시 보내기', + transferLabel: '작업 공간 소유권을 이전하다', + transferPlaceholder: '작업 공간 구성원을 선택하세요…', + warning: '당신은 "{{workspace}}"의 소유권을 이전하려고 합니다. 이는 즉시 발효되며 되돌릴 수 없습니다.', + transfer: '작업 공간 소유권 이전', + resendCount: '{{count}}초 후에 다시 보내기', + verifyContent2: '재인증을 위해 이 이메일로 임시 인증 코드를 발송하겠습니다.', + warningTip: '당신은 관리자 회원이 될 것이고, 새로운 소유자는 완전한 제어 권한을 갖게 됩니다.', + codePlaceholder: '6자리 코드를 붙여넣으세요', + resendTip: '코드를 받지 못하셨나요?', + sendTip: '계속 진행하면, 재인증을 위해 {{email}}로 인증 코드를 전송하겠습니다.', + }, + transferOwnership: '소유권 이전', }, integrations: { connected: '연결됨', @@ -444,6 +486,7 @@ const translation = { addPages: '페이지 추가하기', preview: '미리보기', }, + integratedAlert: 'Notion은 내부 자격 증명을 통해 통합되므로 다시 인증할 필요가 없습니다.', }, website: { inactive: '게으른', diff --git a/web/i18n/ko-KR/login.ts b/web/i18n/ko-KR/login.ts index da044554bc..d0a6925ee4 100644 --- a/web/i18n/ko-KR/login.ts +++ b/web/i18n/ko-KR/login.ts @@ -109,6 +109,7 @@ const translation = { noLoginMethod: '웹 애플리케이션에 대한 인증 방법이 구성되어 있지 않습니다.', disabled: '웹앱 인증이 비활성화되었습니다. 이를 활성화하려면 시스템 관리자에게 문의하십시오. 앱을 직접 사용해 볼 수 있습니다.', noLoginMethodTip: '인증 방법을 추가하려면 시스템 관리자에게 연락하십시오.', + login: '로그인', }, } diff --git a/web/i18n/ko-KR/plugin.ts b/web/i18n/ko-KR/plugin.ts index db94226a10..7cae48d76f 100644 --- a/web/i18n/ko-KR/plugin.ts +++ b/web/i18n/ko-KR/plugin.ts @@ -63,6 +63,7 @@ const translation = { paramsTip2: '\'자동\'이 꺼져 있으면 기본값이 사용됩니다.', unsupportedContent: '설치된 플러그인 버전은 이 작업을 제공하지 않습니다.', toolSetting: '도구 설정', + unsupportedMCPTool: '현재 선택된 에이전트 전략 플러그인 버전은 MCP 도구를 지원하지 않습니다.', }, configureApp: '앱 구성', strategyNum: '{{번호}} {{전략}} 포함', diff --git a/web/i18n/ko-KR/tools.ts b/web/i18n/ko-KR/tools.ts index f660790265..9ff3fe1ece 100644 --- a/web/i18n/ko-KR/tools.ts +++ b/web/i18n/ko-KR/tools.ts @@ -85,9 +85,14 @@ const translation = { api_key: 'API 키', apiKeyPlaceholder: 'API 키의 HTTP 헤더 이름', apiValuePlaceholder: 'API 키를 입력하세요', + api_key_query: '쿼리 매개변수', + queryParamPlaceholder: 'API 키에 대한 쿼리 매개변수 이름', + api_key_header: '헤더', }, key: '키', value: '값', + queryParam: '쿼리 매개변수', + queryParamTooltip: '전달할 API 키 쿼리 매개변수의 이름, 예: "https://example.com/test?key=API_KEY"에서의 "key".', }, authHeaderPrefix: { title: '인증 유형', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index 6a9f97862e..be6c78f3ef 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -119,6 +119,7 @@ const translation = { addBlock: '노드 추가', needAnswerNode: '답변 노드를 추가해야 합니다.', needEndNode: '종단 노드를 추가해야 합니다.', + tagBound: '이 태그를 사용하는 앱 수', }, env: { envPanelTitle: '환경 변수', @@ -243,6 +244,8 @@ const translation = { 'agent': '에이전트 전략', 'blocks': '노드', 'searchBlock': '검색 노드', + 'allAdded': '모두 추가됨', + 'addAll': '모두 추가', }, blocks: { 'start': '시작', @@ -388,7 +391,10 @@ const translation = { ms: '미에스', retries: '{{숫자}} 재시도', }, - typeSwitch: {}, + typeSwitch: { + input: '입력 값', + variable: '변수를 사용하세요', + }, }, start: { required: '필수', @@ -580,6 +586,7 @@ const translation = { advancedDependenciesTip: '더 많은 시간이 소요되거나 기본으로 내장되지 않은 일부 미리 로드된 종속성을 여기에 추가하세요', searchDependencies: '종속성 검색', + syncFunctionSignature: '코드에 함수 시그니처 동기화하기', }, templateTransform: { inputVars: '입력 변수', @@ -702,6 +709,9 @@ const translation = { json: '도구로 생성된 JSON', }, authorize: '권한 부여', + insertPlaceholder1: '타이프하거나 누르세요', + settings: '설정', + insertPlaceholder2: '변수를 삽입하다', }, questionClassifiers: { model: '모델', @@ -902,6 +912,8 @@ const translation = { modelNotSelected: '모델이 선택되지 않음', toolbox: '도구', linkToPlugin: '플러그인에 대한 링크', + parameterSchema: '파라미터 스키마', + clickToViewParameterSchema: '매개변수 스키마 보려면 클릭하세요.', }, loop: { ErrorMethod: { diff --git a/web/i18n/pl-PL/app.ts b/web/i18n/pl-PL/app.ts index 141ab190e0..f5fec6caeb 100644 --- a/web/i18n/pl-PL/app.ts +++ b/web/i18n/pl-PL/app.ts @@ -179,7 +179,10 @@ const translation = { title: 'Tkaj', description: 'Weave to platforma open-source do oceny, testowania i monitorowania aplikacji LLM.', }, - aliyun: {}, + aliyun: { + title: 'Monitor Chmury', + description: 'W pełni zarządzana i wolna od konserwacji platforma obserwowalności oferowana przez Alibaba Cloud umożliwia gotowe monitorowanie, śledzenie i oceny aplikacji Dify.', + }, }, answerIcon: { description: 'Czy w aplikacji udostępnionej ma być używana ikona aplikacji internetowej do zamiany 🤖.', @@ -263,6 +266,8 @@ const translation = { }, accessControl: 'Kontrola dostępu do aplikacji internetowej', noAccessPermission: 'Brak uprawnień do dostępu do aplikacji internetowej', + maxActiveRequests: 'Maksymalne równoczesne żądania', + maxActiveRequestsPlaceholder: 'Wprowadź 0, aby uzyskać nielimitowane', } export default translation diff --git a/web/i18n/pl-PL/common.ts b/web/i18n/pl-PL/common.ts index b8bf43a6b0..78c0f6e9fc 100644 --- a/web/i18n/pl-PL/common.ts +++ b/web/i18n/pl-PL/common.ts @@ -222,6 +222,28 @@ const translation = { workspaceIcon: 'Ikona robocza', workspaceName: 'Nazwa miejsca pracy', editWorkspaceInfo: 'Edytuj informacje o przestrzeni roboczej', + changeEmail: { + emailLabel: 'Nowy e-mail', + emailPlaceholder: 'Wprowadź nowy adres e-mail', + changeTo: 'Zmień na {{email}}', + sendVerifyCode: 'Wyślij kod weryfikacyjny', + resend: 'Wyślij ponownie', + title: 'Zmień e-mail', + newEmail: 'Utwórz nowy adres e-mail', + existingEmail: 'Użytkownik z tym adresem e-mail już istnieje.', + content1: 'Jeśli będziesz kontynuować, wyślemy kod weryfikacyjny na {{email}} w celu ponownej autoryzacji.', + resendCount: 'Wyślij ponownie za {{count}}s', + codeLabel: 'Kod weryfikacyjny', + codePlaceholder: 'Wklej 6-cyfrowy kod', + continue: 'Kontynuuj', + content3: 'Wprowadź nowy adres e-mail, a my wyślemy ci kod weryfikacyjny.', + verifyEmail: 'Zweryfikuj swój aktualny adres e-mail', + verifyNew: 'Zweryfikuj swój nowy adres e-mail', + resendTip: 'Nie otrzymałeś kodu?', + content2: 'Twój aktualny adres email to {{email}}. Kod weryfikacyjny został wysłany na ten adres email.', + content4: 'Właśnie wysłaliśmy Ci tymczasowy kod weryfikacyjny na {{email}}.', + authTip: 'Gdy twoje e-mail zostanie zmienione, konta Google lub GitHub powiązane z twoim starym e-mailem nie będą mogły już logować się do tego konta.', + }, }, members: { team: 'Zespół', @@ -265,6 +287,26 @@ const translation = { builder: 'Budowniczy', builderTip: 'Może tworzyć i edytować własne aplikacje', datasetOperator: 'Wiedza Admin', + transferModal: { + sendVerifyCode: 'Wyślij kod weryfikacyjny', + resend: 'Wyślij ponownie', + codePlaceholder: 'Wklej 6-cyfrowy kod', + verifyContent: 'Twój aktualny adres e-mail to {{email}}.', + continue: 'Kontynuuj', + verifyEmail: 'Zweryfikuj swój aktualny adres e-mail', + resendTip: 'Nie otrzymałeś kodu?', + transferPlaceholder: 'Wybierz członka zespołu…', + transfer: 'Przenieś własność przestrzeni roboczej', + warning: 'Zaraz przekażesz własność „{{workspace}}”. To nastąpi natychmiast i nie można tego cofnąć.', + title: 'Przenieś własność przestrzeni roboczej', + codeLabel: 'Kod weryfikacyjny', + transferLabel: 'Przenieś właśność przestrzeni roboczej na', + resendCount: 'Wyślij ponownie za {{count}}s', + verifyContent2: 'Wyślemy tymczasowy kod weryfikacyjny na ten adres e-mail w celu ponownej autoryzacji.', + sendTip: 'Jeśli będziesz kontynuować, wyślemy kod weryfikacyjny na {{email}} w celu ponownej autoryzacji.', + warningTip: 'Staniesz się członkiem administracji, a nowy właściciel będzie miał pełną kontrolę.', + }, + transferOwnership: 'Przenieś własność', }, integrations: { connected: 'Połączony', @@ -461,6 +503,7 @@ const translation = { addPages: 'Dodaj strony', preview: 'PODGLĄD', }, + integratedAlert: 'Notion jest zintegrowany za pomocą wewnętrznych poświadczeń, nie ma potrzeby ponownej autoryzacji.', }, website: { active: 'Aktywny', diff --git a/web/i18n/pl-PL/login.ts b/web/i18n/pl-PL/login.ts index b1bb0b93c6..8b63fec502 100644 --- a/web/i18n/pl-PL/login.ts +++ b/web/i18n/pl-PL/login.ts @@ -114,6 +114,7 @@ const translation = { noLoginMethod: 'Metoda uwierzytelniania nie jest skonfigurowana dla aplikacji internetowej', noLoginMethodTip: 'Proszę skontaktować się z administratorem systemu, aby dodać metodę uwierzytelniania.', disabled: 'Uwierzytelnianie aplikacji internetowej jest wyłączone. Proszę skontaktować się z administratorem systemu, aby je włączyć. Możesz spróbować użyć aplikacji bezpośrednio.', + login: 'Zaloguj się', }, } diff --git a/web/i18n/pl-PL/plugin.ts b/web/i18n/pl-PL/plugin.ts index d5c05d0df8..cef205250e 100644 --- a/web/i18n/pl-PL/plugin.ts +++ b/web/i18n/pl-PL/plugin.ts @@ -63,6 +63,7 @@ const translation = { paramsTip2: 'Gdy opcja "Auto" jest wyłączona, używana jest wartość domyślna.', toolLabel: 'Narzędzie', toolSetting: 'Ustawienia narzędzi', + unsupportedMCPTool: 'Obecnie wybrana wersja wtyczki strategii agenta nie obsługuje narzędzi MCP.', }, strategyNum: '{{liczba}} {{strategia}} ZAWARTE', endpointsEmpty: 'Kliknij przycisk "+", aby dodać punkt końcowy', diff --git a/web/i18n/pl-PL/tools.ts b/web/i18n/pl-PL/tools.ts index 183abc3f31..d72f9cafe5 100644 --- a/web/i18n/pl-PL/tools.ts +++ b/web/i18n/pl-PL/tools.ts @@ -59,9 +59,14 @@ const translation = { api_key: 'Klucz API', apiKeyPlaceholder: 'Nazwa nagłówka HTTP dla Klucza API', apiValuePlaceholder: 'Wprowadź Klucz API', + api_key_query: 'Parametr zapytania', + api_key_header: 'Nagłówek', + queryParamPlaceholder: 'Nazwa parametru zapytania dla klucza API', }, key: 'Klucz', value: 'Wartość', + queryParam: 'Parametr zapytania', + queryParamTooltip: 'Nazwa parametru zapytania klucza API do przekazania, np. "key" w "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'Typ autoryzacji', diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index 6d1c9ccc8c..bd47328a65 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -115,6 +115,7 @@ const translation = { addBlock: 'Dodaj węzeł', needEndNode: 'Należy dodać węzeł końcowy', needAnswerNode: 'Węzeł odpowiedzi musi zostać dodany', + tagBound: 'Liczba aplikacji korzystających z tego tagu', }, env: { envPanelTitle: 'Zmienne Środowiskowe', @@ -234,6 +235,8 @@ const translation = { 'plugin': 'Wtyczka', 'searchBlock': 'Wyszukaj węzeł', 'blocks': 'Węzły', + 'addAll': 'Dodaj wszystko', + 'allAdded': 'Wszystko dodane', }, blocks: { 'start': 'Start', @@ -364,7 +367,10 @@ const translation = { retryFailedTimes: '{{times}} ponawianie prób nie powiodło się', ms: 'Ms', }, - typeSwitch: {}, + typeSwitch: { + variable: 'Użyj zmiennej', + input: 'Wartość wejściowa', + }, }, start: { required: 'wymagane', @@ -551,6 +557,7 @@ const translation = { advancedDependencies: 'Zaawansowane zależności', advancedDependenciesTip: 'Dodaj niektóre preładowane zależności, które zajmują więcej czasu lub nie są domyślnie wbudowane', searchDependencies: 'Wyszukaj zależności', + syncFunctionSignature: 'Zsynchronizuj sygnaturę funkcji z kodem', }, templateTransform: { inputVars: 'Zmienne wejściowe', @@ -670,6 +677,9 @@ const translation = { json: 'JSON wygenerowany przez narzędzien', }, authorize: 'Autoryzuj', + insertPlaceholder2: 'wstaw zmienną', + settings: 'Ustawienia', + insertPlaceholder1: 'Wpisz lub naciśnij', }, questionClassifiers: { model: 'model', @@ -854,6 +864,8 @@ const translation = { learnMore: 'Dowiedz się więcej', strategyNotSet: 'Nie ustawiono strategii agentalnej', model: 'model', + parameterSchema: 'Schemat parametrów', + clickToViewParameterSchema: 'Kliknij, aby zobaczyć schemat parametrów', }, loop: { ErrorMethod: { diff --git a/web/i18n/pt-BR/app.ts b/web/i18n/pt-BR/app.ts index 099d0a33ea..980767316f 100644 --- a/web/i18n/pt-BR/app.ts +++ b/web/i18n/pt-BR/app.ts @@ -172,7 +172,10 @@ const translation = { description: 'Weave é uma plataforma de código aberto para avaliar, testar e monitorar aplicações de LLM.', title: 'Trançar', }, - aliyun: {}, + aliyun: { + title: 'Monitoramento em Nuvem', + description: 'A plataforma de observabilidade totalmente gerenciada e sem manutenção fornecida pela Alibaba Cloud, permite monitoramento, rastreamento e avaliação prontos para uso de aplicações Dify.', + }, }, answerIcon: { descriptionInExplore: 'Se o ícone do web app deve ser usado para substituir 🤖 no Explore', @@ -256,6 +259,8 @@ const translation = { }, accessControl: 'Controle de Acesso do Aplicativo Web', noAccessPermission: 'Sem permissão para acessar o aplicativo web', + maxActiveRequestsPlaceholder: 'Digite 0 para ilimitado', + maxActiveRequests: 'Máximo de solicitações simultâneas', } export default translation diff --git a/web/i18n/pt-BR/common.ts b/web/i18n/pt-BR/common.ts index 5bdf3388e1..8166f9d28c 100644 --- a/web/i18n/pt-BR/common.ts +++ b/web/i18n/pt-BR/common.ts @@ -216,6 +216,28 @@ const translation = { workspaceName: 'Nome do Espaço de Trabalho', workspaceIcon: 'Ícone de Área de Trabalho', editWorkspaceInfo: 'Editar Informações do Espaço de Trabalho', + changeEmail: { + verifyEmail: 'Verifique seu email atual', + resendCount: 'Reenviar em {{count}}s', + sendVerifyCode: 'Enviar código de verificação', + emailPlaceholder: 'Digite um novo email', + emailLabel: 'Novo e-mail', + resend: 'Reenviar', + codeLabel: 'Código de verificação', + content3: 'Digite um novo e-mail e nós enviaremos um código de verificação.', + codePlaceholder: 'Cole o código de 6 dígitos', + resendTip: 'Não recebeu um código?', + verifyNew: 'Verifique seu novo e-mail', + changeTo: 'Mudar para {{email}}', + continue: 'Continue', + title: 'Mudar E-mail', + content4: 'Acabamos de enviar um código de verificação temporário para {{email}}.', + existingEmail: 'Um usuário com este e-mail já existe.', + authTip: 'Uma vez que seu e-mail seja alterado, as contas do Google ou GitHub vinculadas ao seu e-mail antigo não poderão mais fazer login nesta conta.', + newEmail: 'Crie um novo endereço de e-mail', + content2: 'Seu email atual é {{email}}. O código de verificação foi enviado para este endereço de email.', + content1: 'Se você continuar, enviaremos um código de verificação para {{email}} para reautenticação.', + }, }, members: { team: 'Equipe', @@ -257,6 +279,26 @@ const translation = { setBuilder: 'Definir como construtor', builderTip: 'Pode criar e editar seus próprios aplicativos', datasetOperator: 'Administrador de conhecimento', + transferModal: { + verifyEmail: 'Verifique seu email atual', + resendCount: 'Reenviar em {{count}}s', + codeLabel: 'Código de verificação', + title: 'Transferir a propriedade do espaço de trabalho', + transferPlaceholder: 'Selecione um membro do espaço de trabalho…', + continue: 'Continue', + resendTip: 'Não recebeu um código?', + warningTip: 'Você se tornará um membro administrador, e o novo proprietário terá controle total.', + verifyContent: 'Seu email atual é {{email}}.', + transfer: 'Transferir a propriedade do espaço de trabalho', + resend: 'Reenviar', + verifyContent2: 'Enviaremos um código de verificação temporário para este email para reautenticação.', + codePlaceholder: 'Cole o código de 6 dígitos', + warning: 'Você está prestes a transferir a propriedade de "{{workspace}}". Isso entra em vigor imediatamente e não pode ser desfeito.', + transferLabel: 'Transferir a propriedade do espaço de trabalho para', + sendTip: 'Se você continuar, enviaremos um código de verificação para {{email}} para reautenticação.', + sendVerifyCode: 'Enviar código de verificação', + }, + transferOwnership: 'Transferir Propriedade', }, integrations: { connected: 'Conectado', @@ -448,6 +490,7 @@ const translation = { addPages: 'Adicionar páginas', preview: 'PRÉ-VISUALIZAÇÃO', }, + integratedAlert: 'O Notion está integrado através de credenciais internas, não é necessário reautorizar.', }, website: { inactive: 'Inativo', diff --git a/web/i18n/pt-BR/login.ts b/web/i18n/pt-BR/login.ts index 0880b4776e..290cd3c8b4 100644 --- a/web/i18n/pt-BR/login.ts +++ b/web/i18n/pt-BR/login.ts @@ -109,6 +109,7 @@ const translation = { noLoginMethod: 'Método de autenticação não configurado para o aplicativo web', disabled: 'A autenticação do aplicativo da web está desativada. Por favor, entre em contato com o administrador do sistema para habilitá-la. Você pode tentar usar o aplicativo diretamente.', noLoginMethodTip: 'Por favor, entre em contato com o administrador do sistema para adicionar um método de autenticação.', + login: 'Entrar', }, } diff --git a/web/i18n/pt-BR/plugin.ts b/web/i18n/pt-BR/plugin.ts index be8e7e7f97..a7fc976d0b 100644 --- a/web/i18n/pt-BR/plugin.ts +++ b/web/i18n/pt-BR/plugin.ts @@ -63,6 +63,7 @@ const translation = { uninstalledTitle: 'Ferramenta não instalada', unsupportedTitle: 'Ação sem suporte', toolSetting: 'Configurações da Ferramenta', + unsupportedMCPTool: 'A versão atual do plugin de estratégia do agente selecionado não suporta ferramentas MCP.', }, serviceOk: 'Serviço OK', endpointsTip: 'Este plug-in fornece funcionalidades específicas por meio de endpoints e você pode configurar vários conjuntos de endpoints para o workspace atual.', diff --git a/web/i18n/pt-BR/tools.ts b/web/i18n/pt-BR/tools.ts index bd57de362f..aa9df17c69 100644 --- a/web/i18n/pt-BR/tools.ts +++ b/web/i18n/pt-BR/tools.ts @@ -57,9 +57,14 @@ const translation = { api_key: 'Chave de API', apiKeyPlaceholder: 'Nome do cabeçalho HTTP para a Chave de API', apiValuePlaceholder: 'Digite a Chave de API', + api_key_query: 'Parâmetro de consulta', + queryParamPlaceholder: 'Nome do parâmetro de consulta para a chave da API', + api_key_header: 'Cabeçalho', }, key: 'Chave', value: 'Valor', + queryParam: 'Parâmetro de consulta', + queryParamTooltip: 'O nome do parâmetro de consulta da chave da API a ser passado, por exemplo, "key" em "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'Tipo de Autenticação', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index 1cb323f59a..f36e3b8499 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -115,6 +115,7 @@ const translation = { addBlock: 'Adicionar Nó', needEndNode: 'O nó de Fim deve ser adicionado', needAnswerNode: 'O nó de resposta deve ser adicionado', + tagBound: 'Número de aplicativos usando esta tag', }, env: { envPanelTitle: 'Variáveis de Ambiente', @@ -234,6 +235,8 @@ const translation = { 'agent': 'Estratégia do agente', 'blocks': 'Nodos', 'searchBlock': 'Nó de busca', + 'addAll': 'Adicionar tudo', + 'allAdded': 'Todos adicionados', }, blocks: { 'start': 'Iniciar', @@ -364,7 +367,10 @@ const translation = { ms: 'ms', retries: '{{num}} Tentativas', }, - typeSwitch: {}, + typeSwitch: { + variable: 'Use variável', + input: 'Valor de entrada', + }, }, start: { required: 'requerido', @@ -551,6 +557,7 @@ const translation = { advancedDependencies: 'Dependências avançadas', advancedDependenciesTip: 'Adicione algumas dependências pré-carregadas que levam mais tempo para consumir ou não são padrão aqui', searchDependencies: 'Buscar dependências', + syncFunctionSignature: 'Sincronizar a assinatura da função com o código', }, templateTransform: { inputVars: 'Variáveis de entrada', @@ -670,6 +677,9 @@ const translation = { json: 'JSON gerado por ferramenta', }, authorize: 'Autorizar', + insertPlaceholder2: 'inserir variável', + insertPlaceholder1: 'Digite ou pressione', + settings: 'Configurações', }, questionClassifiers: { model: 'modelo', @@ -854,6 +864,8 @@ const translation = { tools: 'Ferramentas', toolNotAuthorizedTooltip: '{{ferramenta}} Não autorizado', toolbox: 'caixa de ferramentas', + parameterSchema: 'Esquema de Parâmetro', + clickToViewParameterSchema: 'Clique para ver o esquema de parâmetros', }, loop: { ErrorMethod: { diff --git a/web/i18n/ro-RO/app.ts b/web/i18n/ro-RO/app.ts index 2d63bdfba7..a32b8c3c0f 100644 --- a/web/i18n/ro-RO/app.ts +++ b/web/i18n/ro-RO/app.ts @@ -174,6 +174,7 @@ const translation = { }, aliyun: { description: 'Platforma de observabilitate SaaS oferită de Alibaba Cloud permite monitorizarea, urmărirea și evaluarea aplicațiilor Dify din cutie.', + title: 'Monitorizarea Cloud', }, }, answerIcon: { @@ -258,6 +259,8 @@ const translation = { }, accessControl: 'Controlul Accesului la Aplicația Web', noAccessPermission: 'Nici o permisiune pentru a accesa aplicația web', + maxActiveRequestsPlaceholder: 'Introduceți 0 pentru nelimitat', + maxActiveRequests: 'Maxime cereri simultane', } export default translation diff --git a/web/i18n/ro-RO/common.ts b/web/i18n/ro-RO/common.ts index 168d201e0d..dbc00bb134 100644 --- a/web/i18n/ro-RO/common.ts +++ b/web/i18n/ro-RO/common.ts @@ -216,6 +216,28 @@ const translation = { workspaceName: 'Numele spațiului de lucru', editWorkspaceInfo: 'Editează informațiile spațiului de lucru', workspaceIcon: 'Iconița de spațiu de lucru', + changeEmail: { + continue: 'Continuați', + newEmail: 'Creează o nouă adresă de email', + verifyEmail: 'Verifică-ți adresa de email curentă', + verifyNew: 'Verifică-ți noul email', + codePlaceholder: 'Introduceți codul de 6 cifre', + resendTip: 'Nu ai primit un cod?', + codeLabel: 'Cod de verificare', + resendCount: 'Reexpediază în {{count}}s', + resend: 'Retrimite', + title: 'Schimbă emailul', + changeTo: 'Schimbă la {{email}}', + existingEmail: 'Un utilizator cu acest email există deja.', + emailPlaceholder: 'Introduceți un email nou', + content3: 'Introduceți un nou email și vă vom trimite un cod de verificare.', + sendVerifyCode: 'Trimite codul de verificare', + content1: 'Dacă continui, vom trimite un cod de verificare la {{email}} pentru reautentificare.', + authTip: 'Odată ce adresa ta de email este schimbată, conturile Google sau GitHub legate de vechea ta adresă de email nu vor mai putea să se conecteze la acest cont.', + content4: 'Tocmai ți-am trimis un cod de verificare temporar la {{email}}.', + content2: 'Adresa ta de email curentă este {{email}}. Codul de verificare a fost trimis la această adresă de email.', + emailLabel: 'Email nou', + }, }, members: { team: 'Echipă', @@ -257,6 +279,26 @@ const translation = { datasetOperator: 'Administrator de cunoștințe', setBuilder: 'Setare ca constructor', builderTip: 'Poate construi și edita propriile aplicații', + transferModal: { + resendTip: 'Nu ai primit un cod?', + title: 'Transferați proprietatea spațiului de lucru', + warningTip: 'Vei deveni membru administrator, iar noul proprietar va avea control total.', + resendCount: 'Reexpediază în {{count}}s', + transferLabel: 'Transferați proprietatea spațiului de lucru către', + resend: 'Retrimite', + transfer: 'Transferați proprietatea spațiului de lucru', + transferPlaceholder: 'Selectați un membru al spațiului de lucru…', + continue: 'Continuați', + codeLabel: 'Cod de verificare', + verifyContent2: 'Îți vom trimite un cod temporar de verificare pe acest email pentru reautentificare.', + sendTip: 'Dacă continui, vom trimite un cod de verificare la {{email}} pentru reautentificare.', + verifyContent: 'Adresa ta de email curentă este {{email}}.', + warning: 'Ești pe cale să transferi proprietatea „{{workspace}}”. Acest lucru va avea efect imediat și nu poate fi desfăcut.', + sendVerifyCode: 'Trimite codul de verificare', + verifyEmail: 'Verifică-ți adresa de email curentă', + codePlaceholder: 'Introduceți codul de 6 cifre', + }, + transferOwnership: 'Transferați proprietatea', }, integrations: { connected: 'Conectat', @@ -448,6 +490,7 @@ const translation = { addPages: 'Adăugați pagini', preview: 'PREVIZUALIZARE', }, + integratedAlert: 'Notion este integrat prin credențiale interne, nu este nevoie să re-autorizăm.', }, website: { inactive: 'Inactiv', diff --git a/web/i18n/ro-RO/login.ts b/web/i18n/ro-RO/login.ts index 6a6a6edc64..342010a10e 100644 --- a/web/i18n/ro-RO/login.ts +++ b/web/i18n/ro-RO/login.ts @@ -109,6 +109,7 @@ const translation = { noLoginMethod: 'Metoda de autentificare nu este configurată pentru aplicația web', noLoginMethodTip: 'Vă rugăm să contactați administratorul sistemului pentru a adăuga o metodă de autentificare.', disabled: 'Autentificarea webapp-ului este dezactivată. Vă rugăm să contactați administratorul sistemului pentru a o activa. Puteți încerca să folosiți aplicația direct.', + login: 'Conectare', }, } diff --git a/web/i18n/ro-RO/plugin.ts b/web/i18n/ro-RO/plugin.ts index 1c7d173f8f..37c33ab2fd 100644 --- a/web/i18n/ro-RO/plugin.ts +++ b/web/i18n/ro-RO/plugin.ts @@ -63,6 +63,7 @@ const translation = { toolLabel: 'Unealtă', uninstalledTitle: 'Instrumentul nu este instalat', toolSetting: 'Setările instrumentului', + unsupportedMCPTool: 'Versiunea pluginului de strategie a agentului selectat în prezent nu suportă uneltele MCP.', }, endpointDeleteContent: 'Doriți să eliminați {{name}}?', strategyNum: '{{num}} {{strategie}} INCLUS', diff --git a/web/i18n/ro-RO/tools.ts b/web/i18n/ro-RO/tools.ts index 8d8c77a911..9add6aae46 100644 --- a/web/i18n/ro-RO/tools.ts +++ b/web/i18n/ro-RO/tools.ts @@ -57,9 +57,14 @@ const translation = { api_key: 'Cheie API', apiKeyPlaceholder: 'Nume antet HTTP pentru cheia API', apiValuePlaceholder: 'Introduceți cheia API', + api_key_header: 'Antet', + api_key_query: 'Parametru de interogare', + queryParamPlaceholder: 'Numele parametrului de interogare pentru cheia API', }, key: 'Cheie', value: 'Valoare', + queryParam: 'Parametru de interogare', + queryParamTooltip: 'Numele parametrului de interogare pentru cheia API care trebuie transmis, de exemplu, "key" în "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'Tipul de Autentificare', diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index 886dc3b790..2569d5339c 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -115,6 +115,7 @@ const translation = { addBlock: 'Adaugă nod', needAnswerNode: 'Nodul de răspuns trebuie adăugat', needEndNode: 'Nodul de sfârșit trebuie adăugat', + tagBound: 'Numărul de aplicații care folosesc acest tag', }, env: { envPanelTitle: 'Variabile de Mediu', @@ -234,6 +235,8 @@ const translation = { 'plugin': 'Plugin', 'blocks': 'Noduri', 'searchBlock': 'Căutare nod', + 'addAll': 'Adaugă tot', + 'allAdded': 'Toate adăugate', }, blocks: { 'start': 'Începe', @@ -364,7 +367,10 @@ const translation = { retries: '{{num}} Încercări', retryTimes: 'Reîncercați {{times}} ori în caz de eșec', }, - typeSwitch: {}, + typeSwitch: { + variable: 'Folosește variabila', + input: 'Valoare de intrare', + }, }, start: { required: 'necesar', @@ -551,6 +557,7 @@ const translation = { advancedDependencies: 'Dependențe avansate', advancedDependenciesTip: 'Adăugați câteva dependențe preîncărcate care necesită mai mult timp pentru a consuma sau nu sunt integrate implicit aici', searchDependencies: 'Căutați dependențe', + syncFunctionSignature: 'Sincronizați semnătura funcției cu codul', }, templateTransform: { inputVars: 'Variabile de intrare', @@ -670,6 +677,9 @@ const translation = { json: 'JSON generat de instrument', }, authorize: 'Autorizați', + insertPlaceholder2: 'introduce o variabilă', + insertPlaceholder1: 'Scrieți sau apăsați', + settings: 'Setări', }, questionClassifiers: { model: 'model', @@ -854,6 +864,8 @@ const translation = { modelNotInstallTooltip: 'Acest model nu este instalat', linkToPlugin: 'Link către pluginuri', model: 'model', + parameterSchema: 'Schema parametrului', + clickToViewParameterSchema: 'Click pentru a vizualiza schema parametrilor', }, loop: { ErrorMethod: { diff --git a/web/i18n/ru-RU/app.ts b/web/i18n/ru-RU/app.ts index de8047b080..16bdfd9b4a 100644 --- a/web/i18n/ru-RU/app.ts +++ b/web/i18n/ru-RU/app.ts @@ -176,7 +176,10 @@ const translation = { description: 'Weave — это открытая платформа для оценки, тестирования и мониторинга приложений LLM.', title: 'Ткать', }, - aliyun: {}, + aliyun: { + title: 'Облачный монитор', + description: 'Полностью управляемая и не требующая обслуживания платформа наблюдения, предоставляемая Alibaba Cloud, обеспечивает мониторинг, трассировку и оценку приложений Dify из коробки.', + }, }, answerIcon: { title: 'Использование значка web app для замены 🤖', @@ -256,6 +259,8 @@ const translation = { }, accessControl: 'Управление доступом к веб-приложению', noAccessPermission: 'Нет разрешения на доступ к веб-приложению', + maxActiveRequests: 'Максимальное количество параллельных запросов', + maxActiveRequestsPlaceholder: 'Введите 0 для неограниченного количества', } export default translation diff --git a/web/i18n/ru-RU/common.ts b/web/i18n/ru-RU/common.ts index c25de3beef..442efa3782 100644 --- a/web/i18n/ru-RU/common.ts +++ b/web/i18n/ru-RU/common.ts @@ -220,6 +220,28 @@ const translation = { workspaceIcon: 'Иконка рабочего пространства', workspaceName: 'Название рабочего пространства', editWorkspaceInfo: 'Редактировать информацию о рабочем пространстве', + changeEmail: { + resendTip: 'Не получили код?', + codePlaceholder: 'Вставьте 6-значный код', + emailLabel: 'Новое письмо', + codeLabel: 'Код подтверждения', + resend: 'Переслать', + continue: 'Продолжайте', + emailPlaceholder: 'Введите новый адрес электронной почты', + resendCount: 'Отправьте снова через {{count}}с', + newEmail: 'Создайте новый адрес электронной почты', + sendVerifyCode: 'Отправить код проверки', + title: 'Сменить электронную почту', + changeTo: 'Изменить на {{email}}', + existingEmail: 'Пользователь с этим адресом электронной почты уже существует.', + verifyNew: 'Подтвердите ваш новый адрес электронной почты', + verifyEmail: 'Подтвердите ваш текущий адрес электронной почты', + content2: 'Ваш текущий электронный адрес: {{email}}. Код подтверждения был отправлен на этот адрес электронной почты.', + content4: 'Мы только что отправили вам временный код подтверждения на {{email}}.', + content3: 'Введите новый адрес электронной почты, и мы отправим вам код подтверждения.', + content1: 'Если вы продолжите, мы отправим код подтверждения на {{email}} для повторной аутентификации.', + authTip: 'После изменения вашего адреса электронной почты учетные записи Google или GitHub, связанные с вашим старым адресом, больше не смогут войти в эту учетную запись.', + }, }, members: { team: 'Команда', @@ -261,6 +283,26 @@ const translation = { disInvite: 'Отменить приглашение', deleteMember: 'Удалить участника', you: '(Вы)', + transferModal: { + sendVerifyCode: 'Отправить код проверки', + transferPlaceholder: 'Выберите участника рабочего пространства…', + resendCount: 'Отправьте снова через {{count}}с', + resend: 'Переслать', + codePlaceholder: 'Вставьте 6-значный код', + resendTip: 'Не получили код?', + continue: 'Продолжайте', + transfer: 'Передать права собственности на рабочую область', + warningTip: 'Вы станете администратором, и новый владелец получит полный контроль.', + transferLabel: 'Передать право собственности на рабочее пространство на', + codeLabel: 'Код подтверждения', + verifyContent2: 'Мы отправим временный код для проверки на этот электронный адрес для повторной аутентификации.', + verifyEmail: 'Подтвердите ваш текущий адрес электронной почты', + verifyContent: 'Ваш текущий адрес электронной почты: {{email}}.', + title: 'Передать права собственности на рабочую область', + warning: 'Вы собираетесь передать право собственности на «{{workspace}}». Это вступает в силу немедленно и не может быть отменено.', + sendTip: 'Если вы продолжите, мы отправим код подтверждения на {{email}} для повторной аутентификации.', + }, + transferOwnership: 'Передать право собственности', }, integrations: { connected: 'Подключено', @@ -453,6 +495,7 @@ const translation = { addPages: 'Добавить страницы', preview: 'ПРЕДПРОСМОТР', }, + integratedAlert: 'Notion интегрирован через внутренние учетные данные, нет необходимости повторной авторизации.', }, website: { title: 'Веб-сайт', diff --git a/web/i18n/ru-RU/login.ts b/web/i18n/ru-RU/login.ts index 9c623fe5b6..38e4559012 100644 --- a/web/i18n/ru-RU/login.ts +++ b/web/i18n/ru-RU/login.ts @@ -109,6 +109,7 @@ const translation = { noLoginMethod: 'Метод аутентификации не настроен для веб-приложения', noLoginMethodTip: 'Пожалуйста, свяжитесь с администратором системы, чтобы добавить метод аутентификации.', disabled: 'Аутентификация веб-приложения отключена. Пожалуйста, свяжитесь с администратором системы, чтобы включить ее. Вы можете попробовать использовать приложение напрямую.', + login: 'Вход', }, } diff --git a/web/i18n/ru-RU/plugin.ts b/web/i18n/ru-RU/plugin.ts index 0bb6c8232e..fa17ee4685 100644 --- a/web/i18n/ru-RU/plugin.ts +++ b/web/i18n/ru-RU/plugin.ts @@ -63,6 +63,7 @@ const translation = { unsupportedContent2: 'Нажмите, чтобы переключить версию.', uninstalledLink: 'Управление в плагинах', toolSetting: 'Настройки инструмента', + unsupportedMCPTool: 'В настоящее время выбранная версия плагина стратегий агента не поддерживает инструменты MCP.', }, configureTool: 'Инструмент настройки', endpointsTip: 'Этот плагин предоставляет определенные функциональные возможности через конечные точки, и вы можете настроить несколько наборов конечных точек для текущей рабочей области.', diff --git a/web/i18n/ru-RU/tools.ts b/web/i18n/ru-RU/tools.ts index caa1959318..e20e5664d8 100644 --- a/web/i18n/ru-RU/tools.ts +++ b/web/i18n/ru-RU/tools.ts @@ -85,9 +85,14 @@ const translation = { api_key: 'Ключ API', apiKeyPlaceholder: 'Название заголовка HTTP для ключа API', apiValuePlaceholder: 'Введите ключ API', + api_key_header: 'Заголовок', + queryParamPlaceholder: 'Имя параметра запроса для API-ключа', + api_key_query: 'Параметр запроса', }, key: 'Ключ', value: 'Значение', + queryParam: 'Параметр запроса', + queryParamTooltip: 'Название параметра запроса API-ключа, который нужно передать, например, "key" в "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'Тип авторизации', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index aecd9e652c..d8452122ad 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -115,6 +115,7 @@ const translation = { addBlock: 'Добавить узел', needAnswerNode: 'В узел ответа необходимо добавить', needEndNode: 'Узел конца должен быть добавлен', + tagBound: 'Количество приложений, использующих этот тег', }, env: { envPanelTitle: 'Переменные среды', @@ -234,6 +235,8 @@ const translation = { 'agent': 'Агентская стратегия', 'blocks': 'Узлы', 'searchBlock': 'Поиск узла', + 'addAll': 'Добавить всё', + 'allAdded': 'Все добавлено', }, blocks: { 'start': 'Начало', @@ -364,7 +367,10 @@ const translation = { retryFailedTimes: 'Повторные попытки {{times}} не увенчались успехом', retries: '{{число}} Повторных попыток', }, - typeSwitch: {}, + typeSwitch: { + input: 'Входное значение', + variable: 'Используйте переменную', + }, }, start: { required: 'обязательно', @@ -551,6 +557,7 @@ const translation = { advancedDependencies: 'Расширенные зависимости', advancedDependenciesTip: 'Добавьте сюда некоторые предварительно загруженные зависимости, которые занимают больше времени для потребления или не являются встроенными по умолчанию', searchDependencies: 'Поиск зависимостей', + syncFunctionSignature: 'Синхронизировать сигнатуру функции с кодом', }, templateTransform: { inputVars: 'Входные переменные', @@ -670,6 +677,9 @@ const translation = { json: 'json, сгенерированный инструментом', }, authorize: 'Авторизовать', + settings: 'Настройки', + insertPlaceholder2: 'вставьте переменную', + insertPlaceholder1: 'Наберите или нажмите', }, questionClassifiers: { model: 'модель', @@ -854,6 +864,8 @@ const translation = { pluginNotFoundDesc: 'Этот плагин устанавливается с GitHub. Пожалуйста, перейдите в раздел Плагины для переустановки', configureModel: 'Сконфигурировать модель', maxIterations: 'Максимальное количество итераций', + parameterSchema: 'Схема параметров', + clickToViewParameterSchema: 'Нажмите, чтобы просмотреть схему параметров', }, loop: { ErrorMethod: { diff --git a/web/i18n/sl-SI/app.ts b/web/i18n/sl-SI/app.ts index 152b7d63dc..a68b4128e1 100644 --- a/web/i18n/sl-SI/app.ts +++ b/web/i18n/sl-SI/app.ts @@ -181,7 +181,10 @@ const translation = { title: 'Tkanje', description: 'Weave je odprtokodna platforma za vrednotenje, testiranje in spremljanje aplikacij LLM.', }, - aliyun: {}, + aliyun: { + title: 'Oblačni nadzor', + description: 'Popolnoma upravljana in brez vzdrževanja platforma za opazovanje, ki jo zagotavlja Alibaba Cloud, omogoča takojšnje spremljanje, sledenje in ocenjevanje aplikacij Dify.', + }, }, mermaid: { handDrawn: 'Ročno narisano', @@ -256,6 +259,8 @@ const translation = { }, accessControl: 'Nadzor dostopa do spletne aplikacije', noAccessPermission: 'Brez dovoljenja za dostop do spletne aplikacije', + maxActiveRequestsPlaceholder: 'Vnesite 0 za neomejeno', + maxActiveRequests: 'Maksimalno število hkratnih zahtevkov', } export default translation diff --git a/web/i18n/sl-SI/common.ts b/web/i18n/sl-SI/common.ts index 2da1acdbf9..43fe94b4d0 100644 --- a/web/i18n/sl-SI/common.ts +++ b/web/i18n/sl-SI/common.ts @@ -220,6 +220,28 @@ const translation = { workspaceName: 'Ime delovnega prostora', workspaceIcon: 'Ikona delovnega prostora', editWorkspaceInfo: 'Uredi informacije o delovnem prostoru', + changeEmail: { + emailPlaceholder: 'Vnesite nov e-poštni naslov', + title: 'Spremeni e-pošto', + changeTo: 'Spremeni v {{email}}', + continue: 'Nadaljuj', + codeLabel: 'Koda za potrditev', + sendVerifyCode: 'Pošlji kode za preverjanje', + verifyNew: 'Potrdite svoj nov email', + resend: 'Ponovno pošlji', + resendTip: 'Niste prejeli kode?', + resendCount: 'Ponovno pošlji čez {{count}}s', + emailLabel: 'Nova e-pošta', + existingEmail: 'Uporabnik s to e-pošto že obstaja.', + content4: 'Pravkar smo vam poslali začasno verifikacijsko kodo na {{email}}.', + content1: 'Če boste nadaljevali, bomo na {{email}} poslali verifikacijsko kodo za ponovno overitev.', + codePlaceholder: 'Prilepite 6-mestno kodo', + verifyEmail: 'Potrdite svoj trenutni e-poštni naslov', + content3: 'Vnesite nov e-poštni naslov in poslali vam bomo kodo za preverjanje.', + newEmail: 'Ustvarite nov e-poštni naslov', + content2: 'Vaš trenutni elektronski naslov je {{email}}. Koda za preverjanje je bila poslana na ta elektronski naslov.', + authTip: 'Ko bo vaš e-poštni naslov spremenjen, se računi Google ali GitHub, povezani z vašim starim e-poštnim naslovom, ne bodo mogli več prijaviti v ta račun.', + }, }, members: { team: 'Ekipa', @@ -261,6 +283,26 @@ const translation = { disInvite: 'Prekliči povabilo', deleteMember: 'Izbriši člana', you: '(Vi)', + transferModal: { + codeLabel: 'Koda za potrditev', + resendTip: 'Niste prejeli kode?', + continue: 'Nadaljuj', + transferLabel: 'Prenesite lastništvo delovnega prostora na', + resend: 'Ponovno pošlji', + verifyEmail: 'Potrdite svoj trenutni e-poštni naslov', + codePlaceholder: 'Prilepite 6-mestno kodo', + transferPlaceholder: 'Izberite člana delovnega prostora…', + transfer: 'Prenos lastništva delovnega prostora', + resendCount: 'Ponovno pošlji čez {{count}}s', + verifyContent2: 'Na ta e-poštni naslov bomo poslali začasno verifikacijsko kodo za ponovno overitev.', + warningTip: 'Postali boste član administracije, novi lastnik pa bo imel popoln nadzor.', + warning: 'Pripravljate se prenesti lastništvo nad "{{workspace}}". To začne veljati takoj in tega ni mogoče razveljaviti.', + title: 'Prenos lastništva delovnega prostora', + sendVerifyCode: 'Pošlji kode za preverjanje', + sendTip: 'Če boste nadaljevali, bomo na {{email}} poslali verifikacijsko kodo za ponovno overitev.', + verifyContent: 'Vaš trenutni e-poštni naslov je {{email}}.', + }, + transferOwnership: 'Prenos lastništva', }, integrations: { connected: 'Povezano', @@ -648,6 +690,7 @@ const translation = { title: 'Pojem', changeAuthorizedPages: 'Spreminjanje pooblaščenih strani', sync: 'Sinhroniziranje', + integratedAlert: 'Notion je integriran prek notranjih poverilnic, ni potrebno ponovno avtorizirati.', }, website: { active: 'Dejaven', diff --git a/web/i18n/sl-SI/login.ts b/web/i18n/sl-SI/login.ts index 12b424b0d7..479b8b9221 100644 --- a/web/i18n/sl-SI/login.ts +++ b/web/i18n/sl-SI/login.ts @@ -109,6 +109,7 @@ const translation = { noLoginMethod: 'Metoda overjanja ni nastavljena za spletno aplikacijo', noLoginMethodTip: 'Prosimo, da se obrnete na sistemskega skrbnika, da dodate metodo za avtentikacijo.', disabled: 'Avtentikacija v spletni aplikaciji je onemogočena. Prosimo, kontaktirajte skrbnika sistema, da jo omogoči. Poskusite lahko neposredno uporabljati aplikacijo.', + login: 'Prijava', }, } diff --git a/web/i18n/sl-SI/plugin.ts b/web/i18n/sl-SI/plugin.ts index e3be40c4d5..e1abaf8e97 100644 --- a/web/i18n/sl-SI/plugin.ts +++ b/web/i18n/sl-SI/plugin.ts @@ -66,6 +66,7 @@ const translation = { paramsTip1: 'Nadzoruje parametre sklepanja LLM.', paramsTip2: 'Ko je \'Avtomatsko\' izklopljeno, se uporablja privzeta vrednost.', toolSetting: 'Nastavitve orodja', + unsupportedMCPTool: 'Trenutno izbrana različica vtičnika strategije agenta ne podpira orodij MCP.', }, endpointDisableContent: 'Ali želite onemogočiti {{name}}?', serviceOk: 'Storitve so v redu', diff --git a/web/i18n/sl-SI/tools.ts b/web/i18n/sl-SI/tools.ts index d83f218f68..4bf3b607cc 100644 --- a/web/i18n/sl-SI/tools.ts +++ b/web/i18n/sl-SI/tools.ts @@ -85,9 +85,14 @@ const translation = { api_key: 'API ključ', apiKeyPlaceholder: 'Ime HTTP glave za API ključ', apiValuePlaceholder: 'Vnesite API ključ', + api_key_query: 'Vprašanje Param', + queryParamPlaceholder: 'Ime poizvedbenega parametra za API ključ', + api_key_header: 'Naslov', }, key: 'Ključ', value: 'Vrednost', + queryParam: 'Parametri poizvedbe', + queryParamTooltip: 'Ime poizvedbenega parametra API ključa, ki ga je treba posredovati, npr. "key" v "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'Vrsta avtorizacije', diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index a7c2626264..125d82e78f 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -113,6 +113,7 @@ const translation = { autoSaved: 'Samodejno shranjeno', configure: 'Konfiguriraj', inRunMode: 'V načinu izvajanja', + tagBound: 'Število aplikacij, ki uporabljajo to oznako', }, env: { modal: { @@ -231,6 +232,8 @@ const translation = { 'blocks': 'Vozlišča', 'question-understand': 'Vprašanje Razumevanje', 'agent': 'Agentska strategija', + 'addAll': 'Dodaj vse', + 'allAdded': 'Vse dodano', }, blocks: { 'iteration': 'Iteracija', @@ -366,7 +369,10 @@ const translation = { }, insertVarTip: 'Vstavite spremenljivko', outputVars: 'Izhodne spremenljivke', - typeSwitch: {}, + typeSwitch: { + variable: 'Uporabi spremenljivko', + input: 'Vhodna vrednost', + }, }, start: { outputVars: { @@ -553,6 +559,7 @@ const translation = { outputVars: 'Izhodne spremenljivke', inputVars: 'Vhodne spremenljivke', advancedDependenciesTip: 'Dodajte nekaj vnaprej naloženih odvisnosti, ki potrebujejo več časa za obdelavo ali niso privzete vgrajene.', + syncFunctionSignature: 'Sinhronizirajte podpis funkcije s kodo', }, templateTransform: { outputVars: { @@ -670,6 +677,9 @@ const translation = { }, inputVars: 'Vhodne spremenljivke', authorize: 'Pooblasti', + insertPlaceholder2: 'vstavite spremenljivko', + insertPlaceholder1: 'Vnesite ali pritisnite', + settings: 'Nastavitve', }, questionClassifiers: { outputVars: { @@ -886,6 +896,8 @@ const translation = { toolNotAuthorizedTooltip: '{{tool}} Ni pooblaščen', strategyNotFoundDescAndSwitchVersion: 'Nameščena različica vtičnika ne podpira te strategije. Kliknite za preklop na drugo različico.', pluginNotInstalledDesc: 'Ta vtičnik je nameščen iz GitHuba. Prosimo, da greste v vtičnike in ga ponovo namestite.', + parameterSchema: 'Parametrska shema', + clickToViewParameterSchema: 'Kliknite za prikaz sheme parametrov', }, }, tracing: { diff --git a/web/i18n/th-TH/app.ts b/web/i18n/th-TH/app.ts index 9d2b9af398..d89193bded 100644 --- a/web/i18n/th-TH/app.ts +++ b/web/i18n/th-TH/app.ts @@ -177,7 +177,10 @@ const translation = { title: 'ทอ', description: 'Weave เป็นแพลตฟอร์มโอเพนซอร์สสำหรับการประเมินผล ทดสอบ และตรวจสอบแอปพลิเคชัน LLM', }, - aliyun: {}, + aliyun: { + title: 'การตรวจสอบคลาวด์', + description: 'แพลตฟอร์มการสังเกตการณ์ที่จัดการโดย Alibaba Cloud ซึ่งไม่ต้องดูแลและบำรุงรักษา ช่วยให้สามารถติดตาม ตรวจสอบ และประเมินแอปพลิเคชัน Dify ได้ทันที', + }, }, mermaid: { handDrawn: 'วาดด้วยมือ', @@ -252,6 +255,8 @@ const translation = { }, accessControl: 'การควบคุมการเข้าถึงเว็บแอปพลิเคชัน', noAccessPermission: 'ไม่มีสิทธิ์เข้าถึงเว็บแอป', + maxActiveRequestsPlaceholder: 'ใส่ 0 สำหรับไม่จำกัด', + maxActiveRequests: 'จำนวนคำขอพร้อมกันสูงสุด', } export default translation diff --git a/web/i18n/th-TH/common.ts b/web/i18n/th-TH/common.ts index 353ede8052..d956c36716 100644 --- a/web/i18n/th-TH/common.ts +++ b/web/i18n/th-TH/common.ts @@ -215,6 +215,28 @@ const translation = { workspaceIcon: 'ไอคอนพื้นที่ทำงาน', editWorkspaceInfo: 'แก้ไขข้อมูลเวิร์กสเปซ', workspaceName: 'ชื่อพื้นที่ทำงาน', + changeEmail: { + resend: 'ส่งซ้ำ', + changeTo: 'เปลี่ยนเป็น {{email}}', + sendVerifyCode: 'ส่งรหัสยืนยัน', + newEmail: 'สร้างที่อยู่อีเมลใหม่', + emailLabel: 'อีเมลใหม่', + emailPlaceholder: 'ป้อนอีเมลใหม่', + verifyEmail: 'ตรวจสอบอีเมลปัจจุบันของคุณ', + codePlaceholder: 'กรุณาวางรหัส 6 หลัก', + codeLabel: 'รหัสยืนยันตัวตน', + existingEmail: 'มีผู้ใช้ที่มีอีเมลนี้อยู่แล้ว.', + verifyNew: 'ยืนยันอีเมลใหม่ของคุณ', + content4: 'เราเพิ่งส่งรหัสยืนยันชั่วคราวไปที่ {{email}}.', + continue: 'ดำเนินต่อไป', + content2: 'อีเมลปัจจุบันของคุณคือ {{email}} รหัสยืนยันได้ถูกส่งไปยังที่อยู่อีเมลนี้แล้ว', + content3: 'กรุณาใส่อีเมลใหม่และเราจะส่งรหัสยืนยันให้คุณ', + content1: 'หากคุณดำเนินการต่อ เราจะส่งรหัสยืนยันไปยัง {{email}} เพื่อการยืนยันตัวตนใหม่อีกครั้ง.', + resendTip: 'ไม่ได้รับรหัสเหรอ?', + resendCount: 'ส่งอีกครั้งใน {{count}} วินาที', + authTip: 'เมื่ออีเมลของคุณถูกเปลี่ยนแปลง บัญชี Google หรือบัญชี GitHub ที่เชื่อมโยงกับอีเมลเก่าของคุณจะไม่สามารถเข้าสู่ระบบบัญชีนี้ได้อีกต่อไป.', + title: 'เปลี่ยนอีเมล', + }, }, members: { team: 'ทีม', @@ -256,6 +278,26 @@ const translation = { disInvite: 'ยกเลิกคําเชิญ', deleteMember: 'ลบสมาชิก', you: '(คุณ)', + transferModal: { + resend: 'ส่งซ้ำ', + codePlaceholder: 'กรุณาวางรหัส 6 หลัก', + resendTip: 'ไม่ received รหัสใช่ไหม?', + sendVerifyCode: 'ส่งรหัสยืนยัน', + continue: 'ดำเนินต่อไป', + codeLabel: 'รหัสยืนยัน', + transferPlaceholder: 'เลือกสมาชิกในที่ทำงาน…', + transferLabel: 'โอนความเป็นเจ้าของพื้นที่ทำงานไปยัง', + transfer: 'โอนความเป็นเจ้าของพื้นที่ทำงาน', + resendCount: 'ส่งอีกครั้งใน {{count}} วินาที', + verifyEmail: 'ตรวจสอบอีเมลปัจจุบันของคุณ', + verifyContent2: 'เราจะส่งรหัสการตรวจสอบชั่วคราวไปยังอีเมลนี้เพื่อทำการยืนยันตัวตนใหม่.', + warningTip: 'คุณจะกลายเป็นสมาชิกผู้ดูแลระบบ และเจ้าของคนใหม่จะมีการควบคุมทั้งหมด', + warning: 'คุณกำลังจะโอนความเป็นเจ้าของของ "{{workspace}}" นี่จะมีผลทันทีและไม่สามารถย้อนกลับได้.', + sendTip: 'หากคุณดำเนินการต่อไป เราจะส่งรหัสการตรวจสอบไปยัง {{email}} สำหรับการตรวจสอบสิทธิ์ใหม่อีกครั้ง.', + verifyContent: 'อีเมลปัจจุบันของคุณคือ {{email}}.', + title: 'โอนความเป็นเจ้าของพื้นที่ทำงาน', + }, + transferOwnership: 'โอนความเป็นเจ้าของ', }, integrations: { connected: 'เชื่อม ต่อ', @@ -448,6 +490,7 @@ const translation = { addPages: 'เพิ่มหน้า', preview: 'ดูตัวอย่าง', }, + integratedAlert: 'Notion ถูกผสานผ่านข้อมูลประจำตัวภายใน ไม่จำเป็นต้องทำการอนุญาตใหม่อีกครั้ง.', }, website: { title: 'เว็บไซต์', diff --git a/web/i18n/th-TH/login.ts b/web/i18n/th-TH/login.ts index da24be7ea5..3db8da4da8 100644 --- a/web/i18n/th-TH/login.ts +++ b/web/i18n/th-TH/login.ts @@ -108,6 +108,7 @@ const translation = { noLoginMethodTip: 'กรุณาติดต่อผู้ดูแลระบบเพื่อเพิ่มวิธีการตรวจสอบสิทธิ์.', noLoginMethod: 'ไม่ได้กำหนดวิธีการตรวจสอบสิทธิ์สำหรับเว็บแอป', disabled: 'การรับรองความถูกต้องของเว็บแอปถูกปิดใช้งาน โปรดติดต่อผู้ดูแลระบบเพื่อเปิดใช้งาน คุณสามารถลองใช้แอปโดยตรงได้', + login: 'เข้าสู่ระบบ', }, } diff --git a/web/i18n/th-TH/plugin.ts b/web/i18n/th-TH/plugin.ts index 92684a4be8..1b2c7a7c22 100644 --- a/web/i18n/th-TH/plugin.ts +++ b/web/i18n/th-TH/plugin.ts @@ -63,6 +63,7 @@ const translation = { descriptionPlaceholder: 'คําอธิบายสั้น ๆ เกี่ยวกับวัตถุประสงค์ของเครื่องมือ เช่น รับอุณหภูมิสําหรับตําแหน่งเฉพาะ', uninstalledContent: 'ปลั๊กอินนี้ติดตั้งจากที่เก็บในเครื่อง/GitHub กรุณาใช้หลังการติดตั้ง', toolSetting: 'การตั้งค่าเครื่องมือ', + unsupportedMCPTool: 'รุ่นปลั๊กอินกลยุทธ์ตัวแทนที่เลือกในขณะนี้ไม่สนับสนุนเครื่องมือ MCP.', }, endpointDisableContent: 'คุณต้องการปิดการใช้งาน {{name}} หรือไม่?', configureApp: 'กําหนดค่าแอป', diff --git a/web/i18n/th-TH/tools.ts b/web/i18n/th-TH/tools.ts index df36463e57..6406865201 100644 --- a/web/i18n/th-TH/tools.ts +++ b/web/i18n/th-TH/tools.ts @@ -85,9 +85,14 @@ const translation = { api_key: 'คีย์ API', apiKeyPlaceholder: 'ชื่อส่วนหัว HTTP สําหรับคีย์ API', apiValuePlaceholder: 'ป้อนคีย์ API', + api_key_header: 'หัวเรื่อง', + api_key_query: 'พารามิเตอร์การค้นหา', + queryParamPlaceholder: 'ชื่อพารามิเตอร์คำค้นสำหรับ API Key', }, key: 'กุญแจ', value: 'ค่า', + queryParam: 'พารามิเตอร์การค้นหา', + queryParamTooltip: 'ชื่อของพารามิเตอร์การค้นหา API key ที่ต้องส่ง ตัวอย่างเช่น "key" ใน "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'ประเภทการรับรองความถูกต้อง', diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index 28be5c57e8..f03b021fd1 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -115,6 +115,7 @@ const translation = { needAnswerNode: 'ต้องเพิ่มโหนดคำตอบ', addBlock: 'เพิ่มโนด', needEndNode: 'ต้องเพิ่มโหนดจบ', + tagBound: 'จำนวนแอปพลิเคชันที่ใช้แท็กนี้', }, env: { envPanelTitle: 'ตัวแปรสภาพแวดล้อม', @@ -234,6 +235,8 @@ const translation = { 'plugin': 'ปลั๊กอิน', 'searchBlock': 'ค้นหาโหนด', 'blocks': 'โหนด', + 'allAdded': 'ทั้งหมดที่เพิ่มเข้ามา', + 'addAll': 'เพิ่มทั้งหมด', }, blocks: { 'start': 'เริ่ม', @@ -364,7 +367,10 @@ const translation = { retries: '{{num}} ลอง', ms: 'นางสาว', }, - typeSwitch: {}, + typeSwitch: { + input: 'ค่าป้อนข้อมูล', + variable: 'ใช้ตัวแปร', + }, }, start: { required: 'ต้องระบุ', @@ -551,6 +557,7 @@ const translation = { advancedDependencies: 'การพึ่งพาขั้นสูง', advancedDependenciesTip: 'เพิ่มการพึ่งพาที่โหลดไว้ล่วงหน้าซึ่งใช้เวลามากขึ้นในการใช้หรือไม่ใช่ค่าเริ่มต้นในตัวที่นี่', searchDependencies: 'การพึ่งพาการค้นหา', + syncFunctionSignature: 'ซิงก์ลายเซ็นฟังก์ชันให้ตรงกับโค้ด', }, templateTransform: { inputVars: 'ตัวแปรอินพุต', @@ -669,6 +676,9 @@ const translation = { json: 'เครื่องมือสร้าง JSON', }, authorize: 'อนุญาต', + insertPlaceholder2: 'แทรกตัวแปร', + insertPlaceholder1: 'พิมพ์หรือลงทะเบียน', + settings: 'การตั้งค่า', }, questionClassifiers: { model: 'แบบ', @@ -853,6 +863,8 @@ const translation = { tools: 'เครื่อง มือ', modelNotSelected: 'ไม่ได้เลือกรุ่น', linkToPlugin: 'ลิงก์ไปยังปลั๊กอิน', + parameterSchema: 'แบบจำลองพารามิเตอร์', + clickToViewParameterSchema: 'คลิกเพื่อดูโครงร่างพารามิเตอร์', }, loop: { ErrorMethod: { diff --git a/web/i18n/tr-TR/app.ts b/web/i18n/tr-TR/app.ts index 16bad22231..73fff0f217 100644 --- a/web/i18n/tr-TR/app.ts +++ b/web/i18n/tr-TR/app.ts @@ -172,7 +172,10 @@ const translation = { title: 'Dokuma', description: 'Weave, LLM uygulamalarını değerlendirmek, test etmek ve izlemek için açık kaynaklı bir platformdur.', }, - aliyun: {}, + aliyun: { + title: 'Bulut İzleyici', + description: 'Alibaba Cloud tarafından sağlanan tamamen yönetilen ve bakım gerektirmeyen gözlemleme platformu, Dify uygulamalarının kutudan çıkar çıkmaz izlenmesi, takip edilmesi ve değerlendirilmesine olanak tanır.', + }, }, answerIcon: { descriptionInExplore: 'Keşfet\'te değiştirilecek 🤖 web app simgesinin kullanılıp kullanılmayacağı', @@ -252,6 +255,8 @@ const translation = { }, accessControl: 'Web Uygulaması Erişim Kontrolü', noAccessPermission: 'Web uygulamasına erişim izni yok', + maxActiveRequestsPlaceholder: 'Sınırsız için 0 girin', + maxActiveRequests: 'Maksimum eş zamanlı istekler', } export default translation diff --git a/web/i18n/tr-TR/common.ts b/web/i18n/tr-TR/common.ts index 9ecbba3de5..d6caeba290 100644 --- a/web/i18n/tr-TR/common.ts +++ b/web/i18n/tr-TR/common.ts @@ -220,6 +220,28 @@ const translation = { workspaceName: 'Çalışma Alanı Adı', workspaceIcon: 'Çalışma Alanı İkonu', editWorkspaceInfo: 'Çalışma Alanı Bilgilerini Düzenle', + changeEmail: { + resendCount: '{{count}} saniye içinde yeniden gönder', + resend: 'Tekrar gönder', + newEmail: 'Yeni bir e-posta adresi oluşturun', + verifyNew: 'Yeni e-posta adresinizi doğrulayın', + changeTo: '{{email}}\'yi değiştir', + codePlaceholder: '6 haneli kodu yapıştırın', + continue: 'Devam et', + emailLabel: 'Yeni e-posta', + emailPlaceholder: 'Yeni bir e-posta girin', + existingEmail: 'Bu e-posta ile zaten bir kullanıcı var.', + sendVerifyCode: 'Doğrulama kodunu gönder', + title: 'E-posta Değiştir', + content3: 'Yeni bir e-posta adresi girin ve size bir doğrulama kodu göndereceğiz.', + verifyEmail: 'Mevcut e-posta adresinizi doğrulayın', + resendTip: 'Bir kod almadınız mı?', + content1: 'Devam ederseniz, tekrar kimlik doğrulaması için {{email}} adresine bir doğrulama kodu göndereceğiz.', + content4: 'Size {{email}} adresine geçici bir doğrulama kodu gönderdik.', + codeLabel: 'Doğrulama kodu', + content2: 'Sizin mevcut e-posta adresiniz {{email}}. Doğrulama kodu bu e-posta adresine gönderilmiştir.', + authTip: 'E-posta adresiniz değiştiğinde, eski e-posta adresinize bağlı Google veya GitHub hesapları bu hesaba giriş yapamayacak.', + }, }, members: { team: 'Takım', @@ -261,6 +283,26 @@ const translation = { disInvite: 'Davetiyeyi iptal et', deleteMember: 'Üyeyi Sil', you: '(Siz)', + transferModal: { + resend: 'Tekrar gönder', + continue: 'Devam et', + transferPlaceholder: 'Bir çalışma alanı üyesi seçin…', + title: 'Çalışma alanı sahipliğini devret', + resendCount: '{{count}}s içinde yeniden gönder', + resendTip: 'Bir kod almadınız mı?', + transferLabel: 'Çalışma alanı sahipliğini şuna aktar', + verifyContent: 'Mevcut e-posta adresiniz {{email}}.', + warningTip: 'Yönetici üyesi olacaksın ve yeni sahibi tam kontrole sahip olacak.', + verifyContent2: 'Bu e-postaya yeniden kimlik doğrulama için geçici bir doğrulama kodu göndereceğiz.', + codeLabel: 'Doğrulama kodu', + warning: '“{{workspace}}” sahipliğini devretmek üzere olduğunuz için. Bu, hemen yürürlüğe girecek ve geri alınamaz.', + sendVerifyCode: 'Doğrulama kodunu gönder', + transfer: 'Çalışma alanı sahipliğini devret', + codePlaceholder: '6 haneli kodu yapıştırın', + verifyEmail: 'Mevcut e-posta adresinizi doğrulayın', + sendTip: 'Devam ederseniz, tekrar kimlik doğrulaması için {{email}} adresine bir doğrulama kodu göndereceğiz.', + }, + transferOwnership: 'Sahipliği Devret', }, integrations: { connected: 'Bağlandı', @@ -453,6 +495,7 @@ const translation = { addPages: 'Sayfa ekle', preview: 'ÖNİZLEME', }, + integratedAlert: 'Notion, dahili kimlik bilgileri aracılığıyla entegre edilmiştir, yeniden yetkilendirme yapmaya gerek yoktur.', }, website: { title: 'Web Sitesi', diff --git a/web/i18n/tr-TR/login.ts b/web/i18n/tr-TR/login.ts index e6471d935f..b525dd0dd7 100644 --- a/web/i18n/tr-TR/login.ts +++ b/web/i18n/tr-TR/login.ts @@ -109,6 +109,7 @@ const translation = { disabled: 'Web uygulaması kimlik doğrulaması devre dışı. Lütfen bu özelliği etkinleştirmesi için sistem yöneticisi ile iletişime geçin. Uygulamayı doğrudan kullanmayı deneyebilirsiniz.', noLoginMethod: 'Web uygulaması için kimlik doğrulama yöntemi yapılandırılmamış', noLoginMethodTip: 'Lütfen bir kimlik doğrulama yöntemi eklemek için sistem yöneticisi ile iletişime geçin.', + login: 'Giriş', }, } diff --git a/web/i18n/tr-TR/plugin.ts b/web/i18n/tr-TR/plugin.ts index a2d34584fc..31f9cd1570 100644 --- a/web/i18n/tr-TR/plugin.ts +++ b/web/i18n/tr-TR/plugin.ts @@ -63,6 +63,7 @@ const translation = { paramsTip2: '\'Otomatik\' kapalıyken, varsayılan değer kullanılır.', unsupportedTitle: 'Desteklenmeyen Eylem', toolSetting: 'Araç Ayarları', + unsupportedMCPTool: 'Şu anda seçili olan ajan stratejisi eklenti sürümü MCP araçlarını desteklemiyor.', }, strategyNum: '{{sayı}} {{strateji}} DAHİL', switchVersion: 'Sürümü Değiştir', diff --git a/web/i18n/tr-TR/tools.ts b/web/i18n/tr-TR/tools.ts index 6e641165e2..5ae37c474f 100644 --- a/web/i18n/tr-TR/tools.ts +++ b/web/i18n/tr-TR/tools.ts @@ -85,9 +85,14 @@ const translation = { api_key: 'API Anahtarı', apiKeyPlaceholder: 'API Anahtarı için HTTP başlık adı', apiValuePlaceholder: 'API Anahtarını girin', + api_key_header: 'Başlık', + queryParamPlaceholder: 'API Anahtarı için Sorgu parametre adı', + api_key_query: 'Sorgu Parametre', }, key: 'Anahtar', value: 'Değer', + queryParam: 'Sorgu Parametresi', + queryParamTooltip: 'API anahtarı sorgu parametresinin adı, örneğin "key" değeri "https://example.com/test?key=API_KEY" adresinde.', }, authHeaderPrefix: { title: 'Yetki Türü', diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index a09e5d9068..b30442023a 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -115,6 +115,7 @@ const translation = { addBlock: 'Düğüm Ekle', needAnswerNode: 'Cevap düğümü eklenmelidir.', needEndNode: 'Son düğüm eklenmelidir', + tagBound: 'Bu etiketi kullanan uygulama sayısı', }, env: { envPanelTitle: 'Çevre Değişkenleri', @@ -234,6 +235,8 @@ const translation = { 'plugin': 'Eklenti', 'blocks': 'Düğümler', 'searchBlock': 'Arama düğümü', + 'allAdded': 'Hepsi eklendi', + 'addAll': 'Hepsini ekle', }, blocks: { 'start': 'Başlat', @@ -364,7 +367,10 @@ const translation = { retrying: 'Yeniden deneniyor...', ms: 'Ms', }, - typeSwitch: {}, + typeSwitch: { + variable: 'Değişken kullan', + input: 'Girdi değeri', + }, }, start: { required: 'gerekli', @@ -552,6 +558,7 @@ const translation = { advancedDependencies: 'Gelişmiş Bağımlılıklar', advancedDependenciesTip: 'Burada daha uzun sürede tüketilen veya varsayılan olarak yerleşik olmayan bazı ön yüklenmiş bağımlılıkları ekleyin', searchDependencies: 'Bağımlılıkları Ara', + syncFunctionSignature: 'Senkrone işlev imzasını koda eşitle', }, templateTransform: { inputVars: 'Giriş Değişkenleri', @@ -671,6 +678,9 @@ const translation = { json: 'araç tarafından oluşturulan json', }, authorize: 'Yetkilendirmek', + settings: 'Ayarlar', + insertPlaceholder2: 'değişken ekle', + insertPlaceholder1: 'Yazın veya basın', }, questionClassifiers: { model: 'model', @@ -855,6 +865,8 @@ const translation = { strategyNotInstallTooltip: '{{strateji}} yüklü değil', toolNotAuthorizedTooltip: '{{araç}} Yetkili Değil', model: 'model', + parameterSchema: 'Parametre Şeması', + clickToViewParameterSchema: 'Parametre şemasını görüntülemek için tıklayın', }, loop: { ErrorMethod: { diff --git a/web/i18n/uk-UA/app.ts b/web/i18n/uk-UA/app.ts index 9786fd36db..0d41e1e631 100644 --- a/web/i18n/uk-UA/app.ts +++ b/web/i18n/uk-UA/app.ts @@ -172,7 +172,10 @@ const translation = { title: 'Ткати', description: 'Weave є платформою з відкритим кодом для оцінки, тестування та моніторингу LLM додатків.', }, - aliyun: {}, + aliyun: { + title: 'Моніторинг Хмари', + description: 'Повністю керовані та без обслуговування платформи спостереження, надані Alibaba Cloud, дозволяють миттєвий моніторинг, трасування та оцінку застосувань Dify.', + }, }, answerIcon: { title: 'Використовуйте піктограму web app для заміни 🤖', @@ -256,6 +259,8 @@ const translation = { }, accessControl: 'Контроль доступу до веб-додатків', noAccessPermission: 'Немає дозволу на доступ до веб-додатку', + maxActiveRequestsPlaceholder: 'Введіть 0 для необмеженого', + maxActiveRequests: 'Максимальна кількість одночасних запитів', } export default translation diff --git a/web/i18n/uk-UA/common.ts b/web/i18n/uk-UA/common.ts index e15bf1bfe4..1ec367d481 100644 --- a/web/i18n/uk-UA/common.ts +++ b/web/i18n/uk-UA/common.ts @@ -216,6 +216,28 @@ const translation = { workspaceName: 'Назва робочого простору', workspaceIcon: 'Іконка робочого простору', editWorkspaceInfo: 'Редагувати інформацію про робочий простір', + changeEmail: { + codePlaceholder: 'Вставте 6-значний код', + continue: 'Продовжувати', + resendTip: 'Не отримали код?', + emailLabel: 'Новий електронний лист', + changeTo: 'Змінити на {{email}}', + resend: 'Переслати', + codeLabel: 'Код перевірки', + sendVerifyCode: 'Відправити код підтвердження', + emailPlaceholder: 'Введіть нову електронну пошту', + title: 'Змінити електронну пошту', + newEmail: 'Налаштуйте нову електронну адресу', + resendCount: 'Надішліть знову через {{count}}s', + content4: 'Ми тільки що надіслали вам тимчасовий код підтвердження на {{email}}.', + verifyEmail: 'Підтвердіть свою поточну електронну адресу', + existingEmail: 'Користувач з цією електронною поштою вже існує.', + content2: 'Ваш поточний електронний лист - {{email}}. Код для підтвердження було надіслано на цю електронну адресу.', + verifyNew: 'Підтвердіть свою нову електронну пошту', + content3: 'Введіть нову електронну адресу, і ми надішлемо вам код підтвердження.', + authTip: 'Коли ви зміните свою електронну адресу, облікові записи Google або GitHub, пов\'язані з вашою старою електронною адресою, більше не зможуть увійти в цей обліковий запис.', + content1: 'Якщо ви продовжите, ми надішлемо код підтвердження на {{email}} для повторної аутентифікації.', + }, }, members: { team: 'Команда', @@ -257,6 +279,26 @@ const translation = { datasetOperator: 'Адміністратор знань', setBuilder: 'Встановити як будівельник', builderTip: 'Може створювати та редагувати власні програми', + transferModal: { + continue: 'Продовжувати', + resendTip: 'Не отримали код?', + resend: 'Переслати', + sendVerifyCode: 'Відправити код підтвердження', + codePlaceholder: 'Вставте 6-значний код', + codeLabel: 'Код перевірки', + verifyEmail: 'Підтвердіть свою поточну електронну адресу', + warningTip: 'Ви станете членом адміністрації, і новий власник матиме повний контроль.', + resendCount: 'Надішліть знову через {{count}} сек.', + warning: 'Ви збираєтеся передати право власності на "{{workspace}}". Це набирає чинності негайно і не підлягає скасуванню.', + title: 'Перенести право власності на робочий простір', + transfer: 'Перенести право власності на робочий простір', + transferLabel: 'Передати право власності на робочий простір на', + verifyContent: 'Вашу поточну електронну адресу {{email}}.', + verifyContent2: 'Ми надішлемо тимчасовий код підтвердження на цю електронну пошту для повторної автентифікації.', + transferPlaceholder: 'Виберіть учасника робочого простору…', + sendTip: 'Якщо ви продовжите, ми надішлемо код підтвердження на {{email}} для повторної аутентифікації.', + }, + transferOwnership: 'Перенести право власності', }, integrations: { connected: 'Підключено', @@ -449,6 +491,7 @@ const translation = { addPages: 'Додати сторінки', preview: 'ПЕРЕДПЕРЕГЛЯД', }, + integratedAlert: 'Notion інтегрований через внутрішні облікові дані, немає потреби в повторній авторизації.', }, website: { with: 'З', diff --git a/web/i18n/uk-UA/login.ts b/web/i18n/uk-UA/login.ts index 13c71c32c0..b586f3f243 100644 --- a/web/i18n/uk-UA/login.ts +++ b/web/i18n/uk-UA/login.ts @@ -109,6 +109,7 @@ const translation = { noLoginMethod: 'Метод аутентифікації не налаштований для веб-додатку', noLoginMethodTip: 'Будь ласка, зв\'яжіться з адміністратором системи, щоб додати метод автентифікації.', disabled: 'Аутентифікацію вебдодатка вимкнено. Будь ласка, зв\'яжіться з адміністратором системи, щоб увімкнути її. Ви можете спробувати використовувати додаток безпосередньо.', + login: 'Увійти', }, } diff --git a/web/i18n/uk-UA/plugin.ts b/web/i18n/uk-UA/plugin.ts index a09e17fd3d..e6309d65ff 100644 --- a/web/i18n/uk-UA/plugin.ts +++ b/web/i18n/uk-UA/plugin.ts @@ -63,6 +63,7 @@ const translation = { uninstalledContent: 'Цей плагін встановлюється з локального/GitHub репозиторію. Будь ласка, використовуйте після встановлення.', unsupportedContent: 'Встановлена версія плагіна не передбачає цієї дії.', toolSetting: 'Налаштування інструментів', + unsupportedMCPTool: 'Використовувана версія плагіна стратегії агента наразі не підтримує інструменти MCP.', }, modelNum: '{{num}} МОДЕЛІ В КОМПЛЕКТІ', switchVersion: 'Версія перемикача', diff --git a/web/i18n/uk-UA/tools.ts b/web/i18n/uk-UA/tools.ts index 535c17b1ef..476e4f14eb 100644 --- a/web/i18n/uk-UA/tools.ts +++ b/web/i18n/uk-UA/tools.ts @@ -57,9 +57,14 @@ const translation = { api_key: 'API-ключ', apiKeyPlaceholder: 'Назва HTTP-заголовка для API-ключа', apiValuePlaceholder: 'Введіть API-ключ', + api_key_header: 'Заголовок', + queryParamPlaceholder: 'Назва параметра запиту для API Ключа', + api_key_query: 'Параметр запиту', }, key: 'Ключ', value: 'Значення', + queryParam: 'Параметр запиту', + queryParamTooltip: 'Ім\'я параметра запиту для ключа API, який потрібно передати, наприклад, "key" в "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { types: { diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index dd61582129..5b62ef83ee 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -115,6 +115,7 @@ const translation = { addBlock: 'Додати вузол', needEndNode: 'Необхідно додати кінцевий вузол', needAnswerNode: 'Вузол Відповіді повинен бути доданий', + tagBound: 'Кількість додатків, що використовують цей тег', }, env: { envPanelTitle: 'Змінні середовища', @@ -234,6 +235,8 @@ const translation = { 'agent': 'Стратегія агента', 'blocks': 'Вузли', 'searchBlock': 'Пошуковий вузол', + 'addAll': 'Додати все', + 'allAdded': 'Всі додані', }, blocks: { 'start': 'Початок', @@ -364,7 +367,10 @@ const translation = { retryFailedTimes: '{{times}} повторні спроби не вдалися', retryTimes: 'Повторіть спробу {{times}} у разі невдачі', }, - typeSwitch: {}, + typeSwitch: { + input: 'Вхідне значення', + variable: 'Використовуйте змінну', + }, }, start: { required: 'обов\'язковий', @@ -551,6 +557,7 @@ const translation = { advancedDependencies: 'Розширені залежності', advancedDependenciesTip: 'Додайте тут деякі попередньо завантажені залежності, які потребують більше часу для споживання або не є за замовчуванням вбудованими', searchDependencies: 'Шукати залежності', + syncFunctionSignature: 'Синхронізувати підпис функції з кодом', }, templateTransform: { inputVars: 'Вхідні змінні', @@ -670,6 +677,9 @@ const translation = { json: 'JSON, згенерований інструментом', }, authorize: 'Уповноважити', + settings: 'Налаштування', + insertPlaceholder2: 'вставте змінну', + insertPlaceholder1: 'Введіть або натисніть', }, questionClassifiers: { model: 'модель', @@ -854,6 +864,8 @@ const translation = { pluginNotInstalledDesc: 'Цей плагін встановлюється з GitHub. Будь ласка, перейдіть до Плагіни для перевстановлення', modelNotSelected: 'Модель не обрана', strategyNotFoundDescAndSwitchVersion: 'Встановлена версія плагіна не забезпечує цю стратегію. Натисніть, щоб змінити версію.', + parameterSchema: 'Схема параметрів', + clickToViewParameterSchema: 'Натисніть, щоб переглянути схему параметрів', }, loop: { ErrorMethod: { diff --git a/web/i18n/vi-VN/app.ts b/web/i18n/vi-VN/app.ts index d8f80d9df0..c3b5ed96b8 100644 --- a/web/i18n/vi-VN/app.ts +++ b/web/i18n/vi-VN/app.ts @@ -172,7 +172,10 @@ const translation = { title: 'Dệt', description: 'Weave là một nền tảng mã nguồn mở để đánh giá, thử nghiệm và giám sát các ứng dụng LLM.', }, - aliyun: {}, + aliyun: { + title: 'Giám sát Đám mây', + description: 'Nền tảng quan sát được quản lý hoàn toàn và không cần bảo trì do Alibaba Cloud cung cấp, cho phép giám sát, theo dõi và đánh giá các ứng dụng Dify ngay lập tức.', + }, }, answerIcon: { description: 'Có nên sử dụng biểu tượng web app để thay thế 🤖 trong ứng dụng được chia sẻ hay không', @@ -256,6 +259,8 @@ const translation = { }, noAccessPermission: 'Không được phép truy cập ứng dụng web', accessControl: 'Kiểm soát truy cập ứng dụng web', + maxActiveRequestsPlaceholder: 'Nhập 0 để không giới hạn', + maxActiveRequests: 'Số yêu cầu đồng thời tối đa', } export default translation diff --git a/web/i18n/vi-VN/common.ts b/web/i18n/vi-VN/common.ts index 3ab959a25f..084c7bcb48 100644 --- a/web/i18n/vi-VN/common.ts +++ b/web/i18n/vi-VN/common.ts @@ -216,6 +216,28 @@ const translation = { workspaceIcon: 'Biểu tượng không gian làm việc', workspaceName: 'Tên không gian làm việc', editWorkspaceInfo: 'Chỉnh sửa thông tin không gian làm việc', + changeEmail: { + existingEmail: 'Một người dùng với email này đã tồn tại.', + title: 'Đổi Email', + resendTip: 'Không nhận được mã sao?', + resend: 'Gửi lại', + emailLabel: 'Email mới', + verifyNew: 'Xác minh email mới của bạn', + newEmail: 'Tạo một địa chỉ email mới', + emailPlaceholder: 'Nhập một email mới', + changeTo: 'Thay đổi thành {{email}}', + content1: 'Nếu bạn tiếp tục, chúng tôi sẽ gửi một mã xác minh đến {{email}} để xác thực lại.', + codeLabel: 'Mã xác thực', + content3: 'Nhập một email mới và chúng tôi sẽ gửi cho bạn một mã xác minh.', + sendVerifyCode: 'Gửi mã xác minh', + content4: 'Chúng tôi vừa gửi cho bạn một mã xác minh tạm thời đến {{email}}.', + resendCount: 'Gửi lại sau {{count}} giây', + continue: 'Tiếp tục', + content2: 'Email hiện tại của bạn là {{email}}. Mã xác minh đã được gửi đến địa chỉ email này.', + verifyEmail: 'Xác minh email hiện tại của bạn', + codePlaceholder: 'Dán mã 6 chữ số', + authTip: 'Khi email của bạn được thay đổi, các tài khoản Google hoặc GitHub liên kết với email cũ của bạn sẽ không còn có thể đăng nhập vào tài khoản này.', + }, }, members: { team: 'Nhóm', @@ -257,6 +279,26 @@ const translation = { builder: 'Chủ thầu', datasetOperator: 'Quản trị viên kiến thức', setBuilder: 'Đặt làm trình tạo', + transferModal: { + resend: 'Gửi lại', + resendTip: 'Chưa nhận được mã?', + continue: 'Tiếp tục', + verifyEmail: 'Xác minh email hiện tại của bạn', + title: 'Chuyển quyền sở hữu không gian làm việc', + transferPlaceholder: 'Chọn một thành viên trong không gian làm việc…', + transferLabel: 'Chuyển quyền sở hữu không gian làm việc cho', + warningTip: 'Bạn sẽ trở thành thành viên quản trị, và chủ sở hữu mới sẽ có toàn quyền kiểm soát.', + verifyContent: 'Email hiện tại của bạn là {{email}}.', + warning: 'Bạn sắp chuyển quyền sở hữu của "{{workspace}}". Điều này có hiệu lực ngay lập tức và không thể hoàn tác.', + codePlaceholder: 'Dán mã 6 chữ số', + transfer: 'Chuyển quyền sở hữu không gian làm việc', + sendVerifyCode: 'Gửi mã xác minh', + resendCount: 'Gửi lại sau {{count}} giây', + codeLabel: 'Mã xác thực', + sendTip: 'Nếu bạn tiếp tục, chúng tôi sẽ gửi một mã xác minh đến {{email}} để xác thực lại.', + verifyContent2: 'Chúng tôi sẽ gửi một mã xác minh tạm thời đến email này để thực hiện xác thực lại.', + }, + transferOwnership: 'Chuyển quyền sở hữu', }, integrations: { connected: 'Đã kết nối', @@ -448,6 +490,7 @@ const translation = { addPages: 'Thêm trang', preview: 'Xem trước', }, + integratedAlert: 'Notion được tích hợp thông qua thông tin xác thực nội bộ, không cần phải ủy quyền lại.', }, website: { title: 'Trang mạng', diff --git a/web/i18n/vi-VN/login.ts b/web/i18n/vi-VN/login.ts index cc81bd8193..520d5250a8 100644 --- a/web/i18n/vi-VN/login.ts +++ b/web/i18n/vi-VN/login.ts @@ -109,6 +109,7 @@ const translation = { noLoginMethod: 'Phương thức xác thực chưa được cấu hình cho ứng dụng web', noLoginMethodTip: 'Vui lòng liên hệ với quản trị viên hệ thống để thêm phương thức xác thực.', disabled: 'Xác thực webapp đã bị vô hiệu hóa. Vui lòng liên hệ với quản trị hệ thống để kích hoạt nó. Bạn có thể thử sử dụng ứng dụng trực tiếp.', + login: 'Đăng nhập', }, } diff --git a/web/i18n/vi-VN/plugin.ts b/web/i18n/vi-VN/plugin.ts index fa996634d8..8cd2282fd2 100644 --- a/web/i18n/vi-VN/plugin.ts +++ b/web/i18n/vi-VN/plugin.ts @@ -63,6 +63,7 @@ const translation = { empty: 'Nhấp vào nút \'+\' để thêm công cụ. Bạn có thể thêm nhiều công cụ.', unsupportedTitle: 'Hành động không được hỗ trợ', toolSetting: 'Cài đặt công cụ', + unsupportedMCPTool: 'Phiên bản plugin chiến lược đại lý được chọn hiện tại không hỗ trợ công cụ MCP.', }, switchVersion: 'Chuyển đổi phiên bản', endpointDisableTip: 'Tắt điểm cuối', diff --git a/web/i18n/vi-VN/tools.ts b/web/i18n/vi-VN/tools.ts index 4f3893cade..896b83da03 100644 --- a/web/i18n/vi-VN/tools.ts +++ b/web/i18n/vi-VN/tools.ts @@ -57,9 +57,14 @@ const translation = { api_key: 'Khóa API', apiKeyPlaceholder: 'Tên tiêu đề HTTP cho Khóa API', apiValuePlaceholder: 'Nhập Khóa API', + api_key_query: 'Tham số truy vấn', + api_key_header: 'Tiêu đề', + queryParamPlaceholder: 'Tên tham số truy vấn cho khóa API', }, key: 'Khóa', value: 'Giá trị', + queryParam: 'Tham số truy vấn', + queryParamTooltip: 'Tên tham số truy vấn của API key để truyền vào, ví dụ: "key" trong "https://example.com/test?key=API_KEY".', }, authHeaderPrefix: { title: 'Loại xác thực', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index 05cd279728..a4525a3ffa 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -115,6 +115,7 @@ const translation = { needAnswerNode: 'Nút Trả lời phải được thêm vào', addBlock: 'Thêm Node', needEndNode: 'Nút Kết thúc phải được thêm vào', + tagBound: 'Số lượng ứng dụng sử dụng thẻ này', }, env: { envPanelTitle: 'Biến Môi Trường', @@ -234,6 +235,8 @@ const translation = { 'plugin': 'Plugin', 'blocks': 'Nút', 'searchBlock': 'Tìm kiếm nút', + 'allAdded': 'Tất cả đã được thêm vào', + 'addAll': 'Thêm tất cả', }, blocks: { 'start': 'Bắt đầu', @@ -364,7 +367,10 @@ const translation = { times: 'lần', ms: 'Ms', }, - typeSwitch: {}, + typeSwitch: { + input: 'Giá trị đầu vào', + variable: 'Sử dụng biến', + }, }, start: { required: 'bắt buộc', @@ -551,6 +557,7 @@ const translation = { advancedDependencies: 'Phụ thuộc nâng cao', advancedDependenciesTip: 'Thêm một số phụ thuộc được tải trước mà tốn nhiều thời gian hoặc không phải là mặc định tại đây', searchDependencies: 'Tìm kiếm phụ thuộc', + syncFunctionSignature: 'Đồng bộ chữ ký hàm với mã', }, templateTransform: { inputVars: 'Biến đầu vào', @@ -670,6 +677,9 @@ const translation = { json: 'JSON được tạo bởi công cụ', }, authorize: 'Ủy quyền', + settings: 'Cài đặt', + insertPlaceholder2: 'Chèn biến vào', + insertPlaceholder1: 'Gõ hoặc nhấn', }, questionClassifiers: { model: 'mô hình', @@ -854,6 +864,8 @@ const translation = { notAuthorized: 'Không được ủy quyền', strategyNotFoundDesc: 'Phiên bản plugin đã cài đặt không cung cấp chiến lược này.', toolbox: 'hộp công cụ', + clickToViewParameterSchema: 'Nhấp để xem sơ đồ tham số', + parameterSchema: 'Sơ đồ Tham số', }, loop: { ErrorMethod: { diff --git a/web/i18n/zh-Hans/common.ts b/web/i18n/zh-Hans/common.ts index 739dc4ce3d..1b265494b0 100644 --- a/web/i18n/zh-Hans/common.ts +++ b/web/i18n/zh-Hans/common.ts @@ -508,6 +508,7 @@ const translation = { addPages: '添加页面', preview: '预览', }, + integratedAlert: 'Notion通过内部凭证集成,无需重新授权。', }, website: { title: '网站', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 6bd202d58f..81e207f67e 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -113,6 +113,7 @@ const translation = { openInExplore: '在“探索”中打开', loadMore: '加载更多', noHistory: '没有历史版本', + tagBound: '使用此标签的应用数量', }, env: { envPanelTitle: '环境变量', diff --git a/web/i18n/zh-Hant/app.ts b/web/i18n/zh-Hant/app.ts index e5f997daff..07b6c85453 100644 --- a/web/i18n/zh-Hant/app.ts +++ b/web/i18n/zh-Hant/app.ts @@ -171,7 +171,10 @@ const translation = { title: '編織', description: 'Weave 是一個開源平台,用於評估、測試和監控大型語言模型應用程序。', }, - aliyun: {}, + aliyun: { + title: '雲端監控', + description: '阿里雲提供的完全管理且無需維護的可觀察性平台,支持即時監控、追蹤和評估 Dify 應用程序。', + }, }, answerIcon: { descriptionInExplore: '是否使用 web app 圖示在 Explore 中取代 🤖', @@ -255,6 +258,8 @@ const translation = { }, accessControl: '網頁應用程式存取控制', noAccessPermission: '沒有權限訪問網絡應用程式', + maxActiveRequestsPlaceholder: '輸入 0 以表示無限', + maxActiveRequests: '同時最大請求數', } export default translation diff --git a/web/i18n/zh-Hant/common.ts b/web/i18n/zh-Hant/common.ts index 841e5b7b3e..9f71b13d61 100644 --- a/web/i18n/zh-Hant/common.ts +++ b/web/i18n/zh-Hant/common.ts @@ -216,6 +216,28 @@ const translation = { workspaceName: '工作區名稱', workspaceIcon: '工作區域圖示', editWorkspaceInfo: '編輯工作區資訊', + changeEmail: { + emailPlaceholder: '輸入新電子郵件', + verifyEmail: '驗證您目前的電子郵件', + continue: '繼續', + resend: '重新發送', + title: '更改電子郵件', + emailLabel: '新電子郵件', + content4: '我們剛剛向{{email}}發送了一個臨時驗證碼。', + codePlaceholder: '請粘貼六位數代碼', + content2: '您目前的電子郵件是 {{email}}。驗證碼已發送到此電子郵件地址。', + resendCount: '在{{count}}秒後重新發送', + newEmail: '設置一個新的電子郵件地址', + sendVerifyCode: '發送驗證碼', + verifyNew: '驗證您的新電子郵件', + content1: '如果您繼續,我們將向 {{email}} 發送一個驗證碼以進行重新身份驗證。', + content3: '請輸入一個新的電子郵件,我們將向您發送驗證碼。', + codeLabel: '驗證碼', + changeTo: '更改為 {{email}}', + existingEmail: '此電子郵件的用戶已經存在。', + authTip: '一旦您的電子郵件更改,與您的舊電子郵件相關聯的 Google 或 GitHub 帳戶將無法再登錄此帳戶。', + resendTip: '沒有收到代碼嗎?', + }, }, members: { team: '團隊', @@ -257,6 +279,26 @@ const translation = { builder: '建築工人', builderTip: '可以構建和編輯自己的應用程式', datasetOperatorTip: '只能管理知識庫', + transferModal: { + title: '轉移工作區所有權', + transfer: '轉移工作區所有權', + verifyEmail: '驗證您目前的電子郵件', + codeLabel: '驗證碼', + resendTip: '沒有收到代碼嗎?', + sendVerifyCode: '發送驗證碼', + warningTip: '你將成為管理成員,而新擁有者將擁有完全控制權。', + transferLabel: '將工作區的擁有權轉讓給', + resend: '重新發送', + verifyContent: '您目前的電子郵件是 {{email}}.', + verifyContent2: '我們將向此電子郵件發送一個臨時驗證碼以進行重新身份驗證。', + resendCount: '在{{count}}秒後重新發送', + codePlaceholder: '請粘貼六位數代碼', + sendTip: '如果您繼續,我們將向 {{email}} 發送一個驗證碼以進行重新身份驗證。', + transferPlaceholder: '選擇一個工作區成員…', + warning: '您即將轉讓「{{workspace}}」的所有權。這將立即生效,並且無法撤銷。', + continue: '繼續', + }, + transferOwnership: '轉移所有權', }, integrations: { connected: '登入方式', @@ -448,6 +490,7 @@ const translation = { addPages: '新增頁面', preview: '預覽', }, + integratedAlert: 'Notion 透過內部憑證進行整合,無需重新授權。', }, website: { active: '積極', diff --git a/web/i18n/zh-Hant/login.ts b/web/i18n/zh-Hant/login.ts index a928b3b800..ae617cb5c0 100644 --- a/web/i18n/zh-Hant/login.ts +++ b/web/i18n/zh-Hant/login.ts @@ -109,6 +109,7 @@ const translation = { noLoginMethod: '未為網絡應用程序配置身份驗證方法', noLoginMethodTip: '請聯絡系統管理員以添加身份驗證方法。', disabled: '網頁應用程序身份驗證已被禁用。請聯繫系統管理員以啟用它。您可以嘗試直接使用應用程序。', + login: '登入', }, } diff --git a/web/i18n/zh-Hant/plugin.ts b/web/i18n/zh-Hant/plugin.ts index 6b4c76495d..99edb35ec7 100644 --- a/web/i18n/zh-Hant/plugin.ts +++ b/web/i18n/zh-Hant/plugin.ts @@ -63,6 +63,7 @@ const translation = { unsupportedContent2: '按兩下以切換版本。', paramsTip1: '控制 LLM 推理參數。', toolSetting: '工具設定', + unsupportedMCPTool: '目前選定的代理策略插件版本不支持 MCP 工具。', }, actionNum: '{{num}}{{作}}包括', switchVersion: 'Switch 版本', diff --git a/web/i18n/zh-Hant/tools.ts b/web/i18n/zh-Hant/tools.ts index 93c3fda5c4..fbfb09e321 100644 --- a/web/i18n/zh-Hant/tools.ts +++ b/web/i18n/zh-Hant/tools.ts @@ -57,9 +57,14 @@ const translation = { api_key: 'API Key', apiKeyPlaceholder: 'HTTP 頭部名稱,用於傳遞 API Key', apiValuePlaceholder: '輸入 API Key', + api_key_query: '查詢參數', + queryParamPlaceholder: 'API 金鑰的查詢參數名稱', + api_key_header: '標題', }, key: '鍵', value: '值', + queryParam: '查詢參數', + queryParamTooltip: '要傳遞的 API 金鑰查詢參數的名稱,例如在 "https://example.com/test?key=API_KEY" 中的 "key"。', }, authHeaderPrefix: { title: '鑑權頭部字首', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 1d29d2f5ab..0ffdde7713 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -115,6 +115,7 @@ const translation = { exitVersions: '退出版本', exportImage: '匯出圖像', exportJPEG: '匯出為 JPEG', + tagBound: '使用此標籤的應用程式數量', }, env: { envPanelTitle: '環境變數', @@ -234,6 +235,8 @@ const translation = { 'searchTool': '搜索工具', 'agent': '代理策略', 'plugin': '插件', + 'allAdded': '所有已添加的', + 'addAll': '全部添加', }, blocks: { 'start': '開始', @@ -364,7 +367,10 @@ const translation = { ms: '毫秒', retries: '{{num}}重試', }, - typeSwitch: {}, + typeSwitch: { + input: '輸入值', + variable: '使用變數', + }, }, start: { required: '必填', @@ -671,6 +677,9 @@ const translation = { }, json: '工具生成的 JSON', }, + insertPlaceholder2: '插入變量', + insertPlaceholder1: '輸入或按壓', + settings: '設定', }, questionClassifiers: { model: '模型', @@ -855,6 +864,8 @@ const translation = { linkToPlugin: '連結到插件', pluginNotInstalled: '此插件未安裝', notAuthorized: '未授權', + clickToViewParameterSchema: '點擊查看參數架構', + parameterSchema: '參數架構', }, loop: { ErrorMethod: { From 97f080fa038f1665a541b7031e09c90b6ed4ec6c Mon Sep 17 00:00:00 2001 From: yasu89 <19990967+yasu89@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:46:40 +0900 Subject: [PATCH 04/17] fix: Japanese dateTimeFormat (#22516) Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> --- web/app/(commonLayout)/apps/AppCard.tsx | 2 +- .../documents/detail/completed/child-segment-detail.tsx | 8 ++++---- web/i18n/de-DE/dataset-documents.ts | 1 + web/i18n/en-US/dataset-documents.ts | 1 + web/i18n/es-ES/dataset-documents.ts | 1 + web/i18n/fa-IR/dataset-documents.ts | 1 + web/i18n/fr-FR/dataset-documents.ts | 1 + web/i18n/hi-IN/dataset-documents.ts | 1 + web/i18n/it-IT/dataset-documents.ts | 1 + web/i18n/ja-JP/app-log.ts | 2 +- web/i18n/ja-JP/dataset-documents.ts | 1 + web/i18n/ja-JP/dataset-hit-testing.ts | 2 +- web/i18n/ko-KR/dataset-documents.ts | 1 + web/i18n/pl-PL/dataset-documents.ts | 1 + web/i18n/pt-BR/dataset-documents.ts | 1 + web/i18n/ro-RO/dataset-documents.ts | 1 + web/i18n/ru-RU/dataset-documents.ts | 1 + web/i18n/sl-SI/dataset-documents.ts | 1 + web/i18n/th-TH/dataset-documents.ts | 1 + web/i18n/tr-TR/dataset-documents.ts | 1 + web/i18n/uk-UA/dataset-documents.ts | 1 + web/i18n/vi-VN/dataset-documents.ts | 1 + web/i18n/zh-Hans/dataset-documents.ts | 1 + web/i18n/zh-Hant/dataset-documents.ts | 1 + 24 files changed, 27 insertions(+), 7 deletions(-) diff --git a/web/app/(commonLayout)/apps/AppCard.tsx b/web/app/(commonLayout)/apps/AppCard.tsx index e04c3fdea6..b3a1dce5e6 100644 --- a/web/app/(commonLayout)/apps/AppCard.tsx +++ b/web/app/(commonLayout)/apps/AppCard.tsx @@ -308,7 +308,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { const EditTimeText = useMemo(() => { const timeText = formatTime({ date: (app.updated_at || app.created_at) * 1000, - dateFormat: 'MM/DD/YYYY h:mm', + dateFormat: `${t('datasetDocuments.segment.dateTimeFormat')}`, }) return `${t('datasetDocuments.segment.editedAt')} ${timeText}` // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx index 81afed983e..4fb1e90657 100644 --- a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx +++ b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx @@ -66,7 +66,7 @@ const ChildSegmentDetail: FC = ({ const EditTimeText = useMemo(() => { const timeText = formatTime({ date: (childChunkInfo?.updated_at ?? 0) * 1000, - dateFormat: 'MM/DD/YYYY h:mm:ss', + dateFormat: `${t('datasetDocuments.segment.dateTimeFormat')}`, }) return `${t('datasetDocuments.segment.editedAt')} ${timeText}` // eslint-disable-next-line react-hooks/exhaustive-deps @@ -74,7 +74,7 @@ const ChildSegmentDetail: FC = ({ return (
-
+
{t('datasetDocuments.segment.editChildChunk')}
@@ -107,8 +107,8 @@ const ChildSegmentDetail: FC = ({
-
-
+
+
Date: Thu, 17 Jul 2025 13:49:41 +0800 Subject: [PATCH 05/17] minor bug fix: wrong default metrics endpoint (#22535) --- api/extensions/ext_otel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/extensions/ext_otel.py b/api/extensions/ext_otel.py index 0771104fb1..b027a165f9 100644 --- a/api/extensions/ext_otel.py +++ b/api/extensions/ext_otel.py @@ -205,7 +205,7 @@ def init_app(app: DifyApp): metric_endpoint = dify_config.OTLP_METRIC_ENDPOINT if not metric_endpoint: - metric_endpoint = dify_config.OTLP_BASE_ENDPOINT + "/v1/traces" + metric_endpoint = dify_config.OTLP_BASE_ENDPOINT + "/v1/metrics" metric_exporter = HTTPMetricExporter( endpoint=metric_endpoint, headers=headers, From 93c27b134da5cbd363be7c2aa1b6aab5b9edcaf9 Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Thu, 17 Jul 2025 13:52:15 +0800 Subject: [PATCH 06/17] minor typo fix: remove debug code and fix typo (#22539) --- api/controllers/console/auth/error.py | 6 +++--- api/controllers/console/workspace/members.py | 2 -- api/templates/change_mail_confirm_old_template_zh-CN.html | 1 - .../transfer_workspace_owner_confirm_template_en-US.html | 2 +- .../change_mail_confirm_old_template_zh-CN.html | 1 - .../transfer_workspace_owner_confirm_template_en-US.html | 2 +- 6 files changed, 5 insertions(+), 9 deletions(-) diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py index f4a8b97483..8c5e23de58 100644 --- a/api/controllers/console/auth/error.py +++ b/api/controllers/console/auth/error.py @@ -27,19 +27,19 @@ class InvalidTokenError(BaseHTTPException): class PasswordResetRateLimitExceededError(BaseHTTPException): error_code = "password_reset_rate_limit_exceeded" - description = "Too many password reset emails have been sent. Please try again in 1 minutes." + description = "Too many password reset emails have been sent. Please try again in 1 minute." code = 429 class EmailChangeRateLimitExceededError(BaseHTTPException): error_code = "email_change_rate_limit_exceeded" - description = "Too many email change emails have been sent. Please try again in 1 minutes." + description = "Too many email change emails have been sent. Please try again in 1 minute." code = 429 class OwnerTransferRateLimitExceededError(BaseHTTPException): error_code = "owner_transfer_rate_limit_exceeded" - description = "Too many owner tansfer emails have been sent. Please try again in 1 minutes." + description = "Too many owner transfer emails have been sent. Please try again in 1 minute." code = 429 diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 30a4148dbb..b1f79ffdec 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -264,11 +264,9 @@ class OwnerTransfer(Resource): transfer_token_data = AccountService.get_owner_transfer_data(args["token"]) if not transfer_token_data: - print(transfer_token_data, "transfer_token_data") raise InvalidTokenError() if transfer_token_data.get("email") != current_user.email: - print(transfer_token_data.get("email"), current_user.email) raise InvalidEmailError() AccountService.revoke_owner_transfer_token(args["token"]) diff --git a/api/templates/change_mail_confirm_old_template_zh-CN.html b/api/templates/change_mail_confirm_old_template_zh-CN.html index 4a3e35cfb6..23c9e46652 100644 --- a/api/templates/change_mail_confirm_old_template_zh-CN.html +++ b/api/templates/change_mail_confirm_old_template_zh-CN.html @@ -111,7 +111,6 @@

验证您的邮箱变更请求

我们收到了一个变更您 Dify 账户关联邮箱地址的请求。

-

我们收到了一个变更您 Dify 账户关联邮箱地址的请求。

此验证码仅在接下来的5分钟内有效:

diff --git a/api/templates/transfer_workspace_owner_confirm_template_en-US.html b/api/templates/transfer_workspace_owner_confirm_template_en-US.html index fb8c107274..101495ea8f 100644 --- a/api/templates/transfer_workspace_owner_confirm_template_en-US.html +++ b/api/templates/transfer_workspace_owner_confirm_template_en-US.html @@ -143,7 +143,7 @@
Please note:
  • The ownership transfer will take effect immediately once confirmed and cannot be undone.
  • -
  • You’ll become a admin member, and the new owner will have full control of the workspace.
  • +
  • You’ll become an admin member, and the new owner will have full control of the workspace.

If you didn’t make this request, please ignore this email or contact support immediately.

diff --git a/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html b/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html index 66887ccb06..41f0839190 100644 --- a/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html +++ b/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html @@ -108,7 +108,6 @@

验证您的邮箱变更请求

我们收到了一个变更您 Dify 账户关联邮箱地址的请求。

-

我们收到了一个变更您 Dify 账户关联邮箱地址的请求。

此验证码仅在接下来的5分钟内有效:

diff --git a/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html b/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html index 0e045a5878..11ce275641 100644 --- a/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html +++ b/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html @@ -140,7 +140,7 @@
Please note:
  • The ownership transfer will take effect immediately once confirmed and cannot be undone.
  • -
  • You’ll become a admin member, and the new owner will have full control of the workspace.
  • +
  • You’ll become an admin member, and the new owner will have full control of the workspace.

If you didn’t make this request, please ignore this email or contact support immediately.

From 4b2baeea655892667a7bde3c246cd6d054172511 Mon Sep 17 00:00:00 2001 From: Stream <1542763342@qq.com> Date: Thu, 17 Jul 2025 14:19:52 +0800 Subject: [PATCH 07/17] fix: use model provided by user in prompt generator (#22541) (#22542) Co-authored-by: stream --- api/core/llm_generator/llm_generator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index e01896a491..f7fd93be4a 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -148,9 +148,11 @@ class LLMGenerator: model_manager = ModelManager() - model_instance = model_manager.get_default_model_instance( + model_instance = model_manager.get_model_instance( tenant_id=tenant_id, model_type=ModelType.LLM, + provider=model_config.get("provider", ""), + model=model_config.get("name", ""), ) try: From fafb1d5fd7f3263aa7edf64e7041faf23384b00b Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:20:44 +0800 Subject: [PATCH 08/17] feat: validate email according to RFC 5322 (#22540) --- web/app/account/account-page/email-change-modal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/account/account-page/email-change-modal.tsx b/web/app/account/account-page/email-change-modal.tsx index 85c7db5945..c3efad104a 100644 --- a/web/app/account/account-page/email-change-modal.tsx +++ b/web/app/account/account-page/email-change-modal.tsx @@ -113,8 +113,8 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => { } const isValidEmail = (email: string): boolean => { - const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ - return emailRegex.test(email) + const rfc5322emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + return rfc5322emailRegex.test(email) && email.length <= 254 } const checkNewEmailExisted = async (email: string) => { From 74caebac32179bfd3c037d8e430136c350739176 Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:20:59 +0800 Subject: [PATCH 09/17] test: add comprehensive OAuth authentication unit tests (#22528) --- .../controllers/console/auth/test_oauth.py | 496 ++++++++++++++++++ .../unit_tests/libs/test_oauth_clients.py | 249 +++++++++ 2 files changed, 745 insertions(+) create mode 100644 api/tests/unit_tests/controllers/console/auth/test_oauth.py create mode 100644 api/tests/unit_tests/libs/test_oauth_clients.py diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_oauth.py new file mode 100644 index 0000000000..037c9f2745 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py @@ -0,0 +1,496 @@ +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask + +from controllers.console.auth.oauth import ( + OAuthCallback, + OAuthLogin, + _generate_account, + _get_account_by_openid_or_email, + get_oauth_providers, +) +from libs.oauth import OAuthUserInfo +from models.account import AccountStatus +from services.errors.account import AccountNotFoundError + + +class TestGetOAuthProviders: + @pytest.fixture + def app(self): + app = Flask(__name__) + app.config["TESTING"] = True + return app + + @pytest.mark.parametrize( + ("github_config", "google_config", "expected_github", "expected_google"), + [ + # Both providers configured + ( + {"id": "github_id", "secret": "github_secret"}, + {"id": "google_id", "secret": "google_secret"}, + True, + True, + ), + # Only GitHub configured + ({"id": "github_id", "secret": "github_secret"}, {"id": None, "secret": None}, True, False), + # Only Google configured + ({"id": None, "secret": None}, {"id": "google_id", "secret": "google_secret"}, False, True), + # No providers configured + ({"id": None, "secret": None}, {"id": None, "secret": None}, False, False), + ], + ) + @patch("controllers.console.auth.oauth.dify_config") + def test_should_configure_oauth_providers_correctly( + self, mock_config, app, github_config, google_config, expected_github, expected_google + ): + mock_config.GITHUB_CLIENT_ID = github_config["id"] + mock_config.GITHUB_CLIENT_SECRET = github_config["secret"] + mock_config.GOOGLE_CLIENT_ID = google_config["id"] + mock_config.GOOGLE_CLIENT_SECRET = google_config["secret"] + mock_config.CONSOLE_API_URL = "http://localhost" + + with app.app_context(): + providers = get_oauth_providers() + + assert (providers["github"] is not None) == expected_github + assert (providers["google"] is not None) == expected_google + + +class TestOAuthLogin: + @pytest.fixture + def resource(self): + return OAuthLogin() + + @pytest.fixture + def app(self): + app = Flask(__name__) + app.config["TESTING"] = True + return app + + @pytest.fixture + def mock_oauth_provider(self): + provider = MagicMock() + provider.get_authorization_url.return_value = "https://github.com/login/oauth/authorize?..." + return provider + + @pytest.mark.parametrize( + ("invite_token", "expected_token"), + [ + (None, None), + ("test_invite_token", "test_invite_token"), + ("", None), + ], + ) + @patch("controllers.console.auth.oauth.get_oauth_providers") + @patch("controllers.console.auth.oauth.redirect") + def test_should_handle_oauth_login_with_various_tokens( + self, + mock_redirect, + mock_get_providers, + resource, + app, + mock_oauth_provider, + invite_token, + expected_token, + ): + mock_get_providers.return_value = {"github": mock_oauth_provider, "google": None} + + query_string = f"invite_token={invite_token}" if invite_token else "" + with app.test_request_context(f"/auth/oauth/github?{query_string}"): + resource.get("github") + + mock_oauth_provider.get_authorization_url.assert_called_once_with(invite_token=expected_token) + mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...") + + @pytest.mark.parametrize( + ("provider", "expected_error"), + [ + ("invalid_provider", "Invalid provider"), + ("github", "Invalid provider"), # When GitHub is not configured + ("google", "Invalid provider"), # When Google is not configured + ], + ) + @patch("controllers.console.auth.oauth.get_oauth_providers") + def test_should_return_error_for_invalid_providers( + self, mock_get_providers, resource, app, provider, expected_error + ): + mock_get_providers.return_value = {"github": None, "google": None} + + with app.test_request_context(f"/auth/oauth/{provider}"): + response, status_code = resource.get(provider) + + assert status_code == 400 + assert response["error"] == expected_error + + +class TestOAuthCallback: + @pytest.fixture + def resource(self): + return OAuthCallback() + + @pytest.fixture + def app(self): + app = Flask(__name__) + app.config["TESTING"] = True + return app + + @pytest.fixture + def oauth_setup(self): + """Common OAuth setup for callback tests""" + oauth_provider = MagicMock() + oauth_provider.get_access_token.return_value = "access_token" + oauth_provider.get_user_info.return_value = OAuthUserInfo(id="123", name="Test User", email="test@example.com") + + account = MagicMock() + account.status = AccountStatus.ACTIVE.value + + token_pair = MagicMock() + token_pair.access_token = "jwt_access_token" + token_pair.refresh_token = "jwt_refresh_token" + + return {"provider": oauth_provider, "account": account, "token_pair": token_pair} + + @patch("controllers.console.auth.oauth.dify_config") + @patch("controllers.console.auth.oauth.get_oauth_providers") + @patch("controllers.console.auth.oauth._generate_account") + @patch("controllers.console.auth.oauth.AccountService") + @patch("controllers.console.auth.oauth.TenantService") + @patch("controllers.console.auth.oauth.redirect") + def test_should_handle_successful_oauth_callback( + self, + mock_redirect, + mock_tenant_service, + mock_account_service, + mock_generate_account, + mock_get_providers, + mock_config, + resource, + app, + oauth_setup, + ): + mock_config.CONSOLE_WEB_URL = "http://localhost:3000" + mock_get_providers.return_value = {"github": oauth_setup["provider"]} + mock_generate_account.return_value = oauth_setup["account"] + mock_account_service.login.return_value = oauth_setup["token_pair"] + + with app.test_request_context("/auth/oauth/github/callback?code=test_code"): + resource.get("github") + + oauth_setup["provider"].get_access_token.assert_called_once_with("test_code") + oauth_setup["provider"].get_user_info.assert_called_once_with("access_token") + mock_redirect.assert_called_once_with( + "http://localhost:3000?access_token=jwt_access_token&refresh_token=jwt_refresh_token" + ) + + @pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (Exception("OAuth error"), "OAuth process failed"), + (ValueError("Invalid token"), "OAuth process failed"), + (KeyError("Missing key"), "OAuth process failed"), + ], + ) + @patch("controllers.console.auth.oauth.db") + @patch("controllers.console.auth.oauth.get_oauth_providers") + def test_should_handle_oauth_exceptions( + self, mock_get_providers, mock_db, resource, app, exception, expected_error + ): + # Mock database session + mock_db.session = MagicMock() + mock_db.session.rollback = MagicMock() + + # Import the real requests module to create a proper exception + import requests + + request_exception = requests.exceptions.RequestException("OAuth error") + request_exception.response = MagicMock() + request_exception.response.text = str(exception) + + mock_oauth_provider = MagicMock() + mock_oauth_provider.get_access_token.side_effect = request_exception + mock_get_providers.return_value = {"github": mock_oauth_provider} + + with app.test_request_context("/auth/oauth/github/callback?code=test_code"): + response, status_code = resource.get("github") + + assert status_code == 400 + assert response["error"] == expected_error + + @pytest.mark.parametrize( + ("account_status", "expected_redirect"), + [ + (AccountStatus.BANNED.value, "http://localhost:3000/signin?message=Account is banned."), + # CLOSED status: Currently NOT handled, will proceed to login (security issue) + # This documents actual behavior. See test_defensive_check_for_closed_account_status for details + ( + AccountStatus.CLOSED.value, + "http://localhost:3000?access_token=jwt_access_token&refresh_token=jwt_refresh_token", + ), + ], + ) + @patch("controllers.console.auth.oauth.AccountService") + @patch("controllers.console.auth.oauth.TenantService") + @patch("controllers.console.auth.oauth.db") + @patch("controllers.console.auth.oauth.dify_config") + @patch("controllers.console.auth.oauth.get_oauth_providers") + @patch("controllers.console.auth.oauth._generate_account") + @patch("controllers.console.auth.oauth.redirect") + def test_should_redirect_based_on_account_status( + self, + mock_redirect, + mock_generate_account, + mock_get_providers, + mock_config, + mock_db, + mock_tenant_service, + mock_account_service, + resource, + app, + oauth_setup, + account_status, + expected_redirect, + ): + # Mock database session + mock_db.session = MagicMock() + mock_db.session.rollback = MagicMock() + mock_db.session.commit = MagicMock() + + mock_config.CONSOLE_WEB_URL = "http://localhost:3000" + mock_get_providers.return_value = {"github": oauth_setup["provider"]} + + account = MagicMock() + account.status = account_status + account.id = "123" + mock_generate_account.return_value = account + + # Mock login for CLOSED status + mock_token_pair = MagicMock() + mock_token_pair.access_token = "jwt_access_token" + mock_token_pair.refresh_token = "jwt_refresh_token" + mock_account_service.login.return_value = mock_token_pair + + with app.test_request_context("/auth/oauth/github/callback?code=test_code"): + resource.get("github") + + mock_redirect.assert_called_once_with(expected_redirect) + + @patch("controllers.console.auth.oauth.dify_config") + @patch("controllers.console.auth.oauth.get_oauth_providers") + @patch("controllers.console.auth.oauth._generate_account") + @patch("controllers.console.auth.oauth.db") + @patch("controllers.console.auth.oauth.TenantService") + @patch("controllers.console.auth.oauth.AccountService") + def test_should_activate_pending_account( + self, + mock_account_service, + mock_tenant_service, + mock_db, + mock_generate_account, + mock_get_providers, + mock_config, + resource, + app, + oauth_setup, + ): + mock_get_providers.return_value = {"github": oauth_setup["provider"]} + + mock_account = MagicMock() + mock_account.status = AccountStatus.PENDING.value + mock_generate_account.return_value = mock_account + + with app.test_request_context("/auth/oauth/github/callback?code=test_code"): + resource.get("github") + + assert mock_account.status == AccountStatus.ACTIVE.value + assert mock_account.initialized_at is not None + mock_db.session.commit.assert_called_once() + + @patch("controllers.console.auth.oauth.dify_config") + @patch("controllers.console.auth.oauth.get_oauth_providers") + @patch("controllers.console.auth.oauth._generate_account") + @patch("controllers.console.auth.oauth.db") + @patch("controllers.console.auth.oauth.TenantService") + @patch("controllers.console.auth.oauth.AccountService") + @patch("controllers.console.auth.oauth.redirect") + def test_defensive_check_for_closed_account_status( + self, + mock_redirect, + mock_account_service, + mock_tenant_service, + mock_db, + mock_generate_account, + mock_get_providers, + mock_config, + resource, + app, + oauth_setup, + ): + """Defensive test for CLOSED account status handling in OAuth callback. + + This is a defensive test documenting expected security behavior for CLOSED accounts. + + Current behavior: CLOSED status is NOT checked, allowing closed accounts to login. + Expected behavior: CLOSED accounts should be rejected like BANNED accounts. + + Context: + - AccountStatus.CLOSED is defined in the enum but never used in production + - The close_account() method exists but is never called + - Account deletion uses external service instead of status change + - All authentication services (OAuth, password, email) don't check CLOSED status + + TODO: If CLOSED status is implemented in the future: + 1. Update OAuth callback to check for CLOSED status + 2. Add similar checks to all authentication services for consistency + 3. Update this test to verify the rejection behavior + + Security consideration: Until properly implemented, CLOSED status provides no protection. + """ + # Setup + mock_config.CONSOLE_WEB_URL = "http://localhost:3000" + mock_get_providers.return_value = {"github": oauth_setup["provider"]} + + # Create account with CLOSED status + closed_account = MagicMock() + closed_account.status = AccountStatus.CLOSED.value + closed_account.id = "123" + closed_account.name = "Closed Account" + mock_generate_account.return_value = closed_account + + # Mock successful login (current behavior) + mock_token_pair = MagicMock() + mock_token_pair.access_token = "jwt_access_token" + mock_token_pair.refresh_token = "jwt_refresh_token" + mock_account_service.login.return_value = mock_token_pair + + # Execute OAuth callback + with app.test_request_context("/auth/oauth/github/callback?code=test_code"): + resource.get("github") + + # Verify current behavior: login succeeds (this is NOT ideal) + mock_redirect.assert_called_once_with( + "http://localhost:3000?access_token=jwt_access_token&refresh_token=jwt_refresh_token" + ) + mock_account_service.login.assert_called_once() + + # Document expected behavior in comments: + # Expected: mock_redirect.assert_called_once_with( + # "http://localhost:3000/signin?message=Account is closed." + # ) + # Expected: mock_account_service.login.assert_not_called() + + +class TestAccountGeneration: + @pytest.fixture + def user_info(self): + return OAuthUserInfo(id="123", name="Test User", email="test@example.com") + + @pytest.fixture + def mock_account(self): + account = MagicMock() + account.name = "Test User" + return account + + @patch("controllers.console.auth.oauth.db") + @patch("controllers.console.auth.oauth.Account") + @patch("controllers.console.auth.oauth.Session") + @patch("controllers.console.auth.oauth.select") + def test_should_get_account_by_openid_or_email( + self, mock_select, mock_session, mock_account_model, mock_db, user_info, mock_account + ): + # Mock db.engine for Session creation + mock_db.engine = MagicMock() + + # Test OpenID found + mock_account_model.get_by_openid.return_value = mock_account + result = _get_account_by_openid_or_email("github", user_info) + assert result == mock_account + mock_account_model.get_by_openid.assert_called_once_with("github", "123") + + # Test fallback to email + mock_account_model.get_by_openid.return_value = None + mock_session_instance = MagicMock() + mock_session_instance.execute.return_value.scalar_one_or_none.return_value = mock_account + mock_session.return_value.__enter__.return_value = mock_session_instance + + result = _get_account_by_openid_or_email("github", user_info) + assert result == mock_account + + @pytest.mark.parametrize( + ("allow_register", "existing_account", "should_create"), + [ + (True, None, True), # New account creation allowed + (True, "existing", False), # Existing account + (False, None, False), # Registration not allowed + ], + ) + @patch("controllers.console.auth.oauth._get_account_by_openid_or_email") + @patch("controllers.console.auth.oauth.FeatureService") + @patch("controllers.console.auth.oauth.RegisterService") + @patch("controllers.console.auth.oauth.AccountService") + @patch("controllers.console.auth.oauth.TenantService") + @patch("controllers.console.auth.oauth.db") + def test_should_handle_account_generation_scenarios( + self, + mock_db, + mock_tenant_service, + mock_account_service, + mock_register_service, + mock_feature_service, + mock_get_account, + app, + user_info, + mock_account, + allow_register, + existing_account, + should_create, + ): + mock_get_account.return_value = mock_account if existing_account else None + mock_feature_service.get_system_features.return_value.is_allow_register = allow_register + mock_register_service.register.return_value = mock_account + + with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): + if not allow_register and not existing_account: + with pytest.raises(AccountNotFoundError): + _generate_account("github", user_info) + else: + result = _generate_account("github", user_info) + assert result == mock_account + + if should_create: + mock_register_service.register.assert_called_once_with( + email="test@example.com", name="Test User", password=None, open_id="123", provider="github" + ) + + @patch("controllers.console.auth.oauth._get_account_by_openid_or_email") + @patch("controllers.console.auth.oauth.TenantService") + @patch("controllers.console.auth.oauth.FeatureService") + @patch("controllers.console.auth.oauth.AccountService") + @patch("controllers.console.auth.oauth.tenant_was_created") + def test_should_create_workspace_for_account_without_tenant( + self, + mock_event, + mock_account_service, + mock_feature_service, + mock_tenant_service, + mock_get_account, + app, + user_info, + mock_account, + ): + mock_get_account.return_value = mock_account + mock_tenant_service.get_join_tenants.return_value = [] + mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True + + mock_new_tenant = MagicMock() + mock_tenant_service.create_tenant.return_value = mock_new_tenant + + with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): + result = _generate_account("github", user_info) + + assert result == mock_account + mock_tenant_service.create_tenant.assert_called_once_with("Test User's Workspace") + mock_tenant_service.create_tenant_member.assert_called_once_with( + mock_new_tenant, mock_account, role="owner" + ) + mock_event.send.assert_called_once_with(mock_new_tenant) diff --git a/api/tests/unit_tests/libs/test_oauth_clients.py b/api/tests/unit_tests/libs/test_oauth_clients.py new file mode 100644 index 0000000000..629d15b81a --- /dev/null +++ b/api/tests/unit_tests/libs/test_oauth_clients.py @@ -0,0 +1,249 @@ +import urllib.parse +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo + + +class BaseOAuthTest: + """Base class for OAuth provider tests with common fixtures""" + + @pytest.fixture + def oauth_config(self): + return { + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "redirect_uri": "http://localhost/callback", + } + + @pytest.fixture + def mock_response(self): + response = MagicMock() + response.json.return_value = {} + return response + + def parse_auth_url(self, url): + """Helper to parse authorization URL""" + parsed = urllib.parse.urlparse(url) + params = urllib.parse.parse_qs(parsed.query) + return parsed, params + + +class TestGitHubOAuth(BaseOAuthTest): + @pytest.fixture + def oauth(self, oauth_config): + return GitHubOAuth(oauth_config["client_id"], oauth_config["client_secret"], oauth_config["redirect_uri"]) + + @pytest.mark.parametrize( + ("invite_token", "expected_state"), + [ + (None, None), + ("test_invite_token", "test_invite_token"), + ("", None), + ], + ) + def test_should_generate_authorization_url_correctly(self, oauth, oauth_config, invite_token, expected_state): + url = oauth.get_authorization_url(invite_token) + parsed, params = self.parse_auth_url(url) + + assert parsed.scheme == "https" + assert parsed.netloc == "github.com" + assert parsed.path == "/login/oauth/authorize" + assert params["client_id"][0] == oauth_config["client_id"] + assert params["redirect_uri"][0] == oauth_config["redirect_uri"] + assert params["scope"][0] == "user:email" + + if expected_state: + assert params["state"][0] == expected_state + else: + assert "state" not in params + + @pytest.mark.parametrize( + ("response_data", "expected_token", "should_raise"), + [ + ({"access_token": "test_token"}, "test_token", False), + ({"error": "invalid_grant"}, None, True), + ({}, None, True), + ], + ) + @patch("requests.post") + def test_should_retrieve_access_token( + self, mock_post, oauth, mock_response, response_data, expected_token, should_raise + ): + mock_response.json.return_value = response_data + mock_post.return_value = mock_response + + if should_raise: + with pytest.raises(ValueError) as exc_info: + oauth.get_access_token("test_code") + assert "Error in GitHub OAuth" in str(exc_info.value) + else: + token = oauth.get_access_token("test_code") + assert token == expected_token + + @pytest.mark.parametrize( + ("user_data", "email_data", "expected_email"), + [ + # User with primary email + ( + {"id": 12345, "login": "testuser", "name": "Test User"}, + [ + {"email": "secondary@example.com", "primary": False}, + {"email": "primary@example.com", "primary": True}, + ], + "primary@example.com", + ), + # User with no emails - fallback to noreply + ({"id": 12345, "login": "testuser", "name": "Test User"}, [], "12345+testuser@users.noreply.github.com"), + # User with only secondary email - fallback to noreply + ( + {"id": 12345, "login": "testuser", "name": "Test User"}, + [{"email": "secondary@example.com", "primary": False}], + "12345+testuser@users.noreply.github.com", + ), + ], + ) + @patch("requests.get") + def test_should_retrieve_user_info_correctly(self, mock_get, oauth, user_data, email_data, expected_email): + user_response = MagicMock() + user_response.json.return_value = user_data + + email_response = MagicMock() + email_response.json.return_value = email_data + + mock_get.side_effect = [user_response, email_response] + + user_info = oauth.get_user_info("test_token") + + assert user_info.id == str(user_data["id"]) + assert user_info.name == user_data["name"] + assert user_info.email == expected_email + + @patch("requests.get") + def test_should_handle_network_errors(self, mock_get, oauth): + mock_get.side_effect = requests.exceptions.RequestException("Network error") + + with pytest.raises(requests.exceptions.RequestException): + oauth.get_raw_user_info("test_token") + + +class TestGoogleOAuth(BaseOAuthTest): + @pytest.fixture + def oauth(self, oauth_config): + return GoogleOAuth(oauth_config["client_id"], oauth_config["client_secret"], oauth_config["redirect_uri"]) + + @pytest.mark.parametrize( + ("invite_token", "expected_state"), + [ + (None, None), + ("test_invite_token", "test_invite_token"), + ("", None), + ], + ) + def test_should_generate_authorization_url_correctly(self, oauth, oauth_config, invite_token, expected_state): + url = oauth.get_authorization_url(invite_token) + parsed, params = self.parse_auth_url(url) + + assert parsed.scheme == "https" + assert parsed.netloc == "accounts.google.com" + assert parsed.path == "/o/oauth2/v2/auth" + assert params["client_id"][0] == oauth_config["client_id"] + assert params["redirect_uri"][0] == oauth_config["redirect_uri"] + assert params["response_type"][0] == "code" + assert params["scope"][0] == "openid email" + + if expected_state: + assert params["state"][0] == expected_state + else: + assert "state" not in params + + @pytest.mark.parametrize( + ("response_data", "expected_token", "should_raise"), + [ + ({"access_token": "test_token"}, "test_token", False), + ({"error": "invalid_grant"}, None, True), + ({}, None, True), + ], + ) + @patch("requests.post") + def test_should_retrieve_access_token( + self, mock_post, oauth, oauth_config, mock_response, response_data, expected_token, should_raise + ): + mock_response.json.return_value = response_data + mock_post.return_value = mock_response + + if should_raise: + with pytest.raises(ValueError) as exc_info: + oauth.get_access_token("test_code") + assert "Error in Google OAuth" in str(exc_info.value) + else: + token = oauth.get_access_token("test_code") + assert token == expected_token + + mock_post.assert_called_once_with( + oauth._TOKEN_URL, + data={ + "client_id": oauth_config["client_id"], + "client_secret": oauth_config["client_secret"], + "code": "test_code", + "grant_type": "authorization_code", + "redirect_uri": oauth_config["redirect_uri"], + }, + headers={"Accept": "application/json"}, + ) + + @pytest.mark.parametrize( + ("user_data", "expected_name"), + [ + ({"sub": "123", "email": "test@example.com", "email_verified": True}, ""), + ({"sub": "123", "email": "test@example.com", "name": "Test User"}, ""), # Always returns empty string + ], + ) + @patch("requests.get") + def test_should_retrieve_user_info_correctly(self, mock_get, oauth, mock_response, user_data, expected_name): + mock_response.json.return_value = user_data + mock_get.return_value = mock_response + + user_info = oauth.get_user_info("test_token") + + assert user_info.id == user_data["sub"] + assert user_info.name == expected_name + assert user_info.email == user_data["email"] + + mock_get.assert_called_once_with(oauth._USER_INFO_URL, headers={"Authorization": "Bearer test_token"}) + + @pytest.mark.parametrize( + "exception_type", + [ + requests.exceptions.HTTPError, + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + ], + ) + @patch("requests.get") + def test_should_handle_http_errors(self, mock_get, oauth, exception_type): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = exception_type("Error") + mock_get.return_value = mock_response + + with pytest.raises(exception_type): + oauth.get_raw_user_info("invalid_token") + + +class TestOAuthUserInfo: + @pytest.mark.parametrize( + "user_data", + [ + {"id": "123", "name": "Test User", "email": "test@example.com"}, + {"id": "456", "name": "", "email": "user@domain.com"}, + {"id": "789", "name": "Another User", "email": "another@test.org"}, + ], + ) + def test_should_create_user_info_dataclass(self, user_data): + user_info = OAuthUserInfo(**user_data) + + assert user_info.id == user_data["id"] + assert user_info.name == user_data["name"] + assert user_info.email == user_data["email"] From 4b604bd79af51cc3ca51bc02e8be5bee9b2ddf40 Mon Sep 17 00:00:00 2001 From: Om Kashyap Avashia <147749578+omavashia2005@users.noreply.github.com> Date: Thu, 17 Jul 2025 12:39:14 +0530 Subject: [PATCH 10/17] fix: Python SDK WorkflowClient and KnowledgeBase client imports fixed. Added documentation for WorkflowClient. (#22476) Co-authored-by: crazywoola <427733928@qq.com> --- sdks/python-client/README.md | 39 ++++++++++++++++++++++ sdks/python-client/dify_client/__init__.py | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/sdks/python-client/README.md b/sdks/python-client/README.md index 8949ef08fa..7401fd2fd4 100644 --- a/sdks/python-client/README.md +++ b/sdks/python-client/README.md @@ -183,3 +183,42 @@ rename_conversation_response.raise_for_status() print('[rename result]') print(rename_conversation_response.json()) ``` + +* Using the Workflow Client +```python +import json +import requests +from dify_client import WorkflowClient + +api_key = "your_api_key" + +# Initialize Workflow Client +client = WorkflowClient(api_key) + +# Prepare parameters for Workflow Client +user_id = "your_user_id" +context = "previous user interaction / metadata" +user_prompt = "What is the capital of France?" + +inputs = { + "context": context, + "user_prompt": user_prompt, + # Add other input fields expected by your workflow (e.g., additional context, task parameters) + +} + +# Set response mode (default: streaming) +response_mode = "blocking" + +# Run the workflow +response = client.run(inputs=inputs, response_mode=response_mode, user=user_id) +response.raise_for_status() + +# Parse result +result = json.loads(response.text) + +answer = result.get("data").get("outputs") + +print(answer["answer"]) + +``` diff --git a/sdks/python-client/dify_client/__init__.py b/sdks/python-client/dify_client/__init__.py index 6fa9d190e5..b557a9ce95 100644 --- a/sdks/python-client/dify_client/__init__.py +++ b/sdks/python-client/dify_client/__init__.py @@ -1 +1 @@ -from dify_client.client import ChatClient, CompletionClient, DifyClient +from dify_client.client import ChatClient, CompletionClient, WorkflowClient, KnowledgeBaseClient, DifyClient From 3cfba9e47bbe6e1378eba73e5c93dea376c20ab8 Mon Sep 17 00:00:00 2001 From: znn Date: Thu, 17 Jul 2025 12:40:36 +0530 Subject: [PATCH 11/17] updating icon (#22485) --- .../config-prompt/advanced-prompt-input.tsx | 8 ++--- .../config/agent/prompt-editor.tsx | 8 ++--- web/app/components/base/copy-icon/index.tsx | 8 ++--- .../vender/line/files/clipboard-check.svg | 3 -- .../assets/vender/line/files/clipboard.svg | 3 -- .../assets/vender/line/files/copy-check.svg | 3 ++ .../icons/assets/vender/line/files/copy.svg | 3 ++ .../src/vender/line/files/Clipboard.json | 29 ------------------- .../src/vender/line/files/ClipboardCheck.json | 29 ------------------- .../icons/src/vender/line/files/Copy.json | 29 +++++++++++++++++++ .../line/files/{Clipboard.tsx => Copy.tsx} | 4 +-- .../src/vender/line/files/CopyCheck.json | 29 +++++++++++++++++++ .../{ClipboardCheck.tsx => CopyCheck.tsx} | 4 +-- .../base/icons/src/vender/line/files/index.ts | 4 +-- .../plugins/base/key-value-item.tsx | 4 +-- .../plugin-detail-panel/endpoint-card.tsx | 4 +-- .../nodes/_base/components/editor/base.tsx | 8 ++--- .../nodes/_base/components/prompt/editor.tsx | 8 ++--- .../conversation-variable-modal.tsx | 8 ++--- 19 files changed, 98 insertions(+), 98 deletions(-) delete mode 100644 web/app/components/base/icons/assets/vender/line/files/clipboard-check.svg delete mode 100644 web/app/components/base/icons/assets/vender/line/files/clipboard.svg create mode 100644 web/app/components/base/icons/assets/vender/line/files/copy-check.svg create mode 100644 web/app/components/base/icons/assets/vender/line/files/copy.svg delete mode 100644 web/app/components/base/icons/src/vender/line/files/Clipboard.json delete mode 100644 web/app/components/base/icons/src/vender/line/files/ClipboardCheck.json create mode 100644 web/app/components/base/icons/src/vender/line/files/Copy.json rename web/app/components/base/icons/src/vender/line/files/{Clipboard.tsx => Copy.tsx} (87%) create mode 100644 web/app/components/base/icons/src/vender/line/files/CopyCheck.json rename web/app/components/base/icons/src/vender/line/files/{ClipboardCheck.tsx => CopyCheck.tsx} (85%) diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx index 437e25fde4..e2d37bb9de 100644 --- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx @@ -17,8 +17,8 @@ import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap' import cn from '@/utils/classnames' import type { PromptRole, PromptVariable } from '@/models/debug' import { - Clipboard, - ClipboardCheck, + Copy, + CopyCheck, } from '@/app/components/base/icons/src/vender/line/files' import Button from '@/app/components/base/button' import Tooltip from '@/app/components/base/tooltip' @@ -188,13 +188,13 @@ const AdvancedPromptInput: FC = ({ )} {!isCopied ? ( - { + { copy(value) setIsCopied(true) }} /> ) : ( - + )}
diff --git a/web/app/components/app/configuration/config/agent/prompt-editor.tsx b/web/app/components/app/configuration/config/agent/prompt-editor.tsx index 579b7c4d64..98b23e5379 100644 --- a/web/app/components/app/configuration/config/agent/prompt-editor.tsx +++ b/web/app/components/app/configuration/config/agent/prompt-editor.tsx @@ -6,8 +6,8 @@ import { useContext } from 'use-context-selector' import { useTranslation } from 'react-i18next' import cn from '@/utils/classnames' import { - Clipboard, - ClipboardCheck, + Copy, + CopyCheck, } from '@/app/components/base/icons/src/vender/line/files' import PromptEditor from '@/app/components/base/prompt-editor' import type { ExternalDataTool } from '@/models/common' @@ -81,13 +81,13 @@ const Editor: FC = ({
{!isCopied ? ( - { + { copy(value) setIsCopied(true) }} /> ) : ( - + )}
diff --git a/web/app/components/base/copy-icon/index.tsx b/web/app/components/base/copy-icon/index.tsx index c9e8a5ad14..196e256978 100644 --- a/web/app/components/base/copy-icon/index.tsx +++ b/web/app/components/base/copy-icon/index.tsx @@ -5,8 +5,8 @@ import { debounce } from 'lodash-es' import copy from 'copy-to-clipboard' import Tooltip from '../tooltip' import { - Clipboard, - ClipboardCheck, + Copy, + CopyCheck, } from '@/app/components/base/icons/src/vender/line/files' type Props = { @@ -39,10 +39,10 @@ export const CopyIcon = ({ content }: Props) => {
{!isCopied ? ( - + ) : ( - + ) }
diff --git a/web/app/components/base/icons/assets/vender/line/files/clipboard-check.svg b/web/app/components/base/icons/assets/vender/line/files/clipboard-check.svg deleted file mode 100644 index 48c70edd74..0000000000 --- a/web/app/components/base/icons/assets/vender/line/files/clipboard-check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/web/app/components/base/icons/assets/vender/line/files/clipboard.svg b/web/app/components/base/icons/assets/vender/line/files/clipboard.svg deleted file mode 100644 index 8abaaa9c39..0000000000 --- a/web/app/components/base/icons/assets/vender/line/files/clipboard.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/web/app/components/base/icons/assets/vender/line/files/copy-check.svg b/web/app/components/base/icons/assets/vender/line/files/copy-check.svg new file mode 100644 index 0000000000..de5f86cc19 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/files/copy-check.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/assets/vender/line/files/copy.svg b/web/app/components/base/icons/assets/vender/line/files/copy.svg new file mode 100644 index 0000000000..18d2b4e7fc --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/files/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/components/base/icons/src/vender/line/files/Clipboard.json b/web/app/components/base/icons/src/vender/line/files/Clipboard.json deleted file mode 100644 index f256747558..0000000000 --- a/web/app/components/base/icons/src/vender/line/files/Clipboard.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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": "path", - "attributes": { - "d": "M16 4C16.93 4 17.395 4 17.7765 4.10222C18.8117 4.37962 19.6204 5.18827 19.8978 6.22354C20 6.60504 20 7.07003 20 8V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V8C4 7.07003 4 6.60504 4.10222 6.22354C4.37962 5.18827 5.18827 4.37962 6.22354 4.10222C6.60504 4 7.07003 4 8 4M9.6 6H14.4C14.9601 6 15.2401 6 15.454 5.89101C15.6422 5.79513 15.7951 5.64215 15.891 5.45399C16 5.24008 16 4.96005 16 4.4V3.6C16 3.03995 16 2.75992 15.891 2.54601C15.7951 2.35785 15.6422 2.20487 15.454 2.10899C15.2401 2 14.9601 2 14.4 2H9.6C9.03995 2 8.75992 2 8.54601 2.10899C8.35785 2.20487 8.20487 2.35785 8.10899 2.54601C8 2.75992 8 3.03995 8 3.6V4.4C8 4.96005 8 5.24008 8.10899 5.45399C8.20487 5.64215 8.35785 5.79513 8.54601 5.89101C8.75992 6 9.03995 6 9.6 6Z", - "stroke": "currentColor", - "stroke-width": "2", - "stroke-linecap": "round", - "stroke-linejoin": "round" - }, - "children": [] - } - ] - }, - "name": "Clipboard" -} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/line/files/ClipboardCheck.json b/web/app/components/base/icons/src/vender/line/files/ClipboardCheck.json deleted file mode 100644 index 273b115001..0000000000 --- a/web/app/components/base/icons/src/vender/line/files/ClipboardCheck.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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": "path", - "attributes": { - "d": "M16 4C16.93 4 17.395 4 17.7765 4.10222C18.8117 4.37962 19.6204 5.18827 19.8978 6.22354C20 6.60504 20 7.07003 20 8V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V8C4 7.07003 4 6.60504 4.10222 6.22354C4.37962 5.18827 5.18827 4.37962 6.22354 4.10222C6.60504 4 7.07003 4 8 4M9 15L11 17L15.5 12.5M9.6 6H14.4C14.9601 6 15.2401 6 15.454 5.89101C15.6422 5.79513 15.7951 5.64215 15.891 5.45399C16 5.24008 16 4.96005 16 4.4V3.6C16 3.03995 16 2.75992 15.891 2.54601C15.7951 2.35785 15.6422 2.20487 15.454 2.10899C15.2401 2 14.9601 2 14.4 2H9.6C9.03995 2 8.75992 2 8.54601 2.10899C8.35785 2.20487 8.20487 2.35785 8.10899 2.54601C8 2.75992 8 3.03995 8 3.6V4.4C8 4.96005 8 5.24008 8.10899 5.45399C8.20487 5.64215 8.35785 5.79513 8.54601 5.89101C8.75992 6 9.03995 6 9.6 6Z", - "stroke": "currentColor", - "stroke-width": "2", - "stroke-linecap": "round", - "stroke-linejoin": "round" - }, - "children": [] - } - ] - }, - "name": "ClipboardCheck" -} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/line/files/Copy.json b/web/app/components/base/icons/src/vender/line/files/Copy.json new file mode 100644 index 0000000000..0aa0935e0f --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/files/Copy.json @@ -0,0 +1,29 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z", + "stroke": "currentColor", + "stroke-width": "1.5", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + } + ] + }, + "name": "Copy" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/line/files/Clipboard.tsx b/web/app/components/base/icons/src/vender/line/files/Copy.tsx similarity index 87% rename from web/app/components/base/icons/src/vender/line/files/Clipboard.tsx rename to web/app/components/base/icons/src/vender/line/files/Copy.tsx index c49d15d19d..155b825fa1 100644 --- a/web/app/components/base/icons/src/vender/line/files/Clipboard.tsx +++ b/web/app/components/base/icons/src/vender/line/files/Copy.tsx @@ -2,7 +2,7 @@ // DON NOT EDIT IT MANUALLY import * as React from 'react' -import data from './Clipboard.json' +import data from './Copy.json' import IconBase from '@/app/components/base/icons/IconBase' import type { IconData } from '@/app/components/base/icons/IconBase' @@ -15,6 +15,6 @@ const Icon = ( }, ) => -Icon.displayName = 'Clipboard' +Icon.displayName = 'Copy' export default Icon diff --git a/web/app/components/base/icons/src/vender/line/files/CopyCheck.json b/web/app/components/base/icons/src/vender/line/files/CopyCheck.json new file mode 100644 index 0000000000..f1f3a2e1bd --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/files/CopyCheck.json @@ -0,0 +1,29 @@ +{ + "icon": { + "type": "element", + "isRootNode": true, + "name": "svg", + "attributes": { + "width": "16", + "height": "16", + "viewBox": "0 0 16 16", + "fill": "none", + "xmlns": "http://www.w3.org/2000/svg" + }, + "children": [ + { + "type": "element", + "name": "path", + "attributes": { + "d": "M10.6665 2.66683C11.2865 2.66683 11.5965 2.66683 11.8508 2.73498C12.541 2.91991 13.0801 3.45901 13.265 4.14919C13.3332 4.40352 13.3332 4.71352 13.3332 5.3335V11.4668C13.3332 12.5869 13.3332 13.147 13.1152 13.5748C12.9234 13.9511 12.6175 14.2571 12.2412 14.4488C11.8133 14.6668 11.2533 14.6668 10.1332 14.6668H5.8665C4.7464 14.6668 4.18635 14.6668 3.75852 14.4488C3.3822 14.2571 3.07624 13.9511 2.88449 13.5748C2.6665 13.147 2.6665 12.5869 2.6665 11.4668V5.3335C2.6665 4.71352 2.6665 4.40352 2.73465 4.14919C2.91959 3.45901 3.45868 2.91991 4.14887 2.73498C4.4032 2.66683 4.71319 2.66683 5.33317 2.66683M5.99984 10.0002L7.33317 11.3335L10.3332 8.3335M6.39984 4.00016H9.59984C9.9732 4.00016 10.1599 4.00016 10.3025 3.9275C10.4279 3.86359 10.5299 3.7616 10.5938 3.63616C10.6665 3.49355 10.6665 3.30686 10.6665 2.9335V2.40016C10.6665 2.02679 10.6665 1.84011 10.5938 1.6975C10.5299 1.57206 10.4279 1.47007 10.3025 1.40616C10.1599 1.3335 9.97321 1.3335 9.59984 1.3335H6.39984C6.02647 1.3335 5.83978 1.3335 5.69718 1.40616C5.57174 1.47007 5.46975 1.57206 5.40583 1.6975C5.33317 1.84011 5.33317 2.02679 5.33317 2.40016V2.9335C5.33317 3.30686 5.33317 3.49355 5.40583 3.63616C5.46975 3.7616 5.57174 3.86359 5.69718 3.9275C5.83978 4.00016 6.02647 4.00016 6.39984 4.00016Z", + "stroke": "currentColor", + "stroke-width": "1.5", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + } + ] + }, + "name": "CopyCheck" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/line/files/ClipboardCheck.tsx b/web/app/components/base/icons/src/vender/line/files/CopyCheck.tsx similarity index 85% rename from web/app/components/base/icons/src/vender/line/files/ClipboardCheck.tsx rename to web/app/components/base/icons/src/vender/line/files/CopyCheck.tsx index 586b55e616..90eca4c04d 100644 --- a/web/app/components/base/icons/src/vender/line/files/ClipboardCheck.tsx +++ b/web/app/components/base/icons/src/vender/line/files/CopyCheck.tsx @@ -2,7 +2,7 @@ // DON NOT EDIT IT MANUALLY import * as React from 'react' -import data from './ClipboardCheck.json' +import data from './CopyCheck.json' import IconBase from '@/app/components/base/icons/IconBase' import type { IconData } from '@/app/components/base/icons/IconBase' @@ -15,6 +15,6 @@ const Icon = ( }, ) => -Icon.displayName = 'ClipboardCheck' +Icon.displayName = 'CopyCheck' export default Icon diff --git a/web/app/components/base/icons/src/vender/line/files/index.ts b/web/app/components/base/icons/src/vender/line/files/index.ts index 4c0ddc2289..631f7915b0 100644 --- a/web/app/components/base/icons/src/vender/line/files/index.ts +++ b/web/app/components/base/icons/src/vender/line/files/index.ts @@ -1,5 +1,5 @@ -export { default as ClipboardCheck } from './ClipboardCheck' -export { default as Clipboard } from './Clipboard' +export { default as CopyCheck } from './CopyCheck' +export { default as Copy } from './Copy' export { default as File02 } from './File02' export { default as FileArrow01 } from './FileArrow01' export { default as FileCheck02 } from './FileCheck02' diff --git a/web/app/components/plugins/base/key-value-item.tsx b/web/app/components/plugins/base/key-value-item.tsx index cfc81aa177..b616b5ee18 100644 --- a/web/app/components/plugins/base/key-value-item.tsx +++ b/web/app/components/plugins/base/key-value-item.tsx @@ -6,7 +6,7 @@ import { RiClipboardLine, } from '@remixicon/react' import { useTranslation } from 'react-i18next' -import { ClipboardCheck } from '../../base/icons/src/vender/line/files' +import { CopyCheck } from '../../base/icons/src/vender/line/files' import Tooltip from '../../base/tooltip' import cn from '@/utils/classnames' import ActionButton from '@/app/components/base/action-button' @@ -44,7 +44,7 @@ const KeyValueItem: FC = ({ } }, [isCopied]) - const CopyIcon = isCopied ? ClipboardCheck : RiClipboardLine + const CopyIcon = isCopied ? CopyCheck : RiClipboardLine return (
diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx index cc3688aebc..00cd1b88ae 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx @@ -7,7 +7,7 @@ import type { EndpointListItem } from '../types' import EndpointModal from './endpoint-modal' import { NAME_FIELD } from './utils' import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema' -import { ClipboardCheck } from '@/app/components/base/icons/src/vender/line/files' +import { CopyCheck } from '@/app/components/base/icons/src/vender/line/files' import ActionButton from '@/app/components/base/action-button' import Confirm from '@/app/components/base/confirm' import Indicator from '@/app/components/header/indicator' @@ -130,7 +130,7 @@ const EndpointCard = ({ } }, [isCopied]) - const CopyIcon = isCopied ? ClipboardCheck : RiClipboardLine + const CopyIcon = isCopied ? CopyCheck : RiClipboardLine return (
diff --git a/web/app/components/workflow/nodes/_base/components/editor/base.tsx b/web/app/components/workflow/nodes/_base/components/editor/base.tsx index 284c6c77eb..56917bc447 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/base.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/base.tsx @@ -9,8 +9,8 @@ import Wrap from './wrap' import cn from '@/utils/classnames' import PromptEditorHeightResizeWrap from '@/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap' import { - Clipboard, - ClipboardCheck, + Copy, + CopyCheck, } from '@/app/components/base/icons/src/vender/line/files' import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend' import type { FileEntity } from '@/app/components/base/file-uploader/types' @@ -92,10 +92,10 @@ const Base: FC = ({ {!isCopied ? ( - + ) : ( - + ) } diff --git a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx index b28e8a1ca3..4cf0fa037a 100644 --- a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx +++ b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx @@ -23,8 +23,8 @@ import ToggleExpandBtn from '@/app/components/workflow/nodes/_base/components/to import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend' import PromptEditor from '@/app/components/base/prompt-editor' import { - Clipboard, - ClipboardCheck, + Copy, + CopyCheck, } from '@/app/components/base/icons/src/vender/line/files' import { useEventEmitterContextContext } from '@/context/event-emitter' import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block' @@ -204,12 +204,12 @@ const Editor: FC = ({ {!isCopied ? ( - + ) : ( - + ) } diff --git a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx index 57fe461e03..ee3eded719 100644 --- a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx @@ -9,8 +9,8 @@ import Modal from '@/app/components/base/modal' import { BubbleX } from '@/app/components/base/icons/src/vender/line/others' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { - Clipboard, - ClipboardCheck, + Copy, + CopyCheck, } from '@/app/components/base/icons/src/vender/line/files' import { useStore } from '@/app/components/workflow/store' import type { @@ -122,10 +122,10 @@ const ConversationVariableModal = ({
{!isCopied ? ( - + ) : ( - + ) }
From 965e952336efc8abc096bf5218de00de6bf62b3f Mon Sep 17 00:00:00 2001 From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:05:33 +0800 Subject: [PATCH 12/17] minor translation fix: fix translation duplicate and typo, fix date format (#22548) --- web/i18n/fr-FR/dataset-documents.ts | 2 +- web/i18n/hi-IN/workflow.ts | 4 ++-- web/i18n/it-IT/dataset-documents.ts | 2 +- web/i18n/pt-BR/dataset-documents.ts | 2 +- web/i18n/uk-UA/dataset-documents.ts | 2 +- web/i18n/vi-VN/dataset-documents.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/i18n/fr-FR/dataset-documents.ts b/web/i18n/fr-FR/dataset-documents.ts index 2a46d1cced..debb03a379 100644 --- a/web/i18n/fr-FR/dataset-documents.ts +++ b/web/i18n/fr-FR/dataset-documents.ts @@ -374,7 +374,7 @@ const translation = { expandChunks: 'Développer des blocs', characters_other: 'caractères', editedAt: 'Édité le', - dateTimeFormat: 'MM/DD/YYYY h:mm', + dateTimeFormat: 'DD/MM/YYYY HH:mm', searchResults_other: 'RÉSULTATS', regenerationSuccessMessage: 'Vous pouvez fermer cette fenêtre.', parentChunks_one: 'MORCEAU PARENT', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index 9689cbf9c5..68937e5155 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -381,7 +381,7 @@ const translation = { }, typeSwitch: { input: 'इनपुट मान', - variable: 'चर चर का प्रयोग करें', + variable: 'चर का प्रयोग करें', }, }, start: { @@ -695,7 +695,7 @@ const translation = { authorize: 'अधिकृत करें', insertPlaceholder1: 'टाइप करें या दबाएँ', settings: 'सेटिंग्स', - insertPlaceholder2: 'चरित्र डालें', + insertPlaceholder2: 'वेरिएबल डालें', }, questionClassifiers: { model: 'मॉडल', diff --git a/web/i18n/it-IT/dataset-documents.ts b/web/i18n/it-IT/dataset-documents.ts index 327507e406..66eb00aafd 100644 --- a/web/i18n/it-IT/dataset-documents.ts +++ b/web/i18n/it-IT/dataset-documents.ts @@ -380,7 +380,7 @@ const translation = { regenerationConfirmTitle: 'Si desidera rigenerare i blocchi figlio?', chunks_other: 'BLOCCHI', editedAt: 'A cura di', - dateTimeFormat: 'MM/DD/YYYY h:mm', + dateTimeFormat: 'DD/MM/YYYY HH:mm', collapseChunks: 'Comprimi blocchi', clearFilter: 'Cancella filtro', chunks_one: 'PEZZO', diff --git a/web/i18n/pt-BR/dataset-documents.ts b/web/i18n/pt-BR/dataset-documents.ts index 50ac7ef9f2..30fa87f82f 100644 --- a/web/i18n/pt-BR/dataset-documents.ts +++ b/web/i18n/pt-BR/dataset-documents.ts @@ -376,7 +376,7 @@ const translation = { regeneratingMessage: 'Isso pode demorar um pouco, por favor aguarde...', edited: 'EDIÇÃO', editedAt: 'Editado em', - dateTimeFormat: 'MM/DD/YYYY h:mm', + dateTimeFormat: 'DD/MM/YYYY HH:mm', expandChunks: 'Expandir pedaços', collapseChunks: 'Recolher partes', regenerationConfirmMessage: 'A regeneração de partes filhas substituirá as partes filhas atuais, incluindo partes editadas e partes recém-adicionadas. A regeneração não pode ser desfeita.', diff --git a/web/i18n/uk-UA/dataset-documents.ts b/web/i18n/uk-UA/dataset-documents.ts index e28e1e4680..903e8a97c4 100644 --- a/web/i18n/uk-UA/dataset-documents.ts +++ b/web/i18n/uk-UA/dataset-documents.ts @@ -369,7 +369,7 @@ const translation = { empty: 'Шматок не знайдено', chunks_other: 'ШМАТКИ', editedAt: 'За редакцією', - dateTimeFormat: 'MM/DD/YYYY h:mm', + dateTimeFormat: 'DD.MM.YYYY HH:mm', searchResults_zero: 'РЕЗУЛЬТАТ', collapseChunks: 'Згортання шматків', childChunkAdded: 'Додано 1 дочірній фрагмент', diff --git a/web/i18n/vi-VN/dataset-documents.ts b/web/i18n/vi-VN/dataset-documents.ts index c1822b5078..c6fcd4ed45 100644 --- a/web/i18n/vi-VN/dataset-documents.ts +++ b/web/i18n/vi-VN/dataset-documents.ts @@ -369,7 +369,7 @@ const translation = { expandChunks: 'Mở rộng các đoạn', chunks_other: 'KHỐI', editedAt: 'Chỉnh sửa tại', - dateTimeFormat: 'MM/DD/YYYY h:mm', + dateTimeFormat: 'DD/MM/YYYY HH:mm', addAnother: 'Thêm một cái khác', regenerationConfirmTitle: 'Bạn có muốn tái tạo các chunk con không?', searchResults_one: 'KẾT QUẢ', From a4ef9009167ed7cf22b0b8bd3a903e8ba0a74567 Mon Sep 17 00:00:00 2001 From: Maries Date: Thu, 17 Jul 2025 17:18:44 +0800 Subject: [PATCH 13/17] Support OAuth Integration for Plugin Tools (#22550) Co-authored-by: zxhlyh Co-authored-by: Yeuoly --- .github/workflows/build-push.yml | 1 + api/.env.example | 14 +- api/commands.py | 52 +- api/constants/__init__.py | 1 + .../console/workspace/tool_providers.py | 291 ++++++- api/controllers/inner_api/plugin/plugin.py | 1 + api/core/agent/entities.py | 1 + api/core/agent/strategy/base.py | 5 +- api/core/agent/strategy/plugin.py | 3 + .../easy_ui_based_app/agent/manager.py | 1 + api/core/helper/provider_cache.py | 84 +++ api/core/helper/tool_provider_cache.py | 51 -- .../plugin/backwards_invocation/encrypt.py | 14 +- api/core/plugin/backwards_invocation/tool.py | 5 +- api/core/plugin/entities/request.py | 15 + api/core/plugin/impl/agent.py | 3 + api/core/plugin/impl/oauth.py | 91 ++- api/core/plugin/impl/tool.py | 4 +- api/core/tools/__base/tool_runtime.py | 3 +- api/core/tools/builtin_tool/provider.py | 57 +- api/core/tools/entities/api_entities.py | 21 +- api/core/tools/entities/tool_entities.py | 42 ++ api/core/tools/plugin_tool/tool.py | 1 + api/core/tools/tool_manager.py | 168 +++-- api/core/tools/utils/configuration.py | 108 --- api/core/tools/utils/encryption.py | 142 ++++ .../tools/utils/system_oauth_encryption.py | 187 +++++ api/core/tools/utils/uuid_utils.py | 4 +- api/core/workflow/nodes/agent/agent_node.py | 34 +- api/core/workflow/nodes/tool/entities.py | 1 + ...rameters_cache_when_sync_draft_workflow.py | 1 + api/extensions/ext_commands.py | 2 + .../versions/2025_05_15_1635-16081485540c_.py | 41 + ...w_draft_varaibles_add_node_execution_id.py | 2 +- ...2025_07_04_1705-71f5020c6470_tool_oauth.py | 62 ++ api/models/tools.py | 63 +- api/services/app_dsl_service.py | 29 +- .../plugin/plugin_parameter_service.py | 10 +- api/services/plugin/plugin_service.py | 11 + .../tools/api_tools_manage_service.py | 40 +- .../tools/builtin_tools_manage_service.py | 708 ++++++++++++++---- api/services/tools/mcp_tools_mange_service.py | 7 +- api/services/tools/tools_transform_service.py | 59 +- .../test_system_oauth_encryption.py | 619 +++++++++++++++ .../config/agent/agent-tools/index.tsx | 53 +- .../agent-tools/setting-built-in-tool.tsx | 23 +- .../components/base/chat/chat/question.tsx | 2 +- .../base/form/components/base/base-field.tsx | 177 +++++ .../base/form/components/base/base-form.tsx | 115 +++ .../base/form/components/base/index.tsx | 2 + .../base/form/form-scenarios/auth/index.tsx | 23 + web/app/components/base/form/hooks/index.ts | 3 + .../base/form/hooks/use-check-validated.ts | 48 ++ .../base/form/hooks/use-get-form-values.ts | 44 ++ .../base/form/hooks/use-get-validators.ts | 36 + web/app/components/base/form/types.ts | 76 ++ web/app/components/base/form/utils/index.ts | 1 + .../base/form/utils/secret-input/index.ts | 29 + web/app/components/base/modal/modal.tsx | 127 ++++ web/app/components/base/select/pure.tsx | 10 +- .../authorize/add-api-key-button.tsx | 50 ++ .../authorize/add-oauth-button.tsx | 259 +++++++ .../plugin-auth/authorize/api-key-modal.tsx | 181 +++++ .../plugins/plugin-auth/authorize/index.tsx | 104 +++ .../authorize/oauth-client-settings.tsx | 188 +++++ .../plugin-auth/authorized-in-node.tsx | 113 +++ .../plugins/plugin-auth/authorized/index.tsx | 342 +++++++++ .../plugins/plugin-auth/authorized/item.tsx | 219 ++++++ .../plugin-auth/hooks/use-credential.ts | 88 +++ .../plugins/plugin-auth/hooks/use-get-api.ts | 41 + .../plugin-auth/hooks/use-plugin-auth.ts | 25 + .../components/plugins/plugin-auth/index.tsx | 6 + .../plugin-auth/plugin-auth-in-agent.tsx | 123 +++ .../plugins/plugin-auth/plugin-auth.tsx | 59 ++ .../components/plugins/plugin-auth/types.ts | 25 + .../components/plugins/plugin-auth/utils.ts | 10 + .../plugin-detail-panel/action-list.tsx | 62 +- .../plugin-detail-panel/detail-header.tsx | 24 +- .../tool-selector/index.tsx | 310 ++++---- .../tool-selector/tool-item.tsx | 12 +- .../workflow/block-selector/types.ts | 2 + .../_base/components/workflow-panel/index.tsx | 64 +- .../components/workflow/nodes/tool/panel.tsx | 28 - web/app/components/workflow/types.ts | 1 + web/i18n/en-US/plugin.ts | 23 + web/i18n/zh-Hans/plugin.ts | 23 + web/service/use-plugins-auth.ts | 161 ++++ web/service/use-tools.ts | 3 +- web/types/app.ts | 1 + 89 files changed, 5508 insertions(+), 867 deletions(-) create mode 100644 api/core/helper/provider_cache.py delete mode 100644 api/core/helper/tool_provider_cache.py create mode 100644 api/core/tools/utils/encryption.py create mode 100644 api/core/tools/utils/system_oauth_encryption.py create mode 100644 api/migrations/versions/2025_05_15_1635-16081485540c_.py create mode 100644 api/migrations/versions/2025_07_04_1705-71f5020c6470_tool_oauth.py create mode 100644 api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py create mode 100644 web/app/components/base/form/components/base/base-field.tsx create mode 100644 web/app/components/base/form/components/base/base-form.tsx create mode 100644 web/app/components/base/form/components/base/index.tsx create mode 100644 web/app/components/base/form/form-scenarios/auth/index.tsx create mode 100644 web/app/components/base/form/hooks/index.ts create mode 100644 web/app/components/base/form/hooks/use-check-validated.ts create mode 100644 web/app/components/base/form/hooks/use-get-form-values.ts create mode 100644 web/app/components/base/form/hooks/use-get-validators.ts create mode 100644 web/app/components/base/form/types.ts create mode 100644 web/app/components/base/form/utils/index.ts create mode 100644 web/app/components/base/form/utils/secret-input/index.ts create mode 100644 web/app/components/base/modal/modal.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorize/index.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorized-in-node.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorized/index.tsx create mode 100644 web/app/components/plugins/plugin-auth/authorized/item.tsx create mode 100644 web/app/components/plugins/plugin-auth/hooks/use-credential.ts create mode 100644 web/app/components/plugins/plugin-auth/hooks/use-get-api.ts create mode 100644 web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts create mode 100644 web/app/components/plugins/plugin-auth/index.tsx create mode 100644 web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx create mode 100644 web/app/components/plugins/plugin-auth/plugin-auth.tsx create mode 100644 web/app/components/plugins/plugin-auth/types.ts create mode 100644 web/app/components/plugins/plugin-auth/utils.ts create mode 100644 web/service/use-plugins-auth.ts diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index cc735ae67c..b933560a5e 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -6,6 +6,7 @@ on: - "main" - "deploy/dev" - "deploy/enterprise" + - "build/**" tags: - "*" diff --git a/api/.env.example b/api/.env.example index 3fe95c44b5..b8976e5b17 100644 --- a/api/.env.example +++ b/api/.env.example @@ -5,17 +5,17 @@ SECRET_KEY= # Console API base URL -CONSOLE_API_URL=http://127.0.0.1:5001 -CONSOLE_WEB_URL=http://127.0.0.1:3000 +CONSOLE_API_URL=http://localhost:5001 +CONSOLE_WEB_URL=http://localhost:3000 # Service API base URL -SERVICE_API_URL=http://127.0.0.1:5001 +SERVICE_API_URL=http://localhost:5001 # Web APP base URL -APP_WEB_URL=http://127.0.0.1:3000 +APP_WEB_URL=http://localhost:3000 # Files URL -FILES_URL=http://127.0.0.1:5001 +FILES_URL=http://localhost:5001 # INTERNAL_FILES_URL is used for plugin daemon communication within Docker network. # Set this to the internal Docker service URL for proper plugin file access. @@ -138,8 +138,8 @@ SUPABASE_API_KEY=your-access-key SUPABASE_URL=your-server-url # CORS configuration -WEB_API_CORS_ALLOW_ORIGINS=http://127.0.0.1:3000,* -CONSOLE_CORS_ALLOW_ORIGINS=http://127.0.0.1:3000,* +WEB_API_CORS_ALLOW_ORIGINS=http://localhost:3000,* +CONSOLE_CORS_ALLOW_ORIGINS=http://localhost:3000,* # Vector database configuration # support: weaviate, qdrant, milvus, myscale, relyt, pgvecto_rs, pgvector, pgvector, chroma, opensearch, tidb_vector, couchbase, vikingdb, upstash, lindorm, oceanbase, opengauss, tablestore, matrixone diff --git a/api/commands.py b/api/commands.py index 86769847c1..9f933a378c 100644 --- a/api/commands.py +++ b/api/commands.py @@ -2,19 +2,22 @@ import base64 import json import logging import secrets -from typing import Optional +from typing import Any, Optional import click from flask import current_app +from pydantic import TypeAdapter from sqlalchemy import select from werkzeug.exceptions import NotFound from configs import dify_config from constants.languages import languages +from core.plugin.entities.plugin import ToolProviderID from core.rag.datasource.vdb.vector_factory import Vector from core.rag.datasource.vdb.vector_type import VectorType from core.rag.index_processor.constant.built_in_field import BuiltInField from core.rag.models.document import Document +from core.tools.utils.system_oauth_encryption import encrypt_system_oauth_params from events.app_event import app_was_created from extensions.ext_database import db from extensions.ext_redis import redis_client @@ -27,6 +30,7 @@ from models.dataset import Dataset, DatasetCollectionBinding, DatasetMetadata, D from models.dataset import Document as DatasetDocument from models.model import Account, App, AppAnnotationSetting, AppMode, Conversation, MessageAnnotation from models.provider import Provider, ProviderModel +from models.tools import ToolOAuthSystemClient from services.account_service import AccountService, RegisterService, TenantService from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs from services.plugin.data_migration import PluginDataMigration @@ -1155,3 +1159,49 @@ def remove_orphaned_files_on_storage(force: bool): click.echo(click.style(f"Removed {removed_files} orphaned files without errors.", fg="green")) else: click.echo(click.style(f"Removed {removed_files} orphaned files, with {error_files} errors.", fg="yellow")) + + +@click.command("setup-system-tool-oauth-client", help="Setup system tool oauth client.") +@click.option("--provider", prompt=True, help="Provider name") +@click.option("--client-params", prompt=True, help="Client Params") +def setup_system_tool_oauth_client(provider, client_params): + """ + Setup system tool oauth client + """ + provider_id = ToolProviderID(provider) + provider_name = provider_id.provider_name + plugin_id = provider_id.plugin_id + + try: + # json validate + click.echo(click.style(f"Validating client params: {client_params}", fg="yellow")) + client_params_dict = TypeAdapter(dict[str, Any]).validate_json(client_params) + click.echo(click.style("Client params validated successfully.", fg="green")) + + click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow")) + click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow")) + oauth_client_params = encrypt_system_oauth_params(client_params_dict) + click.echo(click.style("Client params encrypted successfully.", fg="green")) + except Exception as e: + click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red")) + return + + deleted_count = ( + db.session.query(ToolOAuthSystemClient) + .filter_by( + provider=provider_name, + plugin_id=plugin_id, + ) + .delete() + ) + if deleted_count > 0: + click.echo(click.style(f"Deleted {deleted_count} existing oauth client params.", fg="yellow")) + + oauth_client = ToolOAuthSystemClient( + provider=provider_name, + plugin_id=plugin_id, + encrypted_oauth_params=oauth_client_params, + ) + db.session.add(oauth_client) + db.session.commit() + click.echo(click.style(f"OAuth client params setup successfully. id: {oauth_client.id}", fg="green")) diff --git a/api/constants/__init__.py b/api/constants/__init__.py index a84de0a451..9e052320ac 100644 --- a/api/constants/__init__.py +++ b/api/constants/__init__.py @@ -1,6 +1,7 @@ from configs import dify_config HIDDEN_VALUE = "[__HIDDEN__]" +UNKNOWN_VALUE = "[__UNKNOWN__]" UUID_NIL = "00000000-0000-0000-0000-000000000000" DEFAULT_FILE_NUMBER_LIMITS = 3 diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index df50871a38..e41375e52b 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -1,23 +1,32 @@ import io from urllib.parse import urlparse -from flask import redirect, send_file +from flask import make_response, redirect, request, send_file from flask_login import current_user -from flask_restful import Resource, reqparse -from sqlalchemy.orm import Session +from flask_restful import ( + Resource, + reqparse, +) from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.console import api -from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required +from controllers.console.wraps import ( + account_initialization_required, + enterprise_license_required, + setup_required, +) from core.mcp.auth.auth_flow import auth, handle_callback from core.mcp.auth.auth_provider import OAuthClientProvider from core.mcp.error import MCPAuthError, MCPError from core.mcp.mcp_client import MCPClient from core.model_runtime.utils.encoders import jsonable_encoder -from extensions.ext_database import db -from libs.helper import alphanumeric, uuid_value +from core.plugin.entities.plugin import ToolProviderID +from core.plugin.impl.oauth import OAuthHandler +from core.tools.entities.tool_entities import CredentialType +from libs.helper import StrLen, alphanumeric, uuid_value from libs.login import login_required +from services.plugin.oauth_service import OAuthProxyService from services.tools.api_tools_manage_service import ApiToolManageService from services.tools.builtin_tools_manage_service import BuiltinToolManageService from services.tools.mcp_tools_mange_service import MCPToolManageService @@ -89,7 +98,7 @@ class ToolBuiltinProviderInfoApi(Resource): user_id = user.id tenant_id = user.current_tenant_id - return jsonable_encoder(BuiltinToolManageService.get_builtin_tool_provider_info(user_id, tenant_id, provider)) + return jsonable_encoder(BuiltinToolManageService.get_builtin_tool_provider_info(tenant_id, provider)) class ToolBuiltinProviderDeleteApi(Resource): @@ -98,17 +107,47 @@ class ToolBuiltinProviderDeleteApi(Resource): @account_initialization_required def post(self, provider): user = current_user - if not user.is_admin_or_owner: raise Forbidden() - user_id = user.id tenant_id = user.current_tenant_id + req = reqparse.RequestParser() + req.add_argument("credential_id", type=str, required=True, nullable=False, location="json") + args = req.parse_args() return BuiltinToolManageService.delete_builtin_tool_provider( - user_id, tenant_id, provider, + args["credential_id"], + ) + + +class ToolBuiltinProviderAddApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, provider): + user = current_user + + user_id = user.id + tenant_id = user.current_tenant_id + + parser = reqparse.RequestParser() + parser.add_argument("credentials", type=dict, required=True, nullable=False, location="json") + parser.add_argument("name", type=StrLen(30), required=False, nullable=False, location="json") + parser.add_argument("type", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + + if args["type"] not in CredentialType.values(): + raise ValueError(f"Invalid credential type: {args['type']}") + + return BuiltinToolManageService.add_builtin_tool_provider( + user_id=user_id, + tenant_id=tenant_id, + provider=provider, + credentials=args["credentials"], + name=args["name"], + api_type=CredentialType.of(args["type"]), ) @@ -126,19 +165,20 @@ class ToolBuiltinProviderUpdateApi(Resource): tenant_id = user.current_tenant_id parser = reqparse.RequestParser() - parser.add_argument("credentials", type=dict, required=True, nullable=False, location="json") + parser.add_argument("credential_id", type=str, required=True, nullable=False, location="json") + parser.add_argument("credentials", type=dict, required=False, nullable=True, location="json") + parser.add_argument("name", type=StrLen(30), required=False, nullable=True, location="json") args = parser.parse_args() - with Session(db.engine) as session: - result = BuiltinToolManageService.update_builtin_tool_provider( - session=session, - user_id=user_id, - tenant_id=tenant_id, - provider_name=provider, - credentials=args["credentials"], - ) - session.commit() + result = BuiltinToolManageService.update_builtin_tool_provider( + user_id=user_id, + tenant_id=tenant_id, + provider=provider, + credential_id=args["credential_id"], + credentials=args.get("credentials", None), + name=args.get("name", ""), + ) return result @@ -149,9 +189,11 @@ class ToolBuiltinProviderGetCredentialsApi(Resource): def get(self, provider): tenant_id = current_user.current_tenant_id - return BuiltinToolManageService.get_builtin_tool_provider_credentials( - tenant_id=tenant_id, - provider_name=provider, + return jsonable_encoder( + BuiltinToolManageService.get_builtin_tool_provider_credentials( + tenant_id=tenant_id, + provider_name=provider, + ) ) @@ -344,12 +386,15 @@ class ToolBuiltinProviderCredentialsSchemaApi(Resource): @setup_required @login_required @account_initialization_required - def get(self, provider): + def get(self, provider, credential_type): user = current_user - tenant_id = user.current_tenant_id - return BuiltinToolManageService.list_builtin_provider_credentials_schema(provider, tenant_id) + return jsonable_encoder( + BuiltinToolManageService.list_builtin_provider_credentials_schema( + provider, CredentialType.of(credential_type), tenant_id + ) + ) class ToolApiProviderSchemaApi(Resource): @@ -586,15 +631,12 @@ class ToolApiListApi(Resource): @account_initialization_required def get(self): user = current_user - - user_id = user.id tenant_id = user.current_tenant_id return jsonable_encoder( [ provider.to_dict() for provider in ApiToolManageService.list_api_tools( - user_id, tenant_id, ) ] @@ -631,6 +673,179 @@ class ToolLabelsApi(Resource): return jsonable_encoder(ToolLabelsService.list_tool_labels()) +class ToolPluginOAuthApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider): + tool_provider = ToolProviderID(provider) + plugin_id = tool_provider.plugin_id + provider_name = tool_provider.provider_name + + # todo check permission + user = current_user + + if not user.is_admin_or_owner: + raise Forbidden() + + tenant_id = user.current_tenant_id + oauth_client_params = BuiltinToolManageService.get_oauth_client(tenant_id=tenant_id, provider=provider) + if oauth_client_params is None: + raise Forbidden("no oauth available client config found for this tool provider") + + oauth_handler = OAuthHandler() + context_id = OAuthProxyService.create_proxy_context( + user_id=current_user.id, tenant_id=tenant_id, plugin_id=plugin_id, provider=provider_name + ) + redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/tool/callback" + authorization_url_response = oauth_handler.get_authorization_url( + tenant_id=tenant_id, + user_id=user.id, + plugin_id=plugin_id, + provider=provider_name, + redirect_uri=redirect_uri, + system_credentials=oauth_client_params, + ) + response = make_response(jsonable_encoder(authorization_url_response)) + response.set_cookie( + "context_id", + context_id, + httponly=True, + samesite="Lax", + max_age=OAuthProxyService.__MAX_AGE__, + ) + return response + + +class ToolOAuthCallback(Resource): + @setup_required + def get(self, provider): + context_id = request.cookies.get("context_id") + if not context_id: + raise Forbidden("context_id not found") + + context = OAuthProxyService.use_proxy_context(context_id) + if context is None: + raise Forbidden("Invalid context_id") + + tool_provider = ToolProviderID(provider) + plugin_id = tool_provider.plugin_id + provider_name = tool_provider.provider_name + user_id, tenant_id = context.get("user_id"), context.get("tenant_id") + + oauth_handler = OAuthHandler() + oauth_client_params = BuiltinToolManageService.get_oauth_client(tenant_id, provider) + if oauth_client_params is None: + raise Forbidden("no oauth available client config found for this tool provider") + + redirect_uri = f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/tool/callback" + credentials = oauth_handler.get_credentials( + tenant_id=tenant_id, + user_id=user_id, + plugin_id=plugin_id, + provider=provider_name, + redirect_uri=redirect_uri, + system_credentials=oauth_client_params, + request=request, + ).credentials + + if not credentials: + raise Exception("the plugin credentials failed") + + # add credentials to database + BuiltinToolManageService.add_builtin_tool_provider( + user_id=user_id, + tenant_id=tenant_id, + provider=provider, + credentials=dict(credentials), + api_type=CredentialType.OAUTH2, + ) + return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback") + + +class ToolBuiltinProviderSetDefaultApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, provider): + parser = reqparse.RequestParser() + parser.add_argument("id", type=str, required=True, nullable=False, location="json") + args = parser.parse_args() + return BuiltinToolManageService.set_default_provider( + tenant_id=current_user.current_tenant_id, user_id=current_user.id, provider=provider, id=args["id"] + ) + + +class ToolOAuthCustomClient(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, provider): + parser = reqparse.RequestParser() + parser.add_argument("client_params", type=dict, required=False, nullable=True, location="json") + parser.add_argument("enable_oauth_custom_client", type=bool, required=False, nullable=True, location="json") + args = parser.parse_args() + + user = current_user + + if not user.is_admin_or_owner: + raise Forbidden() + + return BuiltinToolManageService.save_custom_oauth_client_params( + tenant_id=user.current_tenant_id, + provider=provider, + client_params=args.get("client_params", {}), + enable_oauth_custom_client=args.get("enable_oauth_custom_client", True), + ) + + @setup_required + @login_required + @account_initialization_required + def get(self, provider): + return jsonable_encoder( + BuiltinToolManageService.get_custom_oauth_client_params( + tenant_id=current_user.current_tenant_id, provider=provider + ) + ) + + @setup_required + @login_required + @account_initialization_required + def delete(self, provider): + return jsonable_encoder( + BuiltinToolManageService.delete_custom_oauth_client_params( + tenant_id=current_user.current_tenant_id, provider=provider + ) + ) + + +class ToolBuiltinProviderGetOauthClientSchemaApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider): + return jsonable_encoder( + BuiltinToolManageService.get_builtin_tool_provider_oauth_client_schema( + tenant_id=current_user.current_tenant_id, provider_name=provider + ) + ) + + +class ToolBuiltinProviderGetCredentialInfoApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, provider): + tenant_id = current_user.current_tenant_id + + return jsonable_encoder( + BuiltinToolManageService.get_builtin_tool_provider_credential_info( + tenant_id=tenant_id, + provider=provider, + ) + ) + + class ToolProviderMCPApi(Resource): @setup_required @login_required @@ -794,17 +1009,33 @@ class ToolMCPCallbackApi(Resource): # tool provider api.add_resource(ToolProviderListApi, "/workspaces/current/tool-providers") +# tool oauth +api.add_resource(ToolPluginOAuthApi, "/oauth/plugin//tool/authorization-url") +api.add_resource(ToolOAuthCallback, "/oauth/plugin//tool/callback") +api.add_resource(ToolOAuthCustomClient, "/workspaces/current/tool-provider/builtin//oauth/custom-client") + # builtin tool provider api.add_resource(ToolBuiltinProviderListToolsApi, "/workspaces/current/tool-provider/builtin//tools") api.add_resource(ToolBuiltinProviderInfoApi, "/workspaces/current/tool-provider/builtin//info") +api.add_resource(ToolBuiltinProviderAddApi, "/workspaces/current/tool-provider/builtin//add") api.add_resource(ToolBuiltinProviderDeleteApi, "/workspaces/current/tool-provider/builtin//delete") api.add_resource(ToolBuiltinProviderUpdateApi, "/workspaces/current/tool-provider/builtin//update") +api.add_resource( + ToolBuiltinProviderSetDefaultApi, "/workspaces/current/tool-provider/builtin//default-credential" +) +api.add_resource( + ToolBuiltinProviderGetCredentialInfoApi, "/workspaces/current/tool-provider/builtin//credential/info" +) api.add_resource( ToolBuiltinProviderGetCredentialsApi, "/workspaces/current/tool-provider/builtin//credentials" ) api.add_resource( ToolBuiltinProviderCredentialsSchemaApi, - "/workspaces/current/tool-provider/builtin//credentials_schema", + "/workspaces/current/tool-provider/builtin//credential/schema/", +) +api.add_resource( + ToolBuiltinProviderGetOauthClientSchemaApi, + "/workspaces/current/tool-provider/builtin//oauth/client-schema", ) api.add_resource(ToolBuiltinProviderIconApi, "/workspaces/current/tool-provider/builtin//icon") diff --git a/api/controllers/inner_api/plugin/plugin.py b/api/controllers/inner_api/plugin/plugin.py index 327e9ce834..5dfe41eb6b 100644 --- a/api/controllers/inner_api/plugin/plugin.py +++ b/api/controllers/inner_api/plugin/plugin.py @@ -175,6 +175,7 @@ class PluginInvokeToolApi(Resource): provider=payload.provider, tool_name=payload.tool, tool_parameters=payload.tool_parameters, + credential_id=payload.credential_id, ), ) diff --git a/api/core/agent/entities.py b/api/core/agent/entities.py index 143a3a51aa..a31c1050bd 100644 --- a/api/core/agent/entities.py +++ b/api/core/agent/entities.py @@ -16,6 +16,7 @@ class AgentToolEntity(BaseModel): tool_name: str tool_parameters: dict[str, Any] = Field(default_factory=dict) plugin_unique_identifier: str | None = None + credential_id: str | None = None class AgentPromptEntity(BaseModel): diff --git a/api/core/agent/strategy/base.py b/api/core/agent/strategy/base.py index ead81a7a0e..a52a1dfd7a 100644 --- a/api/core/agent/strategy/base.py +++ b/api/core/agent/strategy/base.py @@ -4,6 +4,7 @@ from typing import Any, Optional from core.agent.entities import AgentInvokeMessage from core.agent.plugin_entities import AgentStrategyParameter +from core.plugin.entities.request import InvokeCredentials class BaseAgentStrategy(ABC): @@ -18,11 +19,12 @@ class BaseAgentStrategy(ABC): conversation_id: Optional[str] = None, app_id: Optional[str] = None, message_id: Optional[str] = None, + credentials: Optional[InvokeCredentials] = None, ) -> Generator[AgentInvokeMessage, None, None]: """ Invoke the agent strategy. """ - yield from self._invoke(params, user_id, conversation_id, app_id, message_id) + yield from self._invoke(params, user_id, conversation_id, app_id, message_id, credentials) def get_parameters(self) -> Sequence[AgentStrategyParameter]: """ @@ -38,5 +40,6 @@ class BaseAgentStrategy(ABC): conversation_id: Optional[str] = None, app_id: Optional[str] = None, message_id: Optional[str] = None, + credentials: Optional[InvokeCredentials] = None, ) -> Generator[AgentInvokeMessage, None, None]: pass diff --git a/api/core/agent/strategy/plugin.py b/api/core/agent/strategy/plugin.py index 4cfcfbf86a..04661581a7 100644 --- a/api/core/agent/strategy/plugin.py +++ b/api/core/agent/strategy/plugin.py @@ -4,6 +4,7 @@ from typing import Any, Optional from core.agent.entities import AgentInvokeMessage from core.agent.plugin_entities import AgentStrategyEntity, AgentStrategyParameter from core.agent.strategy.base import BaseAgentStrategy +from core.plugin.entities.request import InvokeCredentials, PluginInvokeContext from core.plugin.impl.agent import PluginAgentClient from core.plugin.utils.converter import convert_parameters_to_plugin_format @@ -40,6 +41,7 @@ class PluginAgentStrategy(BaseAgentStrategy): conversation_id: Optional[str] = None, app_id: Optional[str] = None, message_id: Optional[str] = None, + credentials: Optional[InvokeCredentials] = None, ) -> Generator[AgentInvokeMessage, None, None]: """ Invoke the agent strategy. @@ -58,4 +60,5 @@ class PluginAgentStrategy(BaseAgentStrategy): conversation_id=conversation_id, app_id=app_id, message_id=message_id, + context=PluginInvokeContext(credentials=credentials or InvokeCredentials()), ) diff --git a/api/core/app/app_config/easy_ui_based_app/agent/manager.py b/api/core/app/app_config/easy_ui_based_app/agent/manager.py index 590b944c0d..8887d2500c 100644 --- a/api/core/app/app_config/easy_ui_based_app/agent/manager.py +++ b/api/core/app/app_config/easy_ui_based_app/agent/manager.py @@ -39,6 +39,7 @@ class AgentConfigManager: "provider_id": tool["provider_id"], "tool_name": tool["tool_name"], "tool_parameters": tool.get("tool_parameters", {}), + "credential_id": tool.get("credential_id", None), } agent_tools.append(AgentToolEntity(**agent_tool_properties)) diff --git a/api/core/helper/provider_cache.py b/api/core/helper/provider_cache.py new file mode 100644 index 0000000000..48ec3be5c8 --- /dev/null +++ b/api/core/helper/provider_cache.py @@ -0,0 +1,84 @@ +import json +from abc import ABC, abstractmethod +from json import JSONDecodeError +from typing import Any, Optional + +from extensions.ext_redis import redis_client + + +class ProviderCredentialsCache(ABC): + """Base class for provider credentials cache""" + + def __init__(self, **kwargs): + self.cache_key = self._generate_cache_key(**kwargs) + + @abstractmethod + def _generate_cache_key(self, **kwargs) -> str: + """Generate cache key based on subclass implementation""" + pass + + def get(self) -> Optional[dict]: + """Get cached provider credentials""" + cached_credentials = redis_client.get(self.cache_key) + if cached_credentials: + try: + cached_credentials = cached_credentials.decode("utf-8") + return dict(json.loads(cached_credentials)) + except JSONDecodeError: + return None + return None + + def set(self, config: dict[str, Any]) -> None: + """Cache provider credentials""" + redis_client.setex(self.cache_key, 86400, json.dumps(config)) + + def delete(self) -> None: + """Delete cached provider credentials""" + redis_client.delete(self.cache_key) + + +class SingletonProviderCredentialsCache(ProviderCredentialsCache): + """Cache for tool single provider credentials""" + + def __init__(self, tenant_id: str, provider_type: str, provider_identity: str): + super().__init__( + tenant_id=tenant_id, + provider_type=provider_type, + provider_identity=provider_identity, + ) + + def _generate_cache_key(self, **kwargs) -> str: + tenant_id = kwargs["tenant_id"] + provider_type = kwargs["provider_type"] + identity_name = kwargs["provider_identity"] + identity_id = f"{provider_type}.{identity_name}" + return f"{provider_type}_credentials:tenant_id:{tenant_id}:id:{identity_id}" + + +class ToolProviderCredentialsCache(ProviderCredentialsCache): + """Cache for tool provider credentials""" + + def __init__(self, tenant_id: str, provider: str, credential_id: str): + super().__init__(tenant_id=tenant_id, provider=provider, credential_id=credential_id) + + def _generate_cache_key(self, **kwargs) -> str: + tenant_id = kwargs["tenant_id"] + provider = kwargs["provider"] + credential_id = kwargs["credential_id"] + return f"tool_credentials:tenant_id:{tenant_id}:provider:{provider}:credential_id:{credential_id}" + + +class NoOpProviderCredentialCache: + """No-op provider credential cache""" + + def get(self) -> Optional[dict]: + """Get cached provider credentials""" + return None + + def set(self, config: dict[str, Any]) -> None: + """Cache provider credentials""" + pass + + def delete(self) -> None: + """Delete cached provider credentials""" + pass diff --git a/api/core/helper/tool_provider_cache.py b/api/core/helper/tool_provider_cache.py deleted file mode 100644 index 2e4a04c579..0000000000 --- a/api/core/helper/tool_provider_cache.py +++ /dev/null @@ -1,51 +0,0 @@ -import json -from enum import Enum -from json import JSONDecodeError -from typing import Optional - -from extensions.ext_redis import redis_client - - -class ToolProviderCredentialsCacheType(Enum): - PROVIDER = "tool_provider" - ENDPOINT = "endpoint" - - -class ToolProviderCredentialsCache: - def __init__(self, tenant_id: str, identity_id: str, cache_type: ToolProviderCredentialsCacheType): - self.cache_key = f"{cache_type.value}_credentials:tenant_id:{tenant_id}:id:{identity_id}" - - def get(self) -> Optional[dict]: - """ - Get cached model provider credentials. - - :return: - """ - cached_provider_credentials = redis_client.get(self.cache_key) - if cached_provider_credentials: - try: - cached_provider_credentials = cached_provider_credentials.decode("utf-8") - cached_provider_credentials = json.loads(cached_provider_credentials) - except JSONDecodeError: - return None - - return dict(cached_provider_credentials) - else: - return None - - def set(self, credentials: dict) -> None: - """ - Cache model provider credentials. - - :param credentials: provider credentials - :return: - """ - redis_client.setex(self.cache_key, 86400, json.dumps(credentials)) - - def delete(self) -> None: - """ - Delete cached model provider credentials. - - :return: - """ - redis_client.delete(self.cache_key) diff --git a/api/core/plugin/backwards_invocation/encrypt.py b/api/core/plugin/backwards_invocation/encrypt.py index 81a5d033a0..213f5c726a 100644 --- a/api/core/plugin/backwards_invocation/encrypt.py +++ b/api/core/plugin/backwards_invocation/encrypt.py @@ -1,16 +1,20 @@ +from core.helper.provider_cache import SingletonProviderCredentialsCache from core.plugin.entities.request import RequestInvokeEncrypt -from core.tools.utils.configuration import ProviderConfigEncrypter +from core.tools.utils.encryption import create_provider_encrypter from models.account import Tenant class PluginEncrypter: @classmethod def invoke_encrypt(cls, tenant: Tenant, payload: RequestInvokeEncrypt) -> dict: - encrypter = ProviderConfigEncrypter( + encrypter, cache = create_provider_encrypter( tenant_id=tenant.id, config=payload.config, - provider_type=payload.namespace, - provider_identity=payload.identity, + cache=SingletonProviderCredentialsCache( + tenant_id=tenant.id, + provider_type=payload.namespace, + provider_identity=payload.identity, + ), ) if payload.opt == "encrypt": @@ -22,7 +26,7 @@ class PluginEncrypter: "data": encrypter.decrypt(payload.data), } elif payload.opt == "clear": - encrypter.delete_tool_credentials_cache() + cache.delete() return { "data": {}, } diff --git a/api/core/plugin/backwards_invocation/tool.py b/api/core/plugin/backwards_invocation/tool.py index 1d62743f13..06773504d9 100644 --- a/api/core/plugin/backwards_invocation/tool.py +++ b/api/core/plugin/backwards_invocation/tool.py @@ -1,5 +1,5 @@ from collections.abc import Generator -from typing import Any +from typing import Any, Optional from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler from core.plugin.backwards_invocation.base import BaseBackwardsInvocation @@ -23,6 +23,7 @@ class PluginToolBackwardsInvocation(BaseBackwardsInvocation): provider: str, tool_name: str, tool_parameters: dict[str, Any], + credential_id: Optional[str] = None, ) -> Generator[ToolInvokeMessage, None, None]: """ invoke tool @@ -30,7 +31,7 @@ class PluginToolBackwardsInvocation(BaseBackwardsInvocation): # get tool runtime try: tool_runtime = ToolManager.get_tool_runtime_from_plugin( - tool_type, tenant_id, provider, tool_name, tool_parameters + tool_type, tenant_id, provider, tool_name, tool_parameters, credential_id ) response = ToolEngine.generic_invoke( tool_runtime, tool_parameters, user_id, DifyWorkflowCallbackHandler(), workflow_call_depth=1 diff --git a/api/core/plugin/entities/request.py b/api/core/plugin/entities/request.py index 89f595ec46..3a783dad3e 100644 --- a/api/core/plugin/entities/request.py +++ b/api/core/plugin/entities/request.py @@ -27,6 +27,20 @@ from core.workflow.nodes.question_classifier.entities import ( ) +class InvokeCredentials(BaseModel): + tool_credentials: dict[str, str] = Field( + default_factory=dict, + description="Map of tool provider to credential id, used to store the credential id for the tool provider.", + ) + + +class PluginInvokeContext(BaseModel): + credentials: Optional[InvokeCredentials] = Field( + default_factory=InvokeCredentials, + description="Credentials context for the plugin invocation or backward invocation.", + ) + + class RequestInvokeTool(BaseModel): """ Request to invoke a tool @@ -36,6 +50,7 @@ class RequestInvokeTool(BaseModel): provider: str tool: str tool_parameters: dict + credential_id: Optional[str] = None class BaseRequestInvokeModel(BaseModel): diff --git a/api/core/plugin/impl/agent.py b/api/core/plugin/impl/agent.py index 66b77c7489..9575c57ac8 100644 --- a/api/core/plugin/impl/agent.py +++ b/api/core/plugin/impl/agent.py @@ -6,6 +6,7 @@ from core.plugin.entities.plugin import GenericProviderID from core.plugin.entities.plugin_daemon import ( PluginAgentProviderEntity, ) +from core.plugin.entities.request import PluginInvokeContext from core.plugin.impl.base import BasePluginClient @@ -83,6 +84,7 @@ class PluginAgentClient(BasePluginClient): conversation_id: Optional[str] = None, app_id: Optional[str] = None, message_id: Optional[str] = None, + context: Optional[PluginInvokeContext] = None, ) -> Generator[AgentInvokeMessage, None, None]: """ Invoke the agent with the given tenant, user, plugin, provider, name and parameters. @@ -99,6 +101,7 @@ class PluginAgentClient(BasePluginClient): "conversation_id": conversation_id, "app_id": app_id, "message_id": message_id, + "context": context.model_dump() if context else {}, "data": { "agent_strategy_provider": agent_provider_id.provider_name, "agent_strategy": agent_strategy, diff --git a/api/core/plugin/impl/oauth.py b/api/core/plugin/impl/oauth.py index b006bf1d4b..d73e5d9f9e 100644 --- a/api/core/plugin/impl/oauth.py +++ b/api/core/plugin/impl/oauth.py @@ -15,27 +15,32 @@ class OAuthHandler(BasePluginClient): user_id: str, plugin_id: str, provider: str, + redirect_uri: str, system_credentials: Mapping[str, Any], ) -> PluginOAuthAuthorizationUrlResponse: - response = self._request_with_plugin_daemon_response_stream( - "POST", - f"plugin/{tenant_id}/dispatch/oauth/get_authorization_url", - PluginOAuthAuthorizationUrlResponse, - data={ - "user_id": user_id, - "data": { - "provider": provider, - "system_credentials": system_credentials, + try: + response = self._request_with_plugin_daemon_response_stream( + "POST", + f"plugin/{tenant_id}/dispatch/oauth/get_authorization_url", + PluginOAuthAuthorizationUrlResponse, + data={ + "user_id": user_id, + "data": { + "provider": provider, + "redirect_uri": redirect_uri, + "system_credentials": system_credentials, + }, }, - }, - headers={ - "X-Plugin-ID": plugin_id, - "Content-Type": "application/json", - }, - ) - for resp in response: - return resp - raise ValueError("No response received from plugin daemon for authorization URL request.") + headers={ + "X-Plugin-ID": plugin_id, + "Content-Type": "application/json", + }, + ) + for resp in response: + return resp + raise ValueError("No response received from plugin daemon for authorization URL request.") + except Exception as e: + raise ValueError(f"Error getting authorization URL: {e}") def get_credentials( self, @@ -43,6 +48,7 @@ class OAuthHandler(BasePluginClient): user_id: str, plugin_id: str, provider: str, + redirect_uri: str, system_credentials: Mapping[str, Any], request: Request, ) -> PluginOAuthCredentialsResponse: @@ -50,30 +56,33 @@ class OAuthHandler(BasePluginClient): Get credentials from the given request. """ - # encode request to raw http request - raw_request_bytes = self._convert_request_to_raw_data(request) - - response = self._request_with_plugin_daemon_response_stream( - "POST", - f"plugin/{tenant_id}/dispatch/oauth/get_credentials", - PluginOAuthCredentialsResponse, - data={ - "user_id": user_id, - "data": { - "provider": provider, - "system_credentials": system_credentials, - # for json serialization - "raw_http_request": binascii.hexlify(raw_request_bytes).decode(), + try: + # encode request to raw http request + raw_request_bytes = self._convert_request_to_raw_data(request) + response = self._request_with_plugin_daemon_response_stream( + "POST", + f"plugin/{tenant_id}/dispatch/oauth/get_credentials", + PluginOAuthCredentialsResponse, + data={ + "user_id": user_id, + "data": { + "provider": provider, + "redirect_uri": redirect_uri, + "system_credentials": system_credentials, + # for json serialization + "raw_http_request": binascii.hexlify(raw_request_bytes).decode(), + }, + }, + headers={ + "X-Plugin-ID": plugin_id, + "Content-Type": "application/json", }, - }, - headers={ - "X-Plugin-ID": plugin_id, - "Content-Type": "application/json", - }, - ) - for resp in response: - return resp - raise ValueError("No response received from plugin daemon for authorization URL request.") + ) + for resp in response: + return resp + raise ValueError("No response received from plugin daemon for authorization URL request.") + except Exception as e: + raise ValueError(f"Error getting credentials: {e}") def _convert_request_to_raw_data(self, request: Request) -> bytes: """ diff --git a/api/core/plugin/impl/tool.py b/api/core/plugin/impl/tool.py index 19b26c8fe3..04225f95ee 100644 --- a/api/core/plugin/impl/tool.py +++ b/api/core/plugin/impl/tool.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from core.plugin.entities.plugin import GenericProviderID, ToolProviderID from core.plugin.entities.plugin_daemon import PluginBasicBooleanResponse, PluginToolProviderEntity from core.plugin.impl.base import BasePluginClient -from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter +from core.tools.entities.tool_entities import CredentialType, ToolInvokeMessage, ToolParameter class PluginToolManager(BasePluginClient): @@ -78,6 +78,7 @@ class PluginToolManager(BasePluginClient): tool_provider: str, tool_name: str, credentials: dict[str, Any], + credential_type: CredentialType, tool_parameters: dict[str, Any], conversation_id: Optional[str] = None, app_id: Optional[str] = None, @@ -102,6 +103,7 @@ class PluginToolManager(BasePluginClient): "provider": tool_provider_id.provider_name, "tool": tool_name, "credentials": credentials, + "credential_type": credential_type, "tool_parameters": tool_parameters, }, }, diff --git a/api/core/tools/__base/tool_runtime.py b/api/core/tools/__base/tool_runtime.py index c9e157cb77..ddec7b1329 100644 --- a/api/core/tools/__base/tool_runtime.py +++ b/api/core/tools/__base/tool_runtime.py @@ -4,7 +4,7 @@ from openai import BaseModel from pydantic import Field from core.app.entities.app_invoke_entities import InvokeFrom -from core.tools.entities.tool_entities import ToolInvokeFrom +from core.tools.entities.tool_entities import CredentialType, ToolInvokeFrom class ToolRuntime(BaseModel): @@ -17,6 +17,7 @@ class ToolRuntime(BaseModel): invoke_from: Optional[InvokeFrom] = None tool_invoke_from: Optional[ToolInvokeFrom] = None credentials: dict[str, Any] = Field(default_factory=dict) + credential_type: CredentialType = Field(default=CredentialType.API_KEY) runtime_parameters: dict[str, Any] = Field(default_factory=dict) diff --git a/api/core/tools/builtin_tool/provider.py b/api/core/tools/builtin_tool/provider.py index cf75bd3d7e..a70ded9efd 100644 --- a/api/core/tools/builtin_tool/provider.py +++ b/api/core/tools/builtin_tool/provider.py @@ -7,7 +7,13 @@ from core.helper.module_import_helper import load_single_subclass_from_source from core.tools.__base.tool_provider import ToolProviderController from core.tools.__base.tool_runtime import ToolRuntime from core.tools.builtin_tool.tool import BuiltinTool -from core.tools.entities.tool_entities import ToolEntity, ToolProviderEntity, ToolProviderType +from core.tools.entities.tool_entities import ( + CredentialType, + OAuthSchema, + ToolEntity, + ToolProviderEntity, + ToolProviderType, +) from core.tools.entities.values import ToolLabelEnum, default_tool_label_dict from core.tools.errors import ( ToolProviderNotFoundError, @@ -39,10 +45,18 @@ class BuiltinToolProviderController(ToolProviderController): credential_dict = provider_yaml.get("credentials_for_provider", {}).get(credential, {}) credentials_schema.append(credential_dict) + oauth_schema = None + if provider_yaml.get("oauth_schema", None) is not None: + oauth_schema = OAuthSchema( + client_schema=provider_yaml.get("oauth_schema", {}).get("client_schema", []), + credentials_schema=provider_yaml.get("oauth_schema", {}).get("credentials_schema", []), + ) + super().__init__( entity=ToolProviderEntity( identity=provider_yaml["identity"], credentials_schema=credentials_schema, + oauth_schema=oauth_schema, ), ) @@ -97,10 +111,39 @@ class BuiltinToolProviderController(ToolProviderController): :return: the credentials schema """ - if not self.entity.credentials_schema: - return [] + return self.get_credentials_schema_by_type(CredentialType.API_KEY.value) + + def get_credentials_schema_by_type(self, credential_type: str) -> list[ProviderConfig]: + """ + returns the credentials schema of the provider - return self.entity.credentials_schema.copy() + :param credential_type: the type of the credential + :return: the credentials schema of the provider + """ + if credential_type == CredentialType.OAUTH2.value: + return self.entity.oauth_schema.credentials_schema.copy() if self.entity.oauth_schema else [] + if credential_type == CredentialType.API_KEY.value: + return self.entity.credentials_schema.copy() if self.entity.credentials_schema else [] + raise ValueError(f"Invalid credential type: {credential_type}") + + def get_oauth_client_schema(self) -> list[ProviderConfig]: + """ + returns the oauth client schema of the provider + + :return: the oauth client schema + """ + return self.entity.oauth_schema.client_schema.copy() if self.entity.oauth_schema else [] + + def get_supported_credential_types(self) -> list[str]: + """ + returns the credential support type of the provider + """ + types = [] + if self.entity.credentials_schema is not None and len(self.entity.credentials_schema) > 0: + types.append(CredentialType.API_KEY.value) + if self.entity.oauth_schema is not None and len(self.entity.oauth_schema.credentials_schema) > 0: + types.append(CredentialType.OAUTH2.value) + return types def get_tools(self) -> list[BuiltinTool]: """ @@ -123,7 +166,11 @@ class BuiltinToolProviderController(ToolProviderController): :return: whether the provider needs credentials """ - return self.entity.credentials_schema is not None and len(self.entity.credentials_schema) != 0 + return ( + self.entity.credentials_schema is not None + and len(self.entity.credentials_schema) != 0 + or (self.entity.oauth_schema is not None and len(self.entity.oauth_schema.credentials_schema) != 0) + ) @property def provider_type(self) -> ToolProviderType: diff --git a/api/core/tools/entities/api_entities.py b/api/core/tools/entities/api_entities.py index 90134ba71d..27ce96b90e 100644 --- a/api/core/tools/entities/api_entities.py +++ b/api/core/tools/entities/api_entities.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, field_validator from core.model_runtime.utils.encoders import jsonable_encoder from core.tools.__base.tool import ToolParameter from core.tools.entities.common_entities import I18nObject -from core.tools.entities.tool_entities import ToolProviderType +from core.tools.entities.tool_entities import CredentialType, ToolProviderType class ToolApiEntity(BaseModel): @@ -87,3 +87,22 @@ class ToolProviderApiEntity(BaseModel): def optional_field(self, key: str, value: Any) -> dict: """Return dict with key-value if value is truthy, empty dict otherwise.""" return {key: value} if value else {} + + +class ToolProviderCredentialApiEntity(BaseModel): + id: str = Field(description="The unique id of the credential") + name: str = Field(description="The name of the credential") + provider: str = Field(description="The provider of the credential") + credential_type: CredentialType = Field(description="The type of the credential") + is_default: bool = Field( + default=False, description="Whether the credential is the default credential for the provider in the workspace" + ) + credentials: dict = Field(description="The credentials of the provider") + + +class ToolProviderCredentialInfoApiEntity(BaseModel): + supported_credential_types: list[str] = Field(description="The supported credential types of the provider") + is_oauth_custom_client_enabled: bool = Field( + default=False, description="Whether the OAuth custom client is enabled for the provider" + ) + credentials: list[ToolProviderCredentialApiEntity] = Field(description="The credentials of the provider") diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py index 64568a8eda..5377cbbb69 100644 --- a/api/core/tools/entities/tool_entities.py +++ b/api/core/tools/entities/tool_entities.py @@ -370,10 +370,18 @@ class ToolEntity(BaseModel): return v or [] +class OAuthSchema(BaseModel): + client_schema: list[ProviderConfig] = Field(default_factory=list, description="The schema of the OAuth client") + credentials_schema: list[ProviderConfig] = Field( + default_factory=list, description="The schema of the OAuth credentials" + ) + + class ToolProviderEntity(BaseModel): identity: ToolProviderIdentity plugin_id: Optional[str] = None credentials_schema: list[ProviderConfig] = Field(default_factory=list) + oauth_schema: Optional[OAuthSchema] = None class ToolProviderEntityWithPlugin(ToolProviderEntity): @@ -453,6 +461,7 @@ class ToolSelector(BaseModel): options: Optional[list[PluginParameterOption]] = None provider_id: str = Field(..., description="The id of the provider") + credential_id: Optional[str] = Field(default=None, description="The id of the credential") tool_name: str = Field(..., description="The name of the tool") tool_description: str = Field(..., description="The description of the tool") tool_configuration: Mapping[str, Any] = Field(..., description="Configuration, type form") @@ -460,3 +469,36 @@ class ToolSelector(BaseModel): def to_plugin_parameter(self) -> dict[str, Any]: return self.model_dump() + + +class CredentialType(enum.StrEnum): + API_KEY = "api-key" + OAUTH2 = "oauth2" + + def get_name(self): + if self == CredentialType.API_KEY: + return "API KEY" + elif self == CredentialType.OAUTH2: + return "AUTH" + else: + return self.value.replace("-", " ").upper() + + def is_editable(self): + return self == CredentialType.API_KEY + + def is_validate_allowed(self): + return self == CredentialType.API_KEY + + @classmethod + def values(cls): + return [item.value for item in cls] + + @classmethod + def of(cls, credential_type: str) -> "CredentialType": + type_name = credential_type.lower() + if type_name == "api-key": + return cls.API_KEY + elif type_name == "oauth2": + return cls.OAUTH2 + else: + raise ValueError(f"Invalid credential type: {credential_type}") diff --git a/api/core/tools/plugin_tool/tool.py b/api/core/tools/plugin_tool/tool.py index d21e3d7d1c..aef2677c36 100644 --- a/api/core/tools/plugin_tool/tool.py +++ b/api/core/tools/plugin_tool/tool.py @@ -44,6 +44,7 @@ class PluginTool(Tool): tool_provider=self.entity.identity.provider, tool_name=self.entity.identity.name, credentials=self.runtime.credentials, + credential_type=self.runtime.credential_type, tool_parameters=tool_parameters, conversation_id=conversation_id, app_id=app_id, diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index 22a9853b41..d61856a8f5 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast from yarl import URL import contexts +from core.helper.provider_cache import ToolProviderCredentialsCache from core.plugin.entities.plugin import ToolProviderID from core.plugin.impl.tool import PluginToolManager from core.tools.__base.tool_provider import ToolProviderController @@ -17,6 +18,7 @@ from core.tools.mcp_tool.provider import MCPToolProviderController from core.tools.mcp_tool.tool import MCPTool from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.plugin_tool.tool import PluginTool +from core.tools.utils.uuid_utils import is_valid_uuid from core.tools.workflow_as_tool.provider import WorkflowToolProviderController from core.workflow.entities.variable_pool import VariablePool from services.tools.mcp_tools_mange_service import MCPToolManageService @@ -24,7 +26,6 @@ from services.tools.mcp_tools_mange_service import MCPToolManageService if TYPE_CHECKING: from core.workflow.nodes.tool.entities import ToolEntity - from configs import dify_config from core.agent.entities import AgentToolEntity from core.app.entities.app_invoke_entities import InvokeFrom @@ -41,16 +42,17 @@ from core.tools.entities.api_entities import ToolProviderApiEntity, ToolProvider from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ( ApiProviderAuthType, + CredentialType, ToolInvokeFrom, ToolParameter, ToolProviderType, ) -from core.tools.errors import ToolNotFoundError, ToolProviderNotFoundError +from core.tools.errors import ToolProviderNotFoundError from core.tools.tool_label_manager import ToolLabelManager from core.tools.utils.configuration import ( - ProviderConfigEncrypter, ToolParameterConfigurationManager, ) +from core.tools.utils.encryption import create_provider_encrypter, create_tool_provider_encrypter from core.tools.workflow_as_tool.tool import WorkflowTool from extensions.ext_database import db from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider @@ -68,8 +70,11 @@ class ToolManager: @classmethod def get_hardcoded_provider(cls, provider: str) -> BuiltinToolProviderController: """ + get the hardcoded provider + """ + if len(cls._hardcoded_providers) == 0: # init the builtin providers cls.load_hardcoded_providers_cache() @@ -113,7 +118,12 @@ class ToolManager: contexts.plugin_tool_providers.set({}) contexts.plugin_tool_providers_lock.set(Lock()) + plugin_tool_providers = contexts.plugin_tool_providers.get() + if provider in plugin_tool_providers: + return plugin_tool_providers[provider] + with contexts.plugin_tool_providers_lock.get(): + # double check plugin_tool_providers = contexts.plugin_tool_providers.get() if provider in plugin_tool_providers: return plugin_tool_providers[provider] @@ -131,25 +141,7 @@ class ToolManager: ) plugin_tool_providers[provider] = controller - - return controller - - @classmethod - def get_builtin_tool(cls, provider: str, tool_name: str, tenant_id: str) -> BuiltinTool | PluginTool | None: - """ - get the builtin tool - - :param provider: the name of the provider - :param tool_name: the name of the tool - :param tenant_id: the id of the tenant - :return: the provider, the tool - """ - provider_controller = cls.get_builtin_provider(provider, tenant_id) - tool = provider_controller.get_tool(tool_name) - if tool is None: - raise ToolNotFoundError(f"tool {tool_name} not found") - - return tool + return controller @classmethod def get_tool_runtime( @@ -160,6 +152,7 @@ class ToolManager: tenant_id: str, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, tool_invoke_from: ToolInvokeFrom = ToolInvokeFrom.AGENT, + credential_id: Optional[str] = None, ) -> Union[BuiltinTool, PluginTool, ApiTool, WorkflowTool, MCPTool]: """ get the tool runtime @@ -170,6 +163,7 @@ class ToolManager: :param tenant_id: the tenant id :param invoke_from: invoke from :param tool_invoke_from: the tool invoke from + :param credential_id: the credential id :return: the tool """ @@ -193,49 +187,70 @@ class ToolManager: ) ), ) - + builtin_provider = None if isinstance(provider_controller, PluginToolProviderController): provider_id_entity = ToolProviderID(provider_id) - # get credentials - builtin_provider: BuiltinToolProvider | None = ( - db.session.query(BuiltinToolProvider) - .filter( - BuiltinToolProvider.tenant_id == tenant_id, - (BuiltinToolProvider.provider == str(provider_id_entity)) - | (BuiltinToolProvider.provider == provider_id_entity.provider_name), - ) - .first() - ) - + # get specific credentials + if is_valid_uuid(credential_id): + try: + builtin_provider = ( + db.session.query(BuiltinToolProvider) + .filter( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.id == credential_id, + ) + .first() + ) + except Exception as e: + builtin_provider = None + logger.info(f"Error getting builtin provider {credential_id}:{e}", exc_info=True) + # if the provider has been deleted, raise an error + if builtin_provider is None: + raise ToolProviderNotFoundError(f"provider has been deleted: {credential_id}") + + # fallback to the default provider if builtin_provider is None: - raise ToolProviderNotFoundError(f"builtin provider {provider_id} not found") + # use the default provider + builtin_provider = ( + db.session.query(BuiltinToolProvider) + .filter( + BuiltinToolProvider.tenant_id == tenant_id, + (BuiltinToolProvider.provider == str(provider_id_entity)) + | (BuiltinToolProvider.provider == provider_id_entity.provider_name), + ) + .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) + .first() + ) + if builtin_provider is None: + raise ToolProviderNotFoundError(f"no default provider for {provider_id}") else: builtin_provider = ( db.session.query(BuiltinToolProvider) .filter(BuiltinToolProvider.tenant_id == tenant_id, (BuiltinToolProvider.provider == provider_id)) + .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) .first() ) if builtin_provider is None: raise ToolProviderNotFoundError(f"builtin provider {provider_id} not found") - # decrypt the credentials - credentials = builtin_provider.credentials - tool_configuration = ProviderConfigEncrypter( + encrypter, _ = create_provider_encrypter( tenant_id=tenant_id, - config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()], - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, + config=[ + x.to_basic_provider_config() + for x in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type) + ], + cache=ToolProviderCredentialsCache( + tenant_id=tenant_id, provider=provider_id, credential_id=builtin_provider.id + ), ) - - decrypted_credentials = tool_configuration.decrypt(credentials) - return cast( BuiltinTool, builtin_tool.fork_tool_runtime( runtime=ToolRuntime( tenant_id=tenant_id, - credentials=decrypted_credentials, + credentials=encrypter.decrypt(builtin_provider.credentials), + credential_type=CredentialType.of(builtin_provider.credential_type), runtime_parameters={}, invoke_from=invoke_from, tool_invoke_from=tool_invoke_from, @@ -245,22 +260,16 @@ class ToolManager: elif provider_type == ToolProviderType.API: api_provider, credentials = cls.get_api_provider_controller(tenant_id, provider_id) - - # decrypt the credentials - tool_configuration = ProviderConfigEncrypter( + encrypter, _ = create_tool_provider_encrypter( tenant_id=tenant_id, - config=[x.to_basic_provider_config() for x in api_provider.get_credentials_schema()], - provider_type=api_provider.provider_type.value, - provider_identity=api_provider.entity.identity.name, + controller=api_provider, ) - decrypted_credentials = tool_configuration.decrypt(credentials) - return cast( ApiTool, api_provider.get_tool(tool_name).fork_tool_runtime( runtime=ToolRuntime( tenant_id=tenant_id, - credentials=decrypted_credentials, + credentials=encrypter.decrypt(credentials), invoke_from=invoke_from, tool_invoke_from=tool_invoke_from, ) @@ -320,6 +329,7 @@ class ToolManager: tenant_id=tenant_id, invoke_from=invoke_from, tool_invoke_from=ToolInvokeFrom.AGENT, + credential_id=agent_tool.credential_id, ) runtime_parameters = {} parameters = tool_entity.get_merged_runtime_parameters() @@ -362,6 +372,7 @@ class ToolManager: tenant_id=tenant_id, invoke_from=invoke_from, tool_invoke_from=ToolInvokeFrom.WORKFLOW, + credential_id=workflow_tool.credential_id, ) parameters = tool_runtime.get_merged_runtime_parameters() @@ -391,6 +402,7 @@ class ToolManager: provider: str, tool_name: str, tool_parameters: dict[str, Any], + credential_id: Optional[str] = None, ) -> Tool: """ get tool runtime from plugin @@ -402,6 +414,7 @@ class ToolManager: tenant_id=tenant_id, invoke_from=InvokeFrom.SERVICE_API, tool_invoke_from=ToolInvokeFrom.PLUGIN, + credential_id=credential_id, ) runtime_parameters = {} parameters = tool_entity.get_merged_runtime_parameters() @@ -551,6 +564,22 @@ class ToolManager: return cls._builtin_tools_labels[tool_name] + @classmethod + def list_default_builtin_providers(cls, tenant_id: str) -> list[BuiltinToolProvider]: + """ + list all the builtin providers + """ + # according to multi credentials, select the one with is_default=True first, then created_at oldest + # for compatibility with old version + sql = """ + SELECT DISTINCT ON (tenant_id, provider) id + FROM tool_builtin_providers + WHERE tenant_id = :tenant_id + ORDER BY tenant_id, provider, is_default DESC, created_at DESC + """ + ids = [row.id for row in db.session.execute(db.text(sql), {"tenant_id": tenant_id}).all()] + return db.session.query(BuiltinToolProvider).filter(BuiltinToolProvider.id.in_(ids)).all() + @classmethod def list_providers_from_api( cls, user_id: str, tenant_id: str, typ: ToolProviderTypeApiLiteral @@ -565,21 +594,13 @@ class ToolManager: with db.session.no_autoflush: if "builtin" in filters: - # get builtin providers builtin_providers = cls.list_builtin_providers(tenant_id) - # get db builtin providers - db_builtin_providers: list[BuiltinToolProvider] = ( - db.session.query(BuiltinToolProvider).filter(BuiltinToolProvider.tenant_id == tenant_id).all() - ) - - # rewrite db_builtin_providers - for db_provider in db_builtin_providers: - tool_provider_id = str(ToolProviderID(db_provider.provider)) - db_provider.provider = tool_provider_id - - def find_db_builtin_provider(provider): - return next((x for x in db_builtin_providers if x.provider == provider), None) + # key: provider name, value: provider + db_builtin_providers = { + str(ToolProviderID(provider.provider)): provider + for provider in cls.list_default_builtin_providers(tenant_id) + } # append builtin providers for provider in builtin_providers: @@ -591,10 +612,9 @@ class ToolManager: name_func=lambda x: x.identity.name, ): continue - user_provider = ToolTransformService.builtin_provider_to_user_provider( provider_controller=provider, - db_provider=find_db_builtin_provider(provider.entity.identity.name), + db_provider=db_builtin_providers.get(provider.entity.identity.name), decrypt_credentials=False, ) @@ -604,7 +624,6 @@ class ToolManager: result_providers[f"builtin_provider.{user_provider.name}"] = user_provider # get db api providers - if "api" in filters: db_api_providers: list[ApiToolProvider] = ( db.session.query(ApiToolProvider).filter(ApiToolProvider.tenant_id == tenant_id).all() @@ -764,15 +783,12 @@ class ToolManager: auth_type, ) # init tool configuration - tool_configuration = ProviderConfigEncrypter( + encrypter, _ = create_tool_provider_encrypter( tenant_id=tenant_id, - config=[x.to_basic_provider_config() for x in controller.get_credentials_schema()], - provider_type=controller.provider_type.value, - provider_identity=controller.entity.identity.name, + controller=controller, ) - decrypted_credentials = tool_configuration.decrypt(credentials) - masked_credentials = tool_configuration.mask_tool_credentials(decrypted_credentials) + masked_credentials = encrypter.mask_tool_credentials(encrypter.decrypt(credentials)) try: icon = json.loads(provider_obj.icon) diff --git a/api/core/tools/utils/configuration.py b/api/core/tools/utils/configuration.py index 251fedf56e..aceba6e69f 100644 --- a/api/core/tools/utils/configuration.py +++ b/api/core/tools/utils/configuration.py @@ -1,12 +1,8 @@ from copy import deepcopy from typing import Any -from pydantic import BaseModel - -from core.entities.provider_entities import BasicProviderConfig from core.helper import encrypter from core.helper.tool_parameter_cache import ToolParameterCache, ToolParameterCacheType -from core.helper.tool_provider_cache import ToolProviderCredentialsCache, ToolProviderCredentialsCacheType from core.tools.__base.tool import Tool from core.tools.entities.tool_entities import ( ToolParameter, @@ -14,110 +10,6 @@ from core.tools.entities.tool_entities import ( ) -class ProviderConfigEncrypter(BaseModel): - tenant_id: str - config: list[BasicProviderConfig] - provider_type: str - provider_identity: str - - def _deep_copy(self, data: dict[str, str]) -> dict[str, str]: - """ - deep copy data - """ - return deepcopy(data) - - def encrypt(self, data: dict[str, str]) -> dict[str, str]: - """ - encrypt tool credentials with tenant id - - return a deep copy of credentials with encrypted values - """ - data = self._deep_copy(data) - - # get fields need to be decrypted - fields = dict[str, BasicProviderConfig]() - for credential in self.config: - fields[credential.name] = credential - - for field_name, field in fields.items(): - if field.type == BasicProviderConfig.Type.SECRET_INPUT: - if field_name in data: - encrypted = encrypter.encrypt_token(self.tenant_id, data[field_name] or "") - data[field_name] = encrypted - - return data - - def mask_tool_credentials(self, data: dict[str, Any]) -> dict[str, Any]: - """ - mask tool credentials - - return a deep copy of credentials with masked values - """ - data = self._deep_copy(data) - - # get fields need to be decrypted - fields = dict[str, BasicProviderConfig]() - for credential in self.config: - fields[credential.name] = credential - - for field_name, field in fields.items(): - if field.type == BasicProviderConfig.Type.SECRET_INPUT: - if field_name in data: - if len(data[field_name]) > 6: - data[field_name] = ( - data[field_name][:2] + "*" * (len(data[field_name]) - 4) + data[field_name][-2:] - ) - else: - data[field_name] = "*" * len(data[field_name]) - - return data - - def decrypt(self, data: dict[str, str], use_cache: bool = True) -> dict[str, str]: - """ - decrypt tool credentials with tenant id - - return a deep copy of credentials with decrypted values - """ - if use_cache: - cache = ToolProviderCredentialsCache( - tenant_id=self.tenant_id, - identity_id=f"{self.provider_type}.{self.provider_identity}", - cache_type=ToolProviderCredentialsCacheType.PROVIDER, - ) - cached_credentials = cache.get() - if cached_credentials: - return cached_credentials - data = self._deep_copy(data) - # get fields need to be decrypted - fields = dict[str, BasicProviderConfig]() - for credential in self.config: - fields[credential.name] = credential - - for field_name, field in fields.items(): - if field.type == BasicProviderConfig.Type.SECRET_INPUT: - if field_name in data: - try: - # if the value is None or empty string, skip decrypt - if not data[field_name]: - continue - - data[field_name] = encrypter.decrypt_token(self.tenant_id, data[field_name]) - except Exception: - pass - - if use_cache: - cache.set(data) - return data - - def delete_tool_credentials_cache(self): - cache = ToolProviderCredentialsCache( - tenant_id=self.tenant_id, - identity_id=f"{self.provider_type}.{self.provider_identity}", - cache_type=ToolProviderCredentialsCacheType.PROVIDER, - ) - cache.delete() - - class ToolParameterConfigurationManager: """ Tool parameter configuration manager diff --git a/api/core/tools/utils/encryption.py b/api/core/tools/utils/encryption.py new file mode 100644 index 0000000000..5fdfd3b9d1 --- /dev/null +++ b/api/core/tools/utils/encryption.py @@ -0,0 +1,142 @@ +from copy import deepcopy +from typing import Any, Optional, Protocol + +from core.entities.provider_entities import BasicProviderConfig +from core.helper import encrypter +from core.helper.provider_cache import SingletonProviderCredentialsCache +from core.tools.__base.tool_provider import ToolProviderController + + +class ProviderConfigCache(Protocol): + """ + Interface for provider configuration cache operations + """ + + def get(self) -> Optional[dict]: + """Get cached provider configuration""" + ... + + def set(self, config: dict[str, Any]) -> None: + """Cache provider configuration""" + ... + + def delete(self) -> None: + """Delete cached provider configuration""" + ... + + +class ProviderConfigEncrypter: + tenant_id: str + config: list[BasicProviderConfig] + provider_config_cache: ProviderConfigCache + + def __init__( + self, + tenant_id: str, + config: list[BasicProviderConfig], + provider_config_cache: ProviderConfigCache, + ): + self.tenant_id = tenant_id + self.config = config + self.provider_config_cache = provider_config_cache + + def _deep_copy(self, data: dict[str, str]) -> dict[str, str]: + """ + deep copy data + """ + return deepcopy(data) + + def encrypt(self, data: dict[str, str]) -> dict[str, str]: + """ + encrypt tool credentials with tenant id + + return a deep copy of credentials with encrypted values + """ + data = self._deep_copy(data) + + # get fields need to be decrypted + fields = dict[str, BasicProviderConfig]() + for credential in self.config: + fields[credential.name] = credential + + for field_name, field in fields.items(): + if field.type == BasicProviderConfig.Type.SECRET_INPUT: + if field_name in data: + encrypted = encrypter.encrypt_token(self.tenant_id, data[field_name] or "") + data[field_name] = encrypted + + return data + + def mask_tool_credentials(self, data: dict[str, Any]) -> dict[str, Any]: + """ + mask tool credentials + + return a deep copy of credentials with masked values + """ + data = self._deep_copy(data) + + # get fields need to be decrypted + fields = dict[str, BasicProviderConfig]() + for credential in self.config: + fields[credential.name] = credential + + for field_name, field in fields.items(): + if field.type == BasicProviderConfig.Type.SECRET_INPUT: + if field_name in data: + if len(data[field_name]) > 6: + data[field_name] = ( + data[field_name][:2] + "*" * (len(data[field_name]) - 4) + data[field_name][-2:] + ) + else: + data[field_name] = "*" * len(data[field_name]) + + return data + + def decrypt(self, data: dict[str, str]) -> dict[str, Any]: + """ + decrypt tool credentials with tenant id + + return a deep copy of credentials with decrypted values + """ + cached_credentials = self.provider_config_cache.get() + if cached_credentials: + return cached_credentials + + data = self._deep_copy(data) + # get fields need to be decrypted + fields = dict[str, BasicProviderConfig]() + for credential in self.config: + fields[credential.name] = credential + + for field_name, field in fields.items(): + if field.type == BasicProviderConfig.Type.SECRET_INPUT: + if field_name in data: + try: + # if the value is None or empty string, skip decrypt + if not data[field_name]: + continue + + data[field_name] = encrypter.decrypt_token(self.tenant_id, data[field_name]) + except Exception: + pass + + self.provider_config_cache.set(data) + return data + + +def create_provider_encrypter(tenant_id: str, config: list[BasicProviderConfig], cache: ProviderConfigCache): + return ProviderConfigEncrypter(tenant_id=tenant_id, config=config, provider_config_cache=cache), cache + + +def create_tool_provider_encrypter(tenant_id: str, controller: ToolProviderController): + cache = SingletonProviderCredentialsCache( + tenant_id=tenant_id, + provider_type=controller.provider_type.value, + provider_identity=controller.entity.identity.name, + ) + encrypt = ProviderConfigEncrypter( + tenant_id=tenant_id, + config=[x.to_basic_provider_config() for x in controller.get_credentials_schema()], + provider_config_cache=cache, + ) + return encrypt, cache diff --git a/api/core/tools/utils/system_oauth_encryption.py b/api/core/tools/utils/system_oauth_encryption.py new file mode 100644 index 0000000000..f3c946b95f --- /dev/null +++ b/api/core/tools/utils/system_oauth_encryption.py @@ -0,0 +1,187 @@ +import base64 +import hashlib +import logging +from collections.abc import Mapping +from typing import Any, Optional + +from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes +from Crypto.Util.Padding import pad, unpad +from pydantic import TypeAdapter + +from configs import dify_config + +logger = logging.getLogger(__name__) + + +class OAuthEncryptionError(Exception): + """OAuth encryption/decryption specific error""" + + pass + + +class SystemOAuthEncrypter: + """ + A simple OAuth parameters encrypter using AES-CBC encryption. + + This class provides methods to encrypt and decrypt OAuth parameters + using AES-CBC mode with a key derived from the application's SECRET_KEY. + """ + + def __init__(self, secret_key: Optional[str] = None): + """ + Initialize the OAuth encrypter. + + Args: + secret_key: Optional secret key. If not provided, uses dify_config.SECRET_KEY + + Raises: + ValueError: If SECRET_KEY is not configured or empty + """ + secret_key = secret_key or dify_config.SECRET_KEY or "" + + # Generate a fixed 256-bit key using SHA-256 + self.key = hashlib.sha256(secret_key.encode()).digest() + + def encrypt_oauth_params(self, oauth_params: Mapping[str, Any]) -> str: + """ + Encrypt OAuth parameters. + + Args: + oauth_params: OAuth parameters dictionary, e.g., {"client_id": "xxx", "client_secret": "xxx"} + + Returns: + Base64-encoded encrypted string + + Raises: + OAuthEncryptionError: If encryption fails + ValueError: If oauth_params is invalid + """ + + try: + # Generate random IV (16 bytes) + iv = get_random_bytes(16) + + # Create AES cipher (CBC mode) + cipher = AES.new(self.key, AES.MODE_CBC, iv) + + # Encrypt data + padded_data = pad(TypeAdapter(dict).dump_json(dict(oauth_params)), AES.block_size) + encrypted_data = cipher.encrypt(padded_data) + + # Combine IV and encrypted data + combined = iv + encrypted_data + + # Return base64 encoded string + return base64.b64encode(combined).decode() + + except Exception as e: + raise OAuthEncryptionError(f"Encryption failed: {str(e)}") from e + + def decrypt_oauth_params(self, encrypted_data: str) -> Mapping[str, Any]: + """ + Decrypt OAuth parameters. + + Args: + encrypted_data: Base64-encoded encrypted string + + Returns: + Decrypted OAuth parameters dictionary + + Raises: + OAuthEncryptionError: If decryption fails + ValueError: If encrypted_data is invalid + """ + if not isinstance(encrypted_data, str): + raise ValueError("encrypted_data must be a string") + + if not encrypted_data: + raise ValueError("encrypted_data cannot be empty") + + try: + # Base64 decode + combined = base64.b64decode(encrypted_data) + + # Check minimum length (IV + at least one AES block) + if len(combined) < 32: # 16 bytes IV + 16 bytes minimum encrypted data + raise ValueError("Invalid encrypted data format") + + # Separate IV and encrypted data + iv = combined[:16] + encrypted_data_bytes = combined[16:] + + # Create AES cipher + cipher = AES.new(self.key, AES.MODE_CBC, iv) + + # Decrypt data + decrypted_data = cipher.decrypt(encrypted_data_bytes) + unpadded_data = unpad(decrypted_data, AES.block_size) + + # Parse JSON + oauth_params: Mapping[str, Any] = TypeAdapter(Mapping[str, Any]).validate_json(unpadded_data) + + if not isinstance(oauth_params, dict): + raise ValueError("Decrypted data is not a valid dictionary") + + return oauth_params + + except Exception as e: + raise OAuthEncryptionError(f"Decryption failed: {str(e)}") from e + + +# Factory function for creating encrypter instances +def create_system_oauth_encrypter(secret_key: Optional[str] = None) -> SystemOAuthEncrypter: + """ + Create an OAuth encrypter instance. + + Args: + secret_key: Optional secret key. If not provided, uses dify_config.SECRET_KEY + + Returns: + SystemOAuthEncrypter instance + """ + return SystemOAuthEncrypter(secret_key=secret_key) + + +# Global encrypter instance (for backward compatibility) +_oauth_encrypter: Optional[SystemOAuthEncrypter] = None + + +def get_system_oauth_encrypter() -> SystemOAuthEncrypter: + """ + Get the global OAuth encrypter instance. + + Returns: + SystemOAuthEncrypter instance + """ + global _oauth_encrypter + if _oauth_encrypter is None: + _oauth_encrypter = SystemOAuthEncrypter() + return _oauth_encrypter + + +# Convenience functions for backward compatibility +def encrypt_system_oauth_params(oauth_params: Mapping[str, Any]) -> str: + """ + Encrypt OAuth parameters using the global encrypter. + + Args: + oauth_params: OAuth parameters dictionary + + Returns: + Base64-encoded encrypted string + """ + return get_system_oauth_encrypter().encrypt_oauth_params(oauth_params) + + +def decrypt_system_oauth_params(encrypted_data: str) -> Mapping[str, Any]: + """ + Decrypt OAuth parameters using the global encrypter. + + Args: + encrypted_data: Base64-encoded encrypted string + + Returns: + Decrypted OAuth parameters dictionary + """ + return get_system_oauth_encrypter().decrypt_oauth_params(encrypted_data) diff --git a/api/core/tools/utils/uuid_utils.py b/api/core/tools/utils/uuid_utils.py index 3046c08c89..bdcc33259d 100644 --- a/api/core/tools/utils/uuid_utils.py +++ b/api/core/tools/utils/uuid_utils.py @@ -1,7 +1,9 @@ import uuid -def is_valid_uuid(uuid_str: str) -> bool: +def is_valid_uuid(uuid_str: str | None) -> bool: + if uuid_str is None or len(uuid_str) == 0: + return False try: uuid.UUID(uuid_str) return True diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 678b99d546..ce67197a58 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -4,6 +4,7 @@ from collections.abc import Generator, Mapping, Sequence from typing import Any, Optional, cast from packaging.version import Version +from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.orm import Session @@ -13,10 +14,16 @@ from core.agent.strategy.plugin import PluginAgentStrategy from core.memory.token_buffer_memory import TokenBufferMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities.model_entities import AIModelEntity, ModelType +from core.plugin.entities.request import InvokeCredentials from core.plugin.impl.exc import PluginDaemonClientSideError from core.plugin.impl.plugin import PluginInstaller from core.provider_manager import ProviderManager -from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter, ToolProviderType +from core.tools.entities.tool_entities import ( + ToolIdentity, + ToolInvokeMessage, + ToolParameter, + ToolProviderType, +) from core.tools.tool_manager import ToolManager from core.variables.segments import StringSegment from core.workflow.entities.node_entities import NodeRunResult @@ -84,6 +91,7 @@ class AgentNode(ToolNode): for_log=True, strategy=strategy, ) + credentials = self._generate_credentials(parameters=parameters) # get conversation id conversation_id = self.graph_runtime_state.variable_pool.get(["sys", SystemVariableKey.CONVERSATION_ID]) @@ -94,6 +102,7 @@ class AgentNode(ToolNode): user_id=self.user_id, app_id=self.app_id, conversation_id=conversation_id.text if conversation_id else None, + credentials=credentials, ) except Exception as e: yield RunCompletedEvent( @@ -246,6 +255,7 @@ class AgentNode(ToolNode): tool_name=tool.get("tool_name", ""), tool_parameters=parameters, plugin_unique_identifier=tool.get("plugin_unique_identifier", None), + credential_id=tool.get("credential_id", None), ) extra = tool.get("extra", {}) @@ -276,6 +286,7 @@ class AgentNode(ToolNode): { **tool_runtime.entity.model_dump(mode="json"), "runtime_parameters": runtime_parameters, + "credential_id": tool.get("credential_id", None), "provider_type": provider_type.value, } ) @@ -305,6 +316,27 @@ class AgentNode(ToolNode): return result + def _generate_credentials( + self, + parameters: dict[str, Any], + ) -> InvokeCredentials: + """ + Generate credentials based on the given agent parameters. + """ + + credentials = InvokeCredentials() + + # generate credentials for tools selector + credentials.tool_credentials = {} + for tool in parameters.get("tools", []): + if tool.get("credential_id"): + try: + identity = ToolIdentity.model_validate(tool.get("identity", {})) + credentials.tool_credentials[identity.provider] = tool.get("credential_id", None) + except ValidationError: + continue + return credentials + @classmethod def _extract_variable_selector_to_variable_mapping( cls, diff --git a/api/core/workflow/nodes/tool/entities.py b/api/core/workflow/nodes/tool/entities.py index 691f6e0196..88c5160d14 100644 --- a/api/core/workflow/nodes/tool/entities.py +++ b/api/core/workflow/nodes/tool/entities.py @@ -14,6 +14,7 @@ class ToolEntity(BaseModel): tool_name: str tool_label: str # redundancy tool_configurations: dict[str, Any] + credential_id: str | None = None plugin_unique_identifier: str | None = None # redundancy @field_validator("tool_configurations", mode="before") diff --git a/api/events/event_handlers/delete_tool_parameters_cache_when_sync_draft_workflow.py b/api/events/event_handlers/delete_tool_parameters_cache_when_sync_draft_workflow.py index 249bd14429..6c9fc0bf1d 100644 --- a/api/events/event_handlers/delete_tool_parameters_cache_when_sync_draft_workflow.py +++ b/api/events/event_handlers/delete_tool_parameters_cache_when_sync_draft_workflow.py @@ -20,6 +20,7 @@ def handle(sender, **kwargs): provider_id=tool_entity.provider_id, tool_name=tool_entity.tool_name, tenant_id=app.tenant_id, + credential_id=tool_entity.credential_id, ) manager = ToolParameterConfigurationManager( tenant_id=app.tenant_id, diff --git a/api/extensions/ext_commands.py b/api/extensions/ext_commands.py index ddc2158a02..600e336c19 100644 --- a/api/extensions/ext_commands.py +++ b/api/extensions/ext_commands.py @@ -18,6 +18,7 @@ def init_app(app: DifyApp): reset_email, reset_encrypt_key_pair, reset_password, + setup_system_tool_oauth_client, upgrade_db, vdb_migrate, ) @@ -40,6 +41,7 @@ def init_app(app: DifyApp): clear_free_plan_tenant_expired_logs, clear_orphaned_file_records, remove_orphaned_files_on_storage, + setup_system_tool_oauth_client, ] for cmd in cmds_to_register: app.cli.add_command(cmd) diff --git a/api/migrations/versions/2025_05_15_1635-16081485540c_.py b/api/migrations/versions/2025_05_15_1635-16081485540c_.py new file mode 100644 index 0000000000..f55730bfb2 --- /dev/null +++ b/api/migrations/versions/2025_05_15_1635-16081485540c_.py @@ -0,0 +1,41 @@ +"""empty message + +Revision ID: 16081485540c +Revises: d28f2004b072 +Create Date: 2025-05-15 16:35:39.113777 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '16081485540c' +down_revision = '2adcbe1f5dfb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tenant_plugin_auto_upgrade_strategies', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('strategy_setting', sa.String(length=16), server_default='fix_only', nullable=False), + sa.Column('upgrade_time_of_day', sa.Integer(), nullable=False), + sa.Column('upgrade_mode', sa.String(length=16), server_default='exclude', nullable=False), + sa.Column('exclude_plugins', sa.ARRAY(sa.String(length=255)), nullable=False), + sa.Column('include_plugins', sa.ARRAY(sa.String(length=255)), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='tenant_plugin_auto_upgrade_strategy_pkey'), + sa.UniqueConstraint('tenant_id', name='unique_tenant_plugin_auto_upgrade_strategy') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('tenant_plugin_auto_upgrade_strategies') + # ### end Alembic commands ### diff --git a/api/migrations/versions/2025_06_06_1424-4474872b0ee6_workflow_draft_varaibles_add_node_execution_id.py b/api/migrations/versions/2025_06_06_1424-4474872b0ee6_workflow_draft_varaibles_add_node_execution_id.py index d7a5d116c9..47ac27511e 100644 --- a/api/migrations/versions/2025_06_06_1424-4474872b0ee6_workflow_draft_varaibles_add_node_execution_id.py +++ b/api/migrations/versions/2025_06_06_1424-4474872b0ee6_workflow_draft_varaibles_add_node_execution_id.py @@ -12,7 +12,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '4474872b0ee6' -down_revision = '2adcbe1f5dfb' +down_revision = '16081485540c' branch_labels = None depends_on = None diff --git a/api/migrations/versions/2025_07_04_1705-71f5020c6470_tool_oauth.py b/api/migrations/versions/2025_07_04_1705-71f5020c6470_tool_oauth.py new file mode 100644 index 0000000000..df4fbf0a0e --- /dev/null +++ b/api/migrations/versions/2025_07_04_1705-71f5020c6470_tool_oauth.py @@ -0,0 +1,62 @@ +"""tool oauth + +Revision ID: 71f5020c6470 +Revises: 4474872b0ee6 +Create Date: 2025-06-24 17:05:43.118647 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '71f5020c6470' +down_revision = '1c9ba48be8e4' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tool_oauth_system_clients', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('plugin_id', sa.String(length=512), nullable=False), + sa.Column('provider', sa.String(length=255), nullable=False), + sa.Column('encrypted_oauth_params', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id', name='tool_oauth_system_client_pkey'), + sa.UniqueConstraint('plugin_id', 'provider', name='tool_oauth_system_client_plugin_id_provider_idx') + ) + op.create_table('tool_oauth_tenant_clients', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('plugin_id', sa.String(length=512), nullable=False), + sa.Column('provider', sa.String(length=255), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False), + sa.Column('encrypted_oauth_params', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id', name='tool_oauth_tenant_client_pkey'), + sa.UniqueConstraint('tenant_id', 'plugin_id', 'provider', name='unique_tool_oauth_tenant_client') + ) + + with op.batch_alter_table('tool_builtin_providers', schema=None) as batch_op: + batch_op.add_column(sa.Column('name', sa.String(length=256), server_default=sa.text("'API KEY 1'::character varying"), nullable=False)) + batch_op.add_column(sa.Column('is_default', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + batch_op.add_column(sa.Column('credential_type', sa.String(length=32), server_default=sa.text("'api-key'::character varying"), nullable=False)) + batch_op.drop_constraint(batch_op.f('unique_builtin_tool_provider'), type_='unique') + batch_op.create_unique_constraint(batch_op.f('unique_builtin_tool_provider'), ['tenant_id', 'provider', 'name']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tool_builtin_providers', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('unique_builtin_tool_provider'), type_='unique') + batch_op.create_unique_constraint(batch_op.f('unique_builtin_tool_provider'), ['tenant_id', 'provider']) + batch_op.drop_column('credential_type') + batch_op.drop_column('is_default') + batch_op.drop_column('name') + + op.drop_table('tool_oauth_tenant_clients') + op.drop_table('tool_oauth_system_clients') + # ### end Alembic commands ### diff --git a/api/models/tools.py b/api/models/tools.py index 9d2c3baea5..7c8b5853ba 100644 --- a/api/models/tools.py +++ b/api/models/tools.py @@ -21,6 +21,43 @@ from .model import Account, App, Tenant from .types import StringUUID +# system level tool oauth client params (client_id, client_secret, etc.) +class ToolOAuthSystemClient(Base): + __tablename__ = "tool_oauth_system_clients" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="tool_oauth_system_client_pkey"), + db.UniqueConstraint("plugin_id", "provider", name="tool_oauth_system_client_plugin_id_provider_idx"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) + plugin_id: Mapped[str] = mapped_column(db.String(512), nullable=False) + provider: Mapped[str] = mapped_column(db.String(255), nullable=False) + # oauth params of the tool provider + encrypted_oauth_params: Mapped[str] = mapped_column(db.Text, nullable=False) + + +# tenant level tool oauth client params (client_id, client_secret, etc.) +class ToolOAuthTenantClient(Base): + __tablename__ = "tool_oauth_tenant_clients" + __table_args__ = ( + db.PrimaryKeyConstraint("id", name="tool_oauth_tenant_client_pkey"), + db.UniqueConstraint("tenant_id", "plugin_id", "provider", name="unique_tool_oauth_tenant_client"), + ) + + id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) + # tenant id + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + plugin_id: Mapped[str] = mapped_column(db.String(512), nullable=False) + provider: Mapped[str] = mapped_column(db.String(255), nullable=False) + enabled: Mapped[bool] = mapped_column(db.Boolean, nullable=False, server_default=db.text("true")) + # oauth params of the tool provider + encrypted_oauth_params: Mapped[str] = mapped_column(db.Text, nullable=False) + + @property + def oauth_params(self) -> dict: + return cast(dict, json.loads(self.encrypted_oauth_params or "{}")) + + class BuiltinToolProvider(Base): """ This table stores the tool provider information for built-in tools for each tenant. @@ -29,12 +66,14 @@ class BuiltinToolProvider(Base): __tablename__ = "tool_builtin_providers" __table_args__ = ( db.PrimaryKeyConstraint("id", name="tool_builtin_provider_pkey"), - # one tenant can only have one tool provider with the same name - db.UniqueConstraint("tenant_id", "provider", name="unique_builtin_tool_provider"), + db.UniqueConstraint("tenant_id", "provider", "name", name="unique_builtin_tool_provider"), ) # id of the tool provider id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) + name: Mapped[str] = mapped_column( + db.String(256), nullable=False, server_default=db.text("'API KEY 1'::character varying") + ) # id of the tenant tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=True) # who created this tool provider @@ -49,6 +88,11 @@ class BuiltinToolProvider(Base): updated_at: Mapped[datetime] = mapped_column( db.DateTime, nullable=False, server_default=db.text("CURRENT_TIMESTAMP(0)") ) + is_default: Mapped[bool] = mapped_column(db.Boolean, nullable=False, server_default=db.text("false")) + # credential type, e.g., "api-key", "oauth2" + credential_type: Mapped[str] = mapped_column( + db.String(32), nullable=False, server_default=db.text("'api-key'::character varying") + ) @property def credentials(self) -> dict: @@ -68,7 +112,7 @@ class ApiToolProvider(Base): id = db.Column(StringUUID, server_default=db.text("uuid_generate_v4()")) # name of the api provider - name = db.Column(db.String(255), nullable=False) + name = db.Column(db.String(255), nullable=False, server_default=db.text("'API KEY 1'::character varying")) # icon icon = db.Column(db.String(255), nullable=False) # original schema @@ -281,18 +325,19 @@ class MCPToolProvider(Base): @property def decrypted_credentials(self) -> dict: + from core.helper.provider_cache import NoOpProviderCredentialCache from core.tools.mcp_tool.provider import MCPToolProviderController - from core.tools.utils.configuration import ProviderConfigEncrypter + from core.tools.utils.encryption import create_provider_encrypter provider_controller = MCPToolProviderController._from_db(self) - tool_configuration = ProviderConfigEncrypter( + encrypter, _ = create_provider_encrypter( tenant_id=self.tenant_id, - config=list(provider_controller.get_credentials_schema()), - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.provider_id, + config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()], + cache=NoOpProviderCredentialCache(), ) - return tool_configuration.decrypt(self.credentials, use_cache=False) + + return encrypter.decrypt(self.credentials) # type: ignore class ToolModelInvoke(Base): diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 20257fa345..08e13c588e 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -575,13 +575,26 @@ class AppDslService: raise ValueError("Missing draft workflow configuration, please check.") workflow_dict = workflow.to_dict(include_secret=include_secret) + # TODO: refactor: we need a better way to filter workspace related data from nodes for node in workflow_dict.get("graph", {}).get("nodes", []): - if node.get("data", {}).get("type", "") == NodeType.KNOWLEDGE_RETRIEVAL.value: - dataset_ids = node["data"].get("dataset_ids", []) - node["data"]["dataset_ids"] = [ + node_data = node.get("data", {}) + if not node_data: + continue + data_type = node_data.get("type", "") + if data_type == NodeType.KNOWLEDGE_RETRIEVAL.value: + dataset_ids = node_data.get("dataset_ids", []) + node_data["dataset_ids"] = [ cls.encrypt_dataset_id(dataset_id=dataset_id, tenant_id=app_model.tenant_id) for dataset_id in dataset_ids ] + # filter credential id from tool node + if not include_secret and data_type == NodeType.TOOL.value: + node_data.pop("credential_id", None) + # filter credential id from agent node + if not include_secret and data_type == NodeType.AGENT.value: + for tool in node_data.get("agent_parameters", {}).get("tools", {}).get("value", []): + tool.pop("credential_id", None) + export_data["workflow"] = workflow_dict dependencies = cls._extract_dependencies_from_workflow(workflow) export_data["dependencies"] = [ @@ -602,7 +615,15 @@ class AppDslService: if not app_model_config: raise ValueError("Missing app configuration, please check.") - export_data["model_config"] = app_model_config.to_dict() + model_config = app_model_config.to_dict() + + # TODO: refactor: we need a better way to filter workspace related data from model config + # filter credential id from model config + for tool in model_config.get("agent_mode", {}).get("tools", []): + tool.pop("credential_id", None) + + export_data["model_config"] = model_config + dependencies = cls._extract_dependencies_from_model_config(app_model_config.to_dict()) export_data["dependencies"] = [ jsonable_encoder(d.model_dump()) diff --git a/api/services/plugin/plugin_parameter_service.py b/api/services/plugin/plugin_parameter_service.py index 393213c0e2..a1c5639e00 100644 --- a/api/services/plugin/plugin_parameter_service.py +++ b/api/services/plugin/plugin_parameter_service.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from core.plugin.entities.parameters import PluginParameterOption from core.plugin.impl.dynamic_select import DynamicSelectClient from core.tools.tool_manager import ToolManager -from core.tools.utils.configuration import ProviderConfigEncrypter +from core.tools.utils.encryption import create_tool_provider_encrypter from extensions.ext_database import db from models.tools import BuiltinToolProvider @@ -38,11 +38,9 @@ class PluginParameterService: case "tool": provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) # init tool configuration - tool_configuration = ProviderConfigEncrypter( + encrypter, _ = create_tool_provider_encrypter( tenant_id=tenant_id, - config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()], - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, + controller=provider_controller, ) # check if credentials are required @@ -63,7 +61,7 @@ class PluginParameterService: if db_record is None: raise ValueError(f"Builtin provider {provider} not found when fetching credentials") - credentials = tool_configuration.decrypt(db_record.credentials) + credentials = encrypter.decrypt(db_record.credentials) case _: raise ValueError(f"Invalid provider type: {provider_type}") diff --git a/api/services/plugin/plugin_service.py b/api/services/plugin/plugin_service.py index 0f22afd8dd..0a5bc44b64 100644 --- a/api/services/plugin/plugin_service.py +++ b/api/services/plugin/plugin_service.py @@ -196,6 +196,17 @@ class PluginService: manager = PluginInstaller() return manager.fetch_plugin_manifest(tenant_id, plugin_unique_identifier) + @staticmethod + def is_plugin_verified(tenant_id: str, plugin_unique_identifier: str) -> bool: + """ + Check if the plugin is verified + """ + manager = PluginInstaller() + try: + return manager.fetch_plugin_manifest(tenant_id, plugin_unique_identifier).verified + except Exception: + return False + @staticmethod def fetch_install_tasks(tenant_id: str, page: int, page_size: int) -> Sequence[PluginInstallTask]: """ diff --git a/api/services/tools/api_tools_manage_service.py b/api/services/tools/api_tools_manage_service.py index 6f848d49c4..80badf2335 100644 --- a/api/services/tools/api_tools_manage_service.py +++ b/api/services/tools/api_tools_manage_service.py @@ -18,7 +18,7 @@ from core.tools.entities.tool_entities import ( ) from core.tools.tool_label_manager import ToolLabelManager from core.tools.tool_manager import ToolManager -from core.tools.utils.configuration import ProviderConfigEncrypter +from core.tools.utils.encryption import create_tool_provider_encrypter from core.tools.utils.parser import ApiBasedToolSchemaParser from extensions.ext_database import db from models.tools import ApiToolProvider @@ -164,15 +164,11 @@ class ApiToolManageService: provider_controller.load_bundled_tools(tool_bundles) # encrypt credentials - tool_configuration = ProviderConfigEncrypter( + encrypter, _ = create_tool_provider_encrypter( tenant_id=tenant_id, - config=list(provider_controller.get_credentials_schema()), - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, + controller=provider_controller, ) - - encrypted_credentials = tool_configuration.encrypt(credentials) - db_provider.credentials_str = json.dumps(encrypted_credentials) + db_provider.credentials_str = json.dumps(encrypter.encrypt(credentials)) db.session.add(db_provider) db.session.commit() @@ -297,28 +293,26 @@ class ApiToolManageService: provider_controller.load_bundled_tools(tool_bundles) # get original credentials if exists - tool_configuration = ProviderConfigEncrypter( + encrypter, cache = create_tool_provider_encrypter( tenant_id=tenant_id, - config=list(provider_controller.get_credentials_schema()), - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, + controller=provider_controller, ) - original_credentials = tool_configuration.decrypt(provider.credentials) - masked_credentials = tool_configuration.mask_tool_credentials(original_credentials) + original_credentials = encrypter.decrypt(provider.credentials) + masked_credentials = encrypter.mask_tool_credentials(original_credentials) # check if the credential has changed, save the original credential for name, value in credentials.items(): if name in masked_credentials and value == masked_credentials[name]: credentials[name] = original_credentials[name] - credentials = tool_configuration.encrypt(credentials) + credentials = encrypter.encrypt(credentials) provider.credentials_str = json.dumps(credentials) db.session.add(provider) db.session.commit() # delete cache - tool_configuration.delete_tool_credentials_cache() + cache.delete() # update labels ToolLabelManager.update_tool_labels(provider_controller, labels) @@ -416,15 +410,13 @@ class ApiToolManageService: # decrypt credentials if db_provider.id: - tool_configuration = ProviderConfigEncrypter( + encrypter, _ = create_tool_provider_encrypter( tenant_id=tenant_id, - config=list(provider_controller.get_credentials_schema()), - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, + controller=provider_controller, ) - decrypted_credentials = tool_configuration.decrypt(credentials) + decrypted_credentials = encrypter.decrypt(credentials) # check if the credential has changed, save the original credential - masked_credentials = tool_configuration.mask_tool_credentials(decrypted_credentials) + masked_credentials = encrypter.mask_tool_credentials(decrypted_credentials) for name, value in credentials.items(): if name in masked_credentials and value == masked_credentials[name]: credentials[name] = decrypted_credentials[name] @@ -446,7 +438,7 @@ class ApiToolManageService: return {"result": result or "empty response"} @staticmethod - def list_api_tools(user_id: str, tenant_id: str) -> list[ToolProviderApiEntity]: + def list_api_tools(tenant_id: str) -> list[ToolProviderApiEntity]: """ list api tools """ @@ -474,7 +466,7 @@ class ApiToolManageService: for tool in tools or []: user_provider.tools.append( ToolTransformService.convert_tool_entity_to_api_entity( - tenant_id=tenant_id, tool=tool, credentials=user_provider.original_credentials, labels=labels + tenant_id=tenant_id, tool=tool, labels=labels ) ) diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index 58a4b2f179..430575b532 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -1,28 +1,84 @@ import json import logging +import re +from collections.abc import Mapping from pathlib import Path +from typing import Any, Optional from sqlalchemy.orm import Session from configs import dify_config +from constants import HIDDEN_VALUE, UNKNOWN_VALUE from core.helper.position_helper import is_filtered -from core.model_runtime.utils.encoders import jsonable_encoder +from core.helper.provider_cache import NoOpProviderCredentialCache, ToolProviderCredentialsCache from core.plugin.entities.plugin import ToolProviderID -from core.plugin.impl.exc import PluginDaemonClientSideError +from core.tools.builtin_tool.provider import BuiltinToolProviderController from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort -from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity -from core.tools.errors import ToolNotFoundError, ToolProviderCredentialValidationError, ToolProviderNotFoundError +from core.tools.entities.api_entities import ( + ToolApiEntity, + ToolProviderApiEntity, + ToolProviderCredentialApiEntity, + ToolProviderCredentialInfoApiEntity, +) +from core.tools.entities.tool_entities import CredentialType +from core.tools.errors import ToolProviderNotFoundError +from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.tool_label_manager import ToolLabelManager from core.tools.tool_manager import ToolManager -from core.tools.utils.configuration import ProviderConfigEncrypter +from core.tools.utils.encryption import create_provider_encrypter +from core.tools.utils.system_oauth_encryption import decrypt_system_oauth_params from extensions.ext_database import db -from models.tools import BuiltinToolProvider +from extensions.ext_redis import redis_client +from models.tools import BuiltinToolProvider, ToolOAuthSystemClient, ToolOAuthTenantClient +from services.plugin.plugin_service import PluginService from services.tools.tools_transform_service import ToolTransformService logger = logging.getLogger(__name__) class BuiltinToolManageService: + __MAX_BUILTIN_TOOL_PROVIDER_COUNT__ = 100 + + @staticmethod + def delete_custom_oauth_client_params(tenant_id: str, provider: str): + """ + delete custom oauth client params + """ + tool_provider = ToolProviderID(provider) + with Session(db.engine) as session: + session.query(ToolOAuthTenantClient).filter_by( + tenant_id=tenant_id, + provider=tool_provider.provider_name, + plugin_id=tool_provider.plugin_id, + ).delete() + session.commit() + return {"result": "success"} + + @staticmethod + def get_builtin_tool_provider_oauth_client_schema(tenant_id: str, provider_name: str): + """ + get builtin tool provider oauth client schema + """ + provider = ToolManager.get_builtin_provider(provider_name, tenant_id) + verified = not isinstance(provider, PluginToolProviderController) or PluginService.is_plugin_verified( + tenant_id, provider.plugin_unique_identifier + ) + + is_oauth_custom_client_enabled = BuiltinToolManageService.is_oauth_custom_client_enabled( + tenant_id, provider_name + ) + is_system_oauth_params_exists = verified and BuiltinToolManageService.is_oauth_system_client_exists( + provider_name + ) + result = { + "schema": provider.get_oauth_client_schema(), + "is_oauth_custom_client_enabled": is_oauth_custom_client_enabled, + "is_system_oauth_params_exists": is_system_oauth_params_exists, + "client_params": BuiltinToolManageService.get_custom_oauth_client_params(tenant_id, provider_name), + "redirect_uri": f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider_name}/tool/callback", + } + return result + @staticmethod def list_builtin_tool_provider_tools(tenant_id: str, provider: str) -> list[ToolApiEntity]: """ @@ -36,27 +92,11 @@ class BuiltinToolManageService: provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) tools = provider_controller.get_tools() - tool_provider_configurations = ProviderConfigEncrypter( - tenant_id=tenant_id, - config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()], - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, - ) - # check if user has added the provider - builtin_provider = BuiltinToolManageService._fetch_builtin_provider(provider, tenant_id) - - credentials = {} - if builtin_provider is not None: - # get credentials - credentials = builtin_provider.credentials - credentials = tool_provider_configurations.decrypt(credentials) - result: list[ToolApiEntity] = [] for tool in tools or []: result.append( ToolTransformService.convert_tool_entity_to_api_entity( tool=tool, - credentials=credentials, tenant_id=tenant_id, labels=ToolLabelManager.get_tool_labels(provider_controller), ) @@ -65,25 +105,15 @@ class BuiltinToolManageService: return result @staticmethod - def get_builtin_tool_provider_info(user_id: str, tenant_id: str, provider: str): + def get_builtin_tool_provider_info(tenant_id: str, provider: str): """ get builtin tool provider info """ provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) - tool_provider_configurations = ProviderConfigEncrypter( - tenant_id=tenant_id, - config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()], - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, - ) # check if user has added the provider - builtin_provider = BuiltinToolManageService._fetch_builtin_provider(provider, tenant_id) - - credentials = {} - if builtin_provider is not None: - # get credentials - credentials = builtin_provider.credentials - credentials = tool_provider_configurations.decrypt(credentials) + builtin_provider = BuiltinToolManageService.get_builtin_provider(provider, tenant_id) + if builtin_provider is None: + raise ValueError(f"you have not added provider {provider}") entity = ToolTransformService.builtin_provider_to_user_provider( provider_controller=provider_controller, @@ -92,127 +122,406 @@ class BuiltinToolManageService: ) entity.original_credentials = {} - return entity @staticmethod - def list_builtin_provider_credentials_schema(provider_name: str, tenant_id: str): + def list_builtin_provider_credentials_schema(provider_name: str, credential_type: CredentialType, tenant_id: str): """ list builtin provider credentials schema + :param credential_type: credential type :param provider_name: the name of the provider :param tenant_id: the id of the tenant :return: the list of tool providers """ provider = ToolManager.get_builtin_provider(provider_name, tenant_id) - return jsonable_encoder(provider.get_credentials_schema()) + return provider.get_credentials_schema_by_type(credential_type) @staticmethod def update_builtin_tool_provider( - session: Session, user_id: str, tenant_id: str, provider_name: str, credentials: dict + user_id: str, + tenant_id: str, + provider: str, + credential_id: str, + credentials: dict | None = None, + name: str | None = None, ): """ update builtin tool provider """ - # get if the provider exists - provider = BuiltinToolManageService._fetch_builtin_provider(provider_name, tenant_id) + with Session(db.engine) as session: + # get if the provider exists + db_provider = ( + session.query(BuiltinToolProvider) + .filter( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.id == credential_id, + ) + .first() + ) + if db_provider is None: + raise ValueError(f"you have not added provider {provider}") + + try: + if CredentialType.of(db_provider.credential_type).is_editable() and credentials: + provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) + if not provider_controller.need_credentials: + raise ValueError(f"provider {provider} does not need credentials") + + encrypter, cache = BuiltinToolManageService.create_tool_encrypter( + tenant_id, db_provider, provider, provider_controller + ) + + original_credentials = encrypter.decrypt(db_provider.credentials) + new_credentials: dict = { + key: value if value != HIDDEN_VALUE else original_credentials.get(key, UNKNOWN_VALUE) + for key, value in credentials.items() + } + + if CredentialType.of(db_provider.credential_type).is_validate_allowed(): + provider_controller.validate_credentials(user_id, new_credentials) + # encrypt credentials + db_provider.encrypted_credentials = json.dumps(encrypter.encrypt(new_credentials)) + + cache.delete() + + # update name if provided + if name and name != db_provider.name: + # check if the name is already used + if ( + session.query(BuiltinToolProvider) + .filter_by(tenant_id=tenant_id, provider=provider, name=name) + .count() + > 0 + ): + raise ValueError(f"the credential name '{name}' is already used") + + db_provider.name = name + + session.commit() + except Exception as e: + session.rollback() + raise ValueError(str(e)) + return {"result": "success"} + + @staticmethod + def add_builtin_tool_provider( + user_id: str, + api_type: CredentialType, + tenant_id: str, + provider: str, + credentials: dict, + name: str | None = None, + ): + """ + add builtin tool provider + """ try: - # get provider - provider_controller = ToolManager.get_builtin_provider(provider_name, tenant_id) - if not provider_controller.need_credentials: - raise ValueError(f"provider {provider_name} does not need credentials") - tool_configuration = ProviderConfigEncrypter( - tenant_id=tenant_id, - config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()], - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, - ) + with Session(db.engine) as session: + lock = f"builtin_tool_provider_create_lock:{tenant_id}_{provider}" + with redis_client.lock(lock, timeout=20): + provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) + if not provider_controller.need_credentials: + raise ValueError(f"provider {provider} does not need credentials") + + provider_count = ( + session.query(BuiltinToolProvider).filter_by(tenant_id=tenant_id, provider=provider).count() + ) + + # check if the provider count is reached the limit + if provider_count >= BuiltinToolManageService.__MAX_BUILTIN_TOOL_PROVIDER_COUNT__: + raise ValueError(f"you have reached the maximum number of providers for {provider}") + + # validate credentials if allowed + if CredentialType.of(api_type).is_validate_allowed(): + provider_controller.validate_credentials(user_id, credentials) + + # generate name if not provided + if name is None or name == "": + name = BuiltinToolManageService.generate_builtin_tool_provider_name( + session=session, tenant_id=tenant_id, provider=provider, credential_type=api_type + ) + else: + # check if the name is already used + if ( + session.query(BuiltinToolProvider) + .filter_by(tenant_id=tenant_id, provider=provider, name=name) + .count() + > 0 + ): + raise ValueError(f"the credential name '{name}' is already used") + + # create encrypter + encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=[ + x.to_basic_provider_config() + for x in provider_controller.get_credentials_schema_by_type(api_type) + ], + cache=NoOpProviderCredentialCache(), + ) + + db_provider = BuiltinToolProvider( + tenant_id=tenant_id, + user_id=user_id, + provider=provider, + encrypted_credentials=json.dumps(encrypter.encrypt(credentials)), + credential_type=api_type.value, + name=name, + ) - # get original credentials if exists - if provider is not None: - original_credentials = tool_configuration.decrypt(provider.credentials) - masked_credentials = tool_configuration.mask_tool_credentials(original_credentials) - # check if the credential has changed, save the original credential - for name, value in credentials.items(): - if name in masked_credentials and value == masked_credentials[name]: - credentials[name] = original_credentials[name] - # validate credentials - provider_controller.validate_credentials(user_id, credentials) - # encrypt credentials - credentials = tool_configuration.encrypt(credentials) - except ( - PluginDaemonClientSideError, - ToolProviderNotFoundError, - ToolNotFoundError, - ToolProviderCredentialValidationError, - ) as e: + session.add(db_provider) + session.commit() + except Exception as e: + session.rollback() raise ValueError(str(e)) + return {"result": "success"} - if provider is None: - # create provider - provider = BuiltinToolProvider( - tenant_id=tenant_id, - user_id=user_id, - provider=provider_name, - encrypted_credentials=json.dumps(credentials), + @staticmethod + def create_tool_encrypter( + tenant_id: str, + db_provider: BuiltinToolProvider, + provider: str, + provider_controller: BuiltinToolProviderController, + ): + encrypter, cache = create_provider_encrypter( + tenant_id=tenant_id, + config=[ + x.to_basic_provider_config() + for x in provider_controller.get_credentials_schema_by_type(db_provider.credential_type) + ], + cache=ToolProviderCredentialsCache(tenant_id=tenant_id, provider=provider, credential_id=db_provider.id), + ) + return encrypter, cache + + @staticmethod + def generate_builtin_tool_provider_name( + session: Session, tenant_id: str, provider: str, credential_type: CredentialType + ) -> str: + try: + db_providers = ( + session.query(BuiltinToolProvider) + .filter_by( + tenant_id=tenant_id, + provider=provider, + credential_type=credential_type.value, + ) + .order_by(BuiltinToolProvider.created_at.desc()) + .all() ) - db.session.add(provider) - else: - provider.encrypted_credentials = json.dumps(credentials) + # Get the default name pattern + default_pattern = f"{credential_type.get_name()}" - # delete cache - tool_configuration.delete_tool_credentials_cache() + # Find all names that match the default pattern: "{default_pattern} {number}" + pattern = rf"^{re.escape(default_pattern)}\s+(\d+)$" + numbers = [] - db.session.commit() - return {"result": "success"} + for db_provider in db_providers: + if db_provider.name: + match = re.match(pattern, db_provider.name.strip()) + if match: + numbers.append(int(match.group(1))) + + # If no default pattern names found, start with 1 + if not numbers: + return f"{default_pattern} 1" + + # Find the next number + max_number = max(numbers) + return f"{default_pattern} {max_number + 1}" + except Exception as e: + logger.warning(f"Error generating next provider name for {provider}: {str(e)}") + # fallback + return f"{credential_type.get_name()} 1" @staticmethod - def get_builtin_tool_provider_credentials(tenant_id: str, provider_name: str): + def get_builtin_tool_provider_credentials( + tenant_id: str, provider_name: str + ) -> list[ToolProviderCredentialApiEntity]: """ get builtin tool provider credentials """ - provider_obj = BuiltinToolManageService._fetch_builtin_provider(provider_name, tenant_id) + with db.session.no_autoflush: + providers = ( + db.session.query(BuiltinToolProvider) + .filter_by(tenant_id=tenant_id, provider=provider_name) + .order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc()) + .all() + ) - if provider_obj is None: - return {} + if len(providers) == 0: + return [] + + default_provider = providers[0] + default_provider.is_default = True + provider_controller = ToolManager.get_builtin_provider(default_provider.provider, tenant_id) + + credentials: list[ToolProviderCredentialApiEntity] = [] + encrypters = {} + for provider in providers: + credential_type = provider.credential_type + if credential_type not in encrypters: + encrypters[credential_type] = BuiltinToolManageService.create_tool_encrypter( + tenant_id, provider, provider.provider, provider_controller + )[0] + encrypter = encrypters[credential_type] + decrypt_credential = encrypter.mask_tool_credentials(encrypter.decrypt(provider.credentials)) + credential_entity = ToolTransformService.convert_builtin_provider_to_credential_entity( + provider=provider, + credentials=decrypt_credential, + ) + credentials.append(credential_entity) + return credentials - provider_controller = ToolManager.get_builtin_provider(provider_obj.provider, tenant_id) - tool_configuration = ProviderConfigEncrypter( - tenant_id=tenant_id, - config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()], - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, + @staticmethod + def get_builtin_tool_provider_credential_info(tenant_id: str, provider: str) -> ToolProviderCredentialInfoApiEntity: + """ + get builtin tool provider credential info + """ + provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) + supported_credential_types = provider_controller.get_supported_credential_types() + credentials = BuiltinToolManageService.get_builtin_tool_provider_credentials(tenant_id, provider) + credential_info = ToolProviderCredentialInfoApiEntity( + supported_credential_types=supported_credential_types, + is_oauth_custom_client_enabled=BuiltinToolManageService.is_oauth_custom_client_enabled(tenant_id, provider), + credentials=credentials, ) - credentials = tool_configuration.decrypt(provider_obj.credentials) - credentials = tool_configuration.mask_tool_credentials(credentials) - return credentials + + return credential_info @staticmethod - def delete_builtin_tool_provider(user_id: str, tenant_id: str, provider_name: str): + def delete_builtin_tool_provider(tenant_id: str, provider: str, credential_id: str): """ delete tool provider """ - provider_obj = BuiltinToolManageService._fetch_builtin_provider(provider_name, tenant_id) + with Session(db.engine) as session: + db_provider = ( + session.query(BuiltinToolProvider) + .filter( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.id == credential_id, + ) + .first() + ) - if provider_obj is None: - raise ValueError(f"you have not added provider {provider_name}") + if db_provider is None: + raise ValueError(f"you have not added provider {provider}") - db.session.delete(provider_obj) - db.session.commit() + session.delete(db_provider) + session.commit() - # delete cache - provider_controller = ToolManager.get_builtin_provider(provider_name, tenant_id) - tool_configuration = ProviderConfigEncrypter( + # delete cache + provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) + _, cache = BuiltinToolManageService.create_tool_encrypter( + tenant_id, db_provider, provider, provider_controller + ) + cache.delete() + + return {"result": "success"} + + @staticmethod + def set_default_provider(tenant_id: str, user_id: str, provider: str, id: str): + """ + set default provider + """ + with Session(db.engine) as session: + # get provider + target_provider = session.query(BuiltinToolProvider).filter_by(id=id).first() + if target_provider is None: + raise ValueError("provider not found") + + # clear default provider + session.query(BuiltinToolProvider).filter_by( + tenant_id=tenant_id, user_id=user_id, provider=provider, is_default=True + ).update({"is_default": False}) + + # set new default provider + target_provider.is_default = True + session.commit() + return {"result": "success"} + + @staticmethod + def is_oauth_system_client_exists(provider_name: str) -> bool: + """ + check if oauth system client exists + """ + tool_provider = ToolProviderID(provider_name) + with Session(db.engine).no_autoflush as session: + system_client: ToolOAuthSystemClient | None = ( + session.query(ToolOAuthSystemClient) + .filter_by(plugin_id=tool_provider.plugin_id, provider=tool_provider.provider_name) + .first() + ) + return system_client is not None + + @staticmethod + def is_oauth_custom_client_enabled(tenant_id: str, provider: str) -> bool: + """ + check if oauth custom client is enabled + """ + tool_provider = ToolProviderID(provider) + with Session(db.engine).no_autoflush as session: + user_client: ToolOAuthTenantClient | None = ( + session.query(ToolOAuthTenantClient) + .filter_by( + tenant_id=tenant_id, + provider=tool_provider.provider_name, + plugin_id=tool_provider.plugin_id, + enabled=True, + ) + .first() + ) + return user_client is not None and user_client.enabled + + @staticmethod + def get_oauth_client(tenant_id: str, provider: str) -> Mapping[str, Any] | None: + """ + get builtin tool provider + """ + tool_provider = ToolProviderID(provider) + provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) + encrypter, _ = create_provider_encrypter( tenant_id=tenant_id, - config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()], - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, + config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()], + cache=NoOpProviderCredentialCache(), ) - tool_configuration.delete_tool_credentials_cache() + with Session(db.engine).no_autoflush as session: + user_client: ToolOAuthTenantClient | None = ( + session.query(ToolOAuthTenantClient) + .filter_by( + tenant_id=tenant_id, + provider=tool_provider.provider_name, + plugin_id=tool_provider.plugin_id, + enabled=True, + ) + .first() + ) + oauth_params: Mapping[str, Any] | None = None + if user_client: + oauth_params = encrypter.decrypt(user_client.oauth_params) + return oauth_params + + # only verified provider can use custom oauth client + is_verified = not isinstance(provider, PluginToolProviderController) or PluginService.is_plugin_verified( + tenant_id, provider.plugin_unique_identifier + ) + if not is_verified: + return oauth_params - return {"result": "success"} + system_client: ToolOAuthSystemClient | None = ( + session.query(ToolOAuthSystemClient) + .filter_by(plugin_id=tool_provider.plugin_id, provider=tool_provider.provider_name) + .first() + ) + if system_client: + try: + oauth_params = decrypt_system_oauth_params(system_client.encrypted_oauth_params) + except Exception as e: + raise ValueError(f"Error decrypting system oauth params: {e}") + + return oauth_params @staticmethod def get_builtin_tool_provider_icon(provider: str): @@ -234,9 +543,7 @@ class BuiltinToolManageService: with db.session.no_autoflush: # get all user added providers - db_providers: list[BuiltinToolProvider] = ( - db.session.query(BuiltinToolProvider).filter(BuiltinToolProvider.tenant_id == tenant_id).all() or [] - ) + db_providers: list[BuiltinToolProvider] = ToolManager.list_default_builtin_providers(tenant_id) # rewrite db_providers for db_provider in db_providers: @@ -275,7 +582,6 @@ class BuiltinToolManageService: ToolTransformService.convert_tool_entity_to_api_entity( tenant_id=tenant_id, tool=tool, - credentials=user_builtin_provider.original_credentials, labels=ToolLabelManager.get_tool_labels(provider_controller), ) ) @@ -287,43 +593,153 @@ class BuiltinToolManageService: return BuiltinToolProviderSort.sort(result) @staticmethod - def _fetch_builtin_provider(provider_name: str, tenant_id: str) -> BuiltinToolProvider | None: - try: - full_provider_name = provider_name - provider_id_entity = ToolProviderID(provider_name) - provider_name = provider_id_entity.provider_name - if provider_id_entity.organization != "langgenius": - provider_obj = ( - db.session.query(BuiltinToolProvider) - .filter( - BuiltinToolProvider.tenant_id == tenant_id, - BuiltinToolProvider.provider == full_provider_name, + def get_builtin_provider(provider_name: str, tenant_id: str) -> Optional[BuiltinToolProvider]: + """ + This method is used to fetch the builtin provider from the database + 1.if the default provider exists, return the default provider + 2.if the default provider does not exist, return the oldest provider + """ + with Session(db.engine) as session: + try: + full_provider_name = provider_name + provider_id_entity = ToolProviderID(provider_name) + provider_name = provider_id_entity.provider_name + + if provider_id_entity.organization != "langgenius": + provider = ( + session.query(BuiltinToolProvider) + .filter( + BuiltinToolProvider.tenant_id == tenant_id, + BuiltinToolProvider.provider == full_provider_name, + ) + .order_by( + BuiltinToolProvider.is_default.desc(), # default=True first + BuiltinToolProvider.created_at.asc(), # oldest first + ) + .first() ) - .first() - ) - else: - provider_obj = ( - db.session.query(BuiltinToolProvider) - .filter( - BuiltinToolProvider.tenant_id == tenant_id, - (BuiltinToolProvider.provider == provider_name) - | (BuiltinToolProvider.provider == full_provider_name), + else: + provider = ( + session.query(BuiltinToolProvider) + .filter( + BuiltinToolProvider.tenant_id == tenant_id, + (BuiltinToolProvider.provider == provider_name) + | (BuiltinToolProvider.provider == full_provider_name), + ) + .order_by( + BuiltinToolProvider.is_default.desc(), # default=True first + BuiltinToolProvider.created_at.asc(), # oldest first + ) + .first() + ) + + if provider is None: + return None + + provider.provider = ToolProviderID(provider.provider).to_string() + return provider + except Exception: + # it's an old provider without organization + return ( + session.query(BuiltinToolProvider) + .filter(BuiltinToolProvider.tenant_id == tenant_id, BuiltinToolProvider.provider == provider_name) + .order_by( + BuiltinToolProvider.is_default.desc(), # default=True first + BuiltinToolProvider.created_at.asc(), # oldest first ) .first() ) - if provider_obj is None: - return None + @staticmethod + def save_custom_oauth_client_params( + tenant_id: str, + provider: str, + client_params: Optional[dict] = None, + enable_oauth_custom_client: Optional[bool] = None, + ): + """ + setup oauth custom client + """ + if client_params is None and enable_oauth_custom_client is None: + return {"result": "success"} + + tool_provider = ToolProviderID(provider) + provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) + if not provider_controller: + raise ToolProviderNotFoundError(f"Provider {provider} not found") - provider_obj.provider = ToolProviderID(provider_obj.provider).to_string() - return provider_obj - except Exception: - # it's an old provider without organization - return ( - db.session.query(BuiltinToolProvider) - .filter( - BuiltinToolProvider.tenant_id == tenant_id, - (BuiltinToolProvider.provider == provider_name), + if not isinstance(provider_controller, (BuiltinToolProviderController, PluginToolProviderController)): + raise ValueError(f"Provider {provider} is not a builtin or plugin provider") + + with Session(db.engine) as session: + custom_client_params = ( + session.query(ToolOAuthTenantClient) + .filter_by( + tenant_id=tenant_id, + plugin_id=tool_provider.plugin_id, + provider=tool_provider.provider_name, ) .first() ) + + # if the record does not exist, create a basic record + if custom_client_params is None: + custom_client_params = ToolOAuthTenantClient( + tenant_id=tenant_id, + plugin_id=tool_provider.plugin_id, + provider=tool_provider.provider_name, + ) + session.add(custom_client_params) + + if client_params is not None: + encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()], + cache=NoOpProviderCredentialCache(), + ) + original_params = encrypter.decrypt(custom_client_params.oauth_params) + new_params: dict = { + key: value if value != HIDDEN_VALUE else original_params.get(key, UNKNOWN_VALUE) + for key, value in client_params.items() + } + custom_client_params.encrypted_oauth_params = json.dumps(encrypter.encrypt(new_params)) + + if enable_oauth_custom_client is not None: + custom_client_params.enabled = enable_oauth_custom_client + + session.commit() + return {"result": "success"} + + @staticmethod + def get_custom_oauth_client_params(tenant_id: str, provider: str): + """ + get custom oauth client params + """ + with Session(db.engine) as session: + tool_provider = ToolProviderID(provider) + custom_oauth_client_params: ToolOAuthTenantClient | None = ( + session.query(ToolOAuthTenantClient) + .filter_by( + tenant_id=tenant_id, + plugin_id=tool_provider.plugin_id, + provider=tool_provider.provider_name, + ) + .first() + ) + if custom_oauth_client_params is None: + return {} + + provider_controller = ToolManager.get_builtin_provider(provider, tenant_id) + if not provider_controller: + raise ToolProviderNotFoundError(f"Provider {provider} not found") + + if not isinstance(provider_controller, BuiltinToolProviderController): + raise ValueError(f"Provider {provider} is not a builtin or plugin provider") + + encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()], + cache=NoOpProviderCredentialCache(), + ) + + return encrypter.mask_tool_credentials(encrypter.decrypt(custom_oauth_client_params.oauth_params)) diff --git a/api/services/tools/mcp_tools_mange_service.py b/api/services/tools/mcp_tools_mange_service.py index 7c23abda4b..fda6da5983 100644 --- a/api/services/tools/mcp_tools_mange_service.py +++ b/api/services/tools/mcp_tools_mange_service.py @@ -7,13 +7,14 @@ from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError from core.helper import encrypter +from core.helper.provider_cache import NoOpProviderCredentialCache from core.mcp.error import MCPAuthError, MCPError from core.mcp.mcp_client import MCPClient from core.tools.entities.api_entities import ToolProviderApiEntity from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ToolProviderType from core.tools.mcp_tool.provider import MCPToolProviderController -from core.tools.utils.configuration import ProviderConfigEncrypter +from core.tools.utils.encryption import ProviderConfigEncrypter from extensions.ext_database import db from models.tools import MCPToolProvider from services.tools.tools_transform_service import ToolTransformService @@ -69,6 +70,7 @@ class MCPToolManageService: MCPToolProvider.server_url_hash == server_url_hash, MCPToolProvider.server_identifier == server_identifier, ), + MCPToolProvider.tenant_id == tenant_id, ) .first() ) @@ -197,8 +199,7 @@ class MCPToolManageService: tool_configuration = ProviderConfigEncrypter( tenant_id=mcp_provider.tenant_id, config=list(provider_controller.get_credentials_schema()), - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.provider_id, + provider_config_cache=NoOpProviderCredentialCache(), ) credentials = tool_configuration.encrypt(credentials) mcp_provider.updated_at = datetime.now() diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py index 3d0c35cd9b..36b892e205 100644 --- a/api/services/tools/tools_transform_service.py +++ b/api/services/tools/tools_transform_service.py @@ -5,21 +5,23 @@ from typing import Any, Optional, Union, cast from yarl import URL from configs import dify_config +from core.helper.provider_cache import ToolProviderCredentialsCache from core.mcp.types import Tool as MCPTool from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime from core.tools.builtin_tool.provider import BuiltinToolProviderController from core.tools.custom_tool.provider import ApiToolProviderController -from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity +from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity, ToolProviderCredentialApiEntity from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_bundle import ApiToolBundle from core.tools.entities.tool_entities import ( ApiProviderAuthType, + CredentialType, ToolParameter, ToolProviderType, ) from core.tools.plugin_tool.provider import PluginToolProviderController -from core.tools.utils.configuration import ProviderConfigEncrypter +from core.tools.utils.encryption import create_provider_encrypter, create_tool_provider_encrypter from core.tools.workflow_as_tool.provider import WorkflowToolProviderController from core.tools.workflow_as_tool.tool import WorkflowTool from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider @@ -119,7 +121,12 @@ class ToolTransformService: result.plugin_unique_identifier = provider_controller.plugin_unique_identifier # get credentials schema - schema = {x.to_basic_provider_config().name: x for x in provider_controller.get_credentials_schema()} + schema = { + x.to_basic_provider_config().name: x + for x in provider_controller.get_credentials_schema_by_type( + CredentialType.of(db_provider.credential_type) if db_provider else CredentialType.API_KEY + ) + } for name, value in schema.items(): if result.masked_credentials: @@ -136,15 +143,23 @@ class ToolTransformService: credentials = db_provider.credentials # init tool configuration - tool_configuration = ProviderConfigEncrypter( + encrypter, _ = create_provider_encrypter( tenant_id=db_provider.tenant_id, - config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()], - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, + config=[ + x.to_basic_provider_config() + for x in provider_controller.get_credentials_schema_by_type( + CredentialType.of(db_provider.credential_type) + ) + ], + cache=ToolProviderCredentialsCache( + tenant_id=db_provider.tenant_id, + provider=db_provider.provider, + credential_id=db_provider.id, + ), ) # decrypt the credentials and mask the credentials - decrypted_credentials = tool_configuration.decrypt(data=credentials) - masked_credentials = tool_configuration.mask_tool_credentials(data=decrypted_credentials) + decrypted_credentials = encrypter.decrypt(data=credentials) + masked_credentials = encrypter.mask_tool_credentials(data=decrypted_credentials) result.masked_credentials = masked_credentials result.original_credentials = decrypted_credentials @@ -287,16 +302,14 @@ class ToolTransformService: if decrypt_credentials: # init tool configuration - tool_configuration = ProviderConfigEncrypter( + encrypter, _ = create_tool_provider_encrypter( tenant_id=db_provider.tenant_id, - config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()], - provider_type=provider_controller.provider_type.value, - provider_identity=provider_controller.entity.identity.name, + controller=provider_controller, ) # decrypt the credentials and mask the credentials - decrypted_credentials = tool_configuration.decrypt(data=credentials) - masked_credentials = tool_configuration.mask_tool_credentials(data=decrypted_credentials) + decrypted_credentials = encrypter.decrypt(data=credentials) + masked_credentials = encrypter.mask_tool_credentials(data=decrypted_credentials) result.masked_credentials = masked_credentials @@ -306,7 +319,6 @@ class ToolTransformService: def convert_tool_entity_to_api_entity( tool: Union[ApiToolBundle, WorkflowTool, Tool], tenant_id: str, - credentials: dict | None = None, labels: list[str] | None = None, ) -> ToolApiEntity: """ @@ -316,7 +328,7 @@ class ToolTransformService: # fork tool runtime tool = tool.fork_tool_runtime( runtime=ToolRuntime( - credentials=credentials or {}, + credentials={}, tenant_id=tenant_id, ) ) @@ -357,6 +369,19 @@ class ToolTransformService: labels=labels or [], ) + @staticmethod + def convert_builtin_provider_to_credential_entity( + provider: BuiltinToolProvider, credentials: dict + ) -> ToolProviderCredentialApiEntity: + return ToolProviderCredentialApiEntity( + id=provider.id, + name=provider.name, + provider=provider.provider, + credential_type=CredentialType.of(provider.credential_type), + is_default=provider.is_default, + credentials=credentials, + ) + @staticmethod def convert_mcp_schema_to_parameter(schema: dict) -> list["ToolParameter"]: """ diff --git a/api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py b/api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py new file mode 100644 index 0000000000..30990f8d50 --- /dev/null +++ b/api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py @@ -0,0 +1,619 @@ +import base64 +import hashlib +from unittest.mock import patch + +import pytest +from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes +from Crypto.Util.Padding import pad + +from core.tools.utils.system_oauth_encryption import ( + OAuthEncryptionError, + SystemOAuthEncrypter, + create_system_oauth_encrypter, + decrypt_system_oauth_params, + encrypt_system_oauth_params, + get_system_oauth_encrypter, +) + + +class TestSystemOAuthEncrypter: + """Test cases for SystemOAuthEncrypter class""" + + def test_init_with_secret_key(self): + """Test initialization with provided secret key""" + secret_key = "test_secret_key" + encrypter = SystemOAuthEncrypter(secret_key=secret_key) + expected_key = hashlib.sha256(secret_key.encode()).digest() + assert encrypter.key == expected_key + + def test_init_with_none_secret_key(self): + """Test initialization with None secret key falls back to config""" + with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "config_secret" + encrypter = SystemOAuthEncrypter(secret_key=None) + expected_key = hashlib.sha256(b"config_secret").digest() + assert encrypter.key == expected_key + + def test_init_with_empty_secret_key(self): + """Test initialization with empty secret key""" + encrypter = SystemOAuthEncrypter(secret_key="") + expected_key = hashlib.sha256(b"").digest() + assert encrypter.key == expected_key + + def test_init_without_secret_key_uses_config(self): + """Test initialization without secret key uses config""" + with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "default_secret" + encrypter = SystemOAuthEncrypter() + expected_key = hashlib.sha256(b"default_secret").digest() + assert encrypter.key == expected_key + + def test_encrypt_oauth_params_basic(self): + """Test basic OAuth parameters encryption""" + encrypter = SystemOAuthEncrypter("test_secret") + oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypter.encrypt_oauth_params(oauth_params) + + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + # Should be valid base64 + try: + base64.b64decode(encrypted) + except Exception: + pytest.fail("Encrypted result is not valid base64") + + def test_encrypt_oauth_params_empty_dict(self): + """Test encryption with empty dictionary""" + encrypter = SystemOAuthEncrypter("test_secret") + oauth_params = {} + + encrypted = encrypter.encrypt_oauth_params(oauth_params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_oauth_params_complex_data(self): + """Test encryption with complex data structures""" + encrypter = SystemOAuthEncrypter("test_secret") + oauth_params = { + "client_id": "test_id", + "client_secret": "test_secret", + "scopes": ["read", "write", "admin"], + "metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True}, + "numeric_value": 42, + "boolean_value": False, + "null_value": None, + } + + encrypted = encrypter.encrypt_oauth_params(oauth_params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_oauth_params_unicode_data(self): + """Test encryption with unicode data""" + encrypter = SystemOAuthEncrypter("test_secret") + oauth_params = {"client_id": "test_id", "client_secret": "test_secret", "description": "This is a test case 🚀"} + + encrypted = encrypter.encrypt_oauth_params(oauth_params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_oauth_params_large_data(self): + """Test encryption with large data""" + encrypter = SystemOAuthEncrypter("test_secret") + oauth_params = { + "client_id": "test_id", + "large_data": "x" * 10000, # 10KB of data + } + + encrypted = encrypter.encrypt_oauth_params(oauth_params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_oauth_params_invalid_input(self): + """Test encryption with invalid input types""" + encrypter = SystemOAuthEncrypter("test_secret") + + with pytest.raises(Exception): # noqa: B017 + encrypter.encrypt_oauth_params(None) # type: ignore + + with pytest.raises(Exception): # noqa: B017 + encrypter.encrypt_oauth_params("not_a_dict") # type: ignore + + def test_decrypt_oauth_params_basic(self): + """Test basic OAuth parameters decryption""" + encrypter = SystemOAuthEncrypter("test_secret") + original_params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypter.encrypt_oauth_params(original_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_oauth_params_empty_dict(self): + """Test decryption of empty dictionary""" + encrypter = SystemOAuthEncrypter("test_secret") + original_params = {} + + encrypted = encrypter.encrypt_oauth_params(original_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_oauth_params_complex_data(self): + """Test decryption with complex data structures""" + encrypter = SystemOAuthEncrypter("test_secret") + original_params = { + "client_id": "test_id", + "client_secret": "test_secret", + "scopes": ["read", "write", "admin"], + "metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True}, + "numeric_value": 42, + "boolean_value": False, + "null_value": None, + } + + encrypted = encrypter.encrypt_oauth_params(original_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_oauth_params_unicode_data(self): + """Test decryption with unicode data""" + encrypter = SystemOAuthEncrypter("test_secret") + original_params = { + "client_id": "test_id", + "client_secret": "test_secret", + "description": "This is a test case 🚀", + } + + encrypted = encrypter.encrypt_oauth_params(original_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_oauth_params_large_data(self): + """Test decryption with large data""" + encrypter = SystemOAuthEncrypter("test_secret") + original_params = { + "client_id": "test_id", + "large_data": "x" * 10000, # 10KB of data + } + + encrypted = encrypter.encrypt_oauth_params(original_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_oauth_params_invalid_base64(self): + """Test decryption with invalid base64 data""" + encrypter = SystemOAuthEncrypter("test_secret") + + with pytest.raises(OAuthEncryptionError): + encrypter.decrypt_oauth_params("invalid_base64!") + + def test_decrypt_oauth_params_empty_string(self): + """Test decryption with empty string""" + encrypter = SystemOAuthEncrypter("test_secret") + + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_oauth_params("") + + assert "encrypted_data cannot be empty" in str(exc_info.value) + + def test_decrypt_oauth_params_non_string_input(self): + """Test decryption with non-string input""" + encrypter = SystemOAuthEncrypter("test_secret") + + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_oauth_params(123) # type: ignore + + assert "encrypted_data must be a string" in str(exc_info.value) + + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_oauth_params(None) # type: ignore + + assert "encrypted_data must be a string" in str(exc_info.value) + + def test_decrypt_oauth_params_too_short_data(self): + """Test decryption with too short encrypted data""" + encrypter = SystemOAuthEncrypter("test_secret") + + # Create data that's too short (less than 32 bytes) + short_data = base64.b64encode(b"short").decode() + + with pytest.raises(OAuthEncryptionError) as exc_info: + encrypter.decrypt_oauth_params(short_data) + + assert "Invalid encrypted data format" in str(exc_info.value) + + def test_decrypt_oauth_params_corrupted_data(self): + """Test decryption with corrupted data""" + encrypter = SystemOAuthEncrypter("test_secret") + + # Create corrupted data (valid base64 but invalid encrypted content) + corrupted_data = base64.b64encode(b"x" * 48).decode() # 48 bytes of garbage + + with pytest.raises(OAuthEncryptionError): + encrypter.decrypt_oauth_params(corrupted_data) + + def test_decrypt_oauth_params_wrong_key(self): + """Test decryption with wrong key""" + encrypter1 = SystemOAuthEncrypter("secret1") + encrypter2 = SystemOAuthEncrypter("secret2") + + original_params = {"client_id": "test_id", "client_secret": "test_secret"} + encrypted = encrypter1.encrypt_oauth_params(original_params) + + with pytest.raises(OAuthEncryptionError): + encrypter2.decrypt_oauth_params(encrypted) + + def test_encryption_decryption_consistency(self): + """Test that encryption and decryption are consistent""" + encrypter = SystemOAuthEncrypter("test_secret") + + test_cases = [ + {}, + {"simple": "value"}, + {"client_id": "id", "client_secret": "secret"}, + {"complex": {"nested": {"deep": "value"}}}, + {"unicode": "test 🚀"}, + {"numbers": 42, "boolean": True, "null": None}, + {"array": [1, 2, 3, "four", {"five": 5}]}, + ] + + for original_params in test_cases: + encrypted = encrypter.encrypt_oauth_params(original_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + assert decrypted == original_params, f"Failed for case: {original_params}" + + def test_encryption_randomness(self): + """Test that encryption produces different results for same input""" + encrypter = SystemOAuthEncrypter("test_secret") + oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted1 = encrypter.encrypt_oauth_params(oauth_params) + encrypted2 = encrypter.encrypt_oauth_params(oauth_params) + + # Should be different due to random IV + assert encrypted1 != encrypted2 + + # But should decrypt to same result + decrypted1 = encrypter.decrypt_oauth_params(encrypted1) + decrypted2 = encrypter.decrypt_oauth_params(encrypted2) + assert decrypted1 == decrypted2 == oauth_params + + def test_different_secret_keys_produce_different_results(self): + """Test that different secret keys produce different encrypted results""" + encrypter1 = SystemOAuthEncrypter("secret1") + encrypter2 = SystemOAuthEncrypter("secret2") + + oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted1 = encrypter1.encrypt_oauth_params(oauth_params) + encrypted2 = encrypter2.encrypt_oauth_params(oauth_params) + + # Should produce different encrypted results + assert encrypted1 != encrypted2 + + # But each should decrypt correctly with its own key + decrypted1 = encrypter1.decrypt_oauth_params(encrypted1) + decrypted2 = encrypter2.decrypt_oauth_params(encrypted2) + assert decrypted1 == decrypted2 == oauth_params + + @patch("core.tools.utils.system_oauth_encryption.get_random_bytes") + def test_encrypt_oauth_params_crypto_error(self, mock_get_random_bytes): + """Test encryption when crypto operation fails""" + mock_get_random_bytes.side_effect = Exception("Crypto error") + + encrypter = SystemOAuthEncrypter("test_secret") + oauth_params = {"client_id": "test_id"} + + with pytest.raises(OAuthEncryptionError) as exc_info: + encrypter.encrypt_oauth_params(oauth_params) + + assert "Encryption failed" in str(exc_info.value) + + @patch("core.tools.utils.system_oauth_encryption.TypeAdapter") + def test_encrypt_oauth_params_serialization_error(self, mock_type_adapter): + """Test encryption when JSON serialization fails""" + mock_type_adapter.return_value.dump_json.side_effect = Exception("Serialization error") + + encrypter = SystemOAuthEncrypter("test_secret") + oauth_params = {"client_id": "test_id"} + + with pytest.raises(OAuthEncryptionError) as exc_info: + encrypter.encrypt_oauth_params(oauth_params) + + assert "Encryption failed" in str(exc_info.value) + + def test_decrypt_oauth_params_invalid_json(self): + """Test decryption with invalid JSON data""" + encrypter = SystemOAuthEncrypter("test_secret") + + # Create valid encrypted data but with invalid JSON content + iv = get_random_bytes(16) + cipher = AES.new(encrypter.key, AES.MODE_CBC, iv) + invalid_json = b"invalid json content" + padded_data = pad(invalid_json, AES.block_size) + encrypted_data = cipher.encrypt(padded_data) + combined = iv + encrypted_data + encoded = base64.b64encode(combined).decode() + + with pytest.raises(OAuthEncryptionError): + encrypter.decrypt_oauth_params(encoded) + + def test_key_derivation_consistency(self): + """Test that key derivation is consistent""" + secret_key = "test_secret" + encrypter1 = SystemOAuthEncrypter(secret_key) + encrypter2 = SystemOAuthEncrypter(secret_key) + + assert encrypter1.key == encrypter2.key + + # Keys should be 32 bytes (256 bits) + assert len(encrypter1.key) == 32 + + +class TestFactoryFunctions: + """Test cases for factory functions""" + + def test_create_system_oauth_encrypter_with_secret(self): + """Test factory function with secret key""" + secret_key = "test_secret" + encrypter = create_system_oauth_encrypter(secret_key) + + assert isinstance(encrypter, SystemOAuthEncrypter) + expected_key = hashlib.sha256(secret_key.encode()).digest() + assert encrypter.key == expected_key + + def test_create_system_oauth_encrypter_without_secret(self): + """Test factory function without secret key""" + with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "config_secret" + encrypter = create_system_oauth_encrypter() + + assert isinstance(encrypter, SystemOAuthEncrypter) + expected_key = hashlib.sha256(b"config_secret").digest() + assert encrypter.key == expected_key + + def test_create_system_oauth_encrypter_with_none_secret(self): + """Test factory function with None secret key""" + with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "config_secret" + encrypter = create_system_oauth_encrypter(None) + + assert isinstance(encrypter, SystemOAuthEncrypter) + expected_key = hashlib.sha256(b"config_secret").digest() + assert encrypter.key == expected_key + + +class TestGlobalEncrypterInstance: + """Test cases for global encrypter instance""" + + def test_get_system_oauth_encrypter_singleton(self): + """Test that get_system_oauth_encrypter returns singleton instance""" + # Clear the global instance first + import core.tools.utils.system_oauth_encryption + + core.tools.utils.system_oauth_encryption._oauth_encrypter = None + + encrypter1 = get_system_oauth_encrypter() + encrypter2 = get_system_oauth_encrypter() + + assert encrypter1 is encrypter2 + assert isinstance(encrypter1, SystemOAuthEncrypter) + + def test_get_system_oauth_encrypter_uses_config(self): + """Test that global encrypter uses config""" + # Clear the global instance first + import core.tools.utils.system_oauth_encryption + + core.tools.utils.system_oauth_encryption._oauth_encrypter = None + + with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "global_secret" + encrypter = get_system_oauth_encrypter() + + expected_key = hashlib.sha256(b"global_secret").digest() + assert encrypter.key == expected_key + + +class TestConvenienceFunctions: + """Test cases for convenience functions""" + + def test_encrypt_system_oauth_params(self): + """Test encrypt_system_oauth_params convenience function""" + oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypt_system_oauth_params(oauth_params) + + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_decrypt_system_oauth_params(self): + """Test decrypt_system_oauth_params convenience function""" + oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypt_system_oauth_params(oauth_params) + decrypted = decrypt_system_oauth_params(encrypted) + + assert decrypted == oauth_params + + def test_convenience_functions_consistency(self): + """Test that convenience functions work consistently""" + test_cases = [ + {}, + {"simple": "value"}, + {"client_id": "id", "client_secret": "secret"}, + {"complex": {"nested": {"deep": "value"}}}, + {"unicode": "test 🚀"}, + {"numbers": 42, "boolean": True, "null": None}, + ] + + for original_params in test_cases: + encrypted = encrypt_system_oauth_params(original_params) + decrypted = decrypt_system_oauth_params(encrypted) + assert decrypted == original_params, f"Failed for case: {original_params}" + + def test_convenience_functions_with_errors(self): + """Test convenience functions with error conditions""" + # Test encryption with invalid input + with pytest.raises(Exception): # noqa: B017 + encrypt_system_oauth_params(None) # type: ignore + + # Test decryption with invalid input + with pytest.raises(ValueError): + decrypt_system_oauth_params("") + + with pytest.raises(ValueError): + decrypt_system_oauth_params(None) # type: ignore + + +class TestErrorHandling: + """Test cases for error handling""" + + def test_oauth_encryption_error_inheritance(self): + """Test that OAuthEncryptionError is a proper exception""" + error = OAuthEncryptionError("Test error") + assert isinstance(error, Exception) + assert str(error) == "Test error" + + def test_oauth_encryption_error_with_cause(self): + """Test OAuthEncryptionError with cause""" + original_error = ValueError("Original error") + error = OAuthEncryptionError("Wrapper error") + error.__cause__ = original_error + + assert isinstance(error, Exception) + assert str(error) == "Wrapper error" + assert error.__cause__ is original_error + + def test_error_messages_are_informative(self): + """Test that error messages are informative""" + encrypter = SystemOAuthEncrypter("test_secret") + + # Test empty string error + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_oauth_params("") + assert "encrypted_data cannot be empty" in str(exc_info.value) + + # Test non-string error + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_oauth_params(123) # type: ignore + assert "encrypted_data must be a string" in str(exc_info.value) + + # Test invalid format error + short_data = base64.b64encode(b"short").decode() + with pytest.raises(OAuthEncryptionError) as exc_info: + encrypter.decrypt_oauth_params(short_data) + assert "Invalid encrypted data format" in str(exc_info.value) + + +class TestEdgeCases: + """Test cases for edge cases and boundary conditions""" + + def test_very_long_secret_key(self): + """Test with very long secret key""" + long_secret = "x" * 10000 + encrypter = SystemOAuthEncrypter(long_secret) + + # Key should still be 32 bytes due to SHA-256 + assert len(encrypter.key) == 32 + + # Should still work normally + oauth_params = {"client_id": "test_id"} + encrypted = encrypter.encrypt_oauth_params(oauth_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + assert decrypted == oauth_params + + def test_special_characters_in_secret_key(self): + """Test with special characters in secret key""" + special_secret = "!@#$%^&*()_+-=[]{}|;':\",./<>?`~test🚀" + encrypter = SystemOAuthEncrypter(special_secret) + + oauth_params = {"client_id": "test_id"} + encrypted = encrypter.encrypt_oauth_params(oauth_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + assert decrypted == oauth_params + + def test_empty_values_in_oauth_params(self): + """Test with empty values in oauth params""" + oauth_params = { + "client_id": "", + "client_secret": "", + "empty_dict": {}, + "empty_list": [], + "empty_string": "", + "zero": 0, + "false": False, + "none": None, + } + + encrypter = SystemOAuthEncrypter("test_secret") + encrypted = encrypter.encrypt_oauth_params(oauth_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + assert decrypted == oauth_params + + def test_deeply_nested_oauth_params(self): + """Test with deeply nested oauth params""" + oauth_params = {"level1": {"level2": {"level3": {"level4": {"level5": {"deep_value": "found"}}}}}} + + encrypter = SystemOAuthEncrypter("test_secret") + encrypted = encrypter.encrypt_oauth_params(oauth_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + assert decrypted == oauth_params + + def test_oauth_params_with_all_json_types(self): + """Test with all JSON-supported data types""" + oauth_params = { + "string": "test_string", + "integer": 42, + "float": 3.14159, + "boolean_true": True, + "boolean_false": False, + "null_value": None, + "empty_string": "", + "array": [1, "two", 3.0, True, False, None], + "object": {"nested_string": "nested_value", "nested_number": 123, "nested_bool": True}, + } + + encrypter = SystemOAuthEncrypter("test_secret") + encrypted = encrypter.encrypt_oauth_params(oauth_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + assert decrypted == oauth_params + + +class TestPerformance: + """Test cases for performance considerations""" + + def test_large_oauth_params(self): + """Test with large oauth params""" + large_value = "x" * 100000 # 100KB + oauth_params = {"client_id": "test_id", "large_data": large_value} + + encrypter = SystemOAuthEncrypter("test_secret") + encrypted = encrypter.encrypt_oauth_params(oauth_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + assert decrypted == oauth_params + + def test_many_fields_oauth_params(self): + """Test with many fields in oauth params""" + oauth_params = {f"field_{i}": f"value_{i}" for i in range(1000)} + + encrypter = SystemOAuthEncrypter("test_secret") + encrypted = encrypter.encrypt_oauth_params(oauth_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + assert decrypted == oauth_params + + def test_repeated_encryption_decryption(self): + """Test repeated encryption and decryption operations""" + encrypter = SystemOAuthEncrypter("test_secret") + oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} + + # Test multiple rounds of encryption/decryption + for i in range(100): + encrypted = encrypter.encrypt_oauth_params(oauth_params) + decrypted = encrypter.decrypt_oauth_params(encrypted) + assert decrypted == oauth_params diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index a1b82ab2fe..b4711ea39a 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -18,7 +18,6 @@ import AppIcon from '@/app/components/base/app-icon' import Button from '@/app/components/base/button' import Indicator from '@/app/components/header/indicator' import Switch from '@/app/components/base/switch' -import Toast from '@/app/components/base/toast' import ConfigContext from '@/context/debug-configuration' import type { AgentTool } from '@/types/app' import { type Collection, CollectionType } from '@/app/components/tools/types' @@ -26,8 +25,6 @@ import { MAX_TOOLS_NUM } from '@/config' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' import Tooltip from '@/app/components/base/tooltip' import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other' -import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' -import { updateBuiltInToolCredential } from '@/service/tools' import cn from '@/utils/classnames' import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types' @@ -57,13 +54,7 @@ const AgentTools: FC = () => { const formattingChangedDispatcher = useFormattingChangedDispatcher() const [currentTool, setCurrentTool] = useState(null) - const currentCollection = useMemo(() => { - if (!currentTool) return null - const collection = collectionList.find(collection => canFindTool(collection.id, currentTool?.provider_id) && collection.type === currentTool?.provider_type) - return collection - }, [currentTool, collectionList]) const [isShowSettingTool, setIsShowSettingTool] = useState(false) - const [isShowSettingAuth, setShowSettingAuth] = useState(false) const tools = (modelConfig?.agentConfig?.tools as AgentTool[] || []).map((item) => { const collection = collectionList.find( collection => @@ -100,17 +91,6 @@ const AgentTools: FC = () => { formattingChangedDispatcher() } - const handleToolAuthSetting = (value: AgentToolWithMoreInfo) => { - const newModelConfig = produce(modelConfig, (draft) => { - const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === value?.collection?.id && item.tool_name === value?.tool_name) - if (tool) - (tool as AgentTool).notAuthor = false - }) - setModelConfig(newModelConfig) - setIsShowSettingTool(false) - formattingChangedDispatcher() - } - const [isDeleting, setIsDeleting] = useState(-1) const getToolValue = (tool: ToolDefaultValue) => { return { @@ -144,6 +124,20 @@ const AgentTools: FC = () => { return item.provider_name } + const handleAuthorizationItemClick = useCallback((credentialId: string) => { + const newModelConfig = produce(modelConfig, (draft) => { + const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id) + if (tool) + (tool as AgentTool).credential_id = credentialId + }) + setCurrentTool({ + ...currentTool, + credential_id: credentialId, + } as any) + setModelConfig(newModelConfig) + formattingChangedDispatcher() + }, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher]) + return ( <> { {item.notAuthor && (
{currTool?.label[language]}
{!!currTool?.description[language] && ( - + )} + { + collection.allow_delete && collection.type === CollectionType.builtIn && ( + + ) + }
{/* form */}
diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx index 6630d9bb9d..666a869a32 100644 --- a/web/app/components/base/chat/chat/question.tsx +++ b/web/app/components/base/chat/chat/question.tsx @@ -117,7 +117,7 @@ const Question: FC = ({
{ diff --git a/web/app/components/base/form/components/base/base-field.tsx b/web/app/components/base/form/components/base/base-field.tsx new file mode 100644 index 0000000000..0195f38795 --- /dev/null +++ b/web/app/components/base/form/components/base/base-field.tsx @@ -0,0 +1,177 @@ +import { + isValidElement, + memo, + useMemo, +} from 'react' +import type { AnyFieldApi } from '@tanstack/react-form' +import { useStore } from '@tanstack/react-form' +import cn from '@/utils/classnames' +import Input from '@/app/components/base/input' +import PureSelect from '@/app/components/base/select/pure' +import type { FormSchema } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' +import { useRenderI18nObject } from '@/hooks/use-i18n' + +export type BaseFieldProps = { + fieldClassName?: string + labelClassName?: string + inputContainerClassName?: string + inputClassName?: string + formSchema: FormSchema + field: AnyFieldApi + disabled?: boolean +} +const BaseField = ({ + fieldClassName, + labelClassName, + inputContainerClassName, + inputClassName, + formSchema, + field, + disabled, +}: BaseFieldProps) => { + const renderI18nObject = useRenderI18nObject() + const { + label, + required, + placeholder, + options, + labelClassName: formLabelClassName, + show_on = [], + } = formSchema + + const memorizedLabel = useMemo(() => { + if (isValidElement(label)) + return label + + if (typeof label === 'string') + return label + + if (typeof label === 'object' && label !== null) + return renderI18nObject(label as Record) + }, [label, renderI18nObject]) + const memorizedPlaceholder = useMemo(() => { + if (typeof placeholder === 'string') + return placeholder + + if (typeof placeholder === 'object' && placeholder !== null) + return renderI18nObject(placeholder as Record) + }, [placeholder, renderI18nObject]) + const memorizedOptions = useMemo(() => { + return options?.map((option) => { + return { + label: typeof option.label === 'string' ? option.label : renderI18nObject(option.label), + value: option.value, + } + }) || [] + }, [options, renderI18nObject]) + const value = useStore(field.form.store, s => s.values[field.name]) + const values = useStore(field.form.store, (s) => { + return show_on.reduce((acc, condition) => { + acc[condition.variable] = s.values[condition.variable] + return acc + }, {} as Record) + }) + const show = useMemo(() => { + return show_on.every((condition) => { + const conditionValue = values[condition.variable] + return conditionValue === condition.value + }) + }, [values, show_on]) + + if (!show) + return null + + return ( +
+
+ {memorizedLabel} + { + required && !isValidElement(label) && ( + * + ) + } +
+
+ { + formSchema.type === FormTypeEnum.textInput && ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + disabled={disabled} + placeholder={memorizedPlaceholder} + /> + ) + } + { + formSchema.type === FormTypeEnum.secretInput && ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + disabled={disabled} + placeholder={memorizedPlaceholder} + /> + ) + } + { + formSchema.type === FormTypeEnum.textNumber && ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + disabled={disabled} + placeholder={memorizedPlaceholder} + /> + ) + } + { + formSchema.type === FormTypeEnum.select && ( + field.handleChange(v)} + disabled={disabled} + placeholder={memorizedPlaceholder} + options={memorizedOptions} + triggerPopupSameWidth + /> + ) + } + { + formSchema.type === FormTypeEnum.radio && ( +
+ { + memorizedOptions.map(option => ( +
field.handleChange(option.value)} + > + {option.label} +
+ )) + } +
+ ) + } +
+
+ ) +} + +export default memo(BaseField) diff --git a/web/app/components/base/form/components/base/base-form.tsx b/web/app/components/base/form/components/base/base-form.tsx new file mode 100644 index 0000000000..640d474b19 --- /dev/null +++ b/web/app/components/base/form/components/base/base-form.tsx @@ -0,0 +1,115 @@ +import { + memo, + useCallback, + useImperativeHandle, +} from 'react' +import type { + AnyFieldApi, + AnyFormApi, +} from '@tanstack/react-form' +import { useForm } from '@tanstack/react-form' +import type { + FormRef, + FormSchema, +} from '@/app/components/base/form/types' +import { + BaseField, +} from '.' +import type { + BaseFieldProps, +} from '.' +import cn from '@/utils/classnames' +import { + useGetFormValues, + useGetValidators, +} from '@/app/components/base/form/hooks' + +export type BaseFormProps = { + formSchemas?: FormSchema[] + defaultValues?: Record + formClassName?: string + ref?: FormRef + disabled?: boolean + formFromProps?: AnyFormApi +} & Pick + +const BaseForm = ({ + formSchemas = [], + defaultValues, + formClassName, + fieldClassName, + labelClassName, + inputContainerClassName, + inputClassName, + ref, + disabled, + formFromProps, +}: BaseFormProps) => { + const formFromHook = useForm({ + defaultValues, + }) + const form: any = formFromProps || formFromHook + const { getFormValues } = useGetFormValues(form, formSchemas) + const { getValidators } = useGetValidators() + + useImperativeHandle(ref, () => { + return { + getForm() { + return form + }, + getFormValues: (option) => { + return getFormValues(option) + }, + } + }, [form, getFormValues]) + + const renderField = useCallback((field: AnyFieldApi) => { + const formSchema = formSchemas?.find(schema => schema.name === field.name) + + if (formSchema) { + return ( + + ) + } + + return null + }, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled]) + + const renderFieldWrapper = useCallback((formSchema: FormSchema) => { + const validators = getValidators(formSchema) + const { + name, + } = formSchema + + return ( + + {renderField} + + ) + }, [renderField, form, getValidators]) + + if (!formSchemas?.length) + return null + + return ( +
+ {formSchemas.map(renderFieldWrapper)} +
+ ) +} + +export default memo(BaseForm) diff --git a/web/app/components/base/form/components/base/index.tsx b/web/app/components/base/form/components/base/index.tsx new file mode 100644 index 0000000000..0d6f0808ff --- /dev/null +++ b/web/app/components/base/form/components/base/index.tsx @@ -0,0 +1,2 @@ +export { default as BaseForm, type BaseFormProps } from './base-form' +export { default as BaseField, type BaseFieldProps } from './base-field' diff --git a/web/app/components/base/form/form-scenarios/auth/index.tsx b/web/app/components/base/form/form-scenarios/auth/index.tsx new file mode 100644 index 0000000000..3927f90959 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/auth/index.tsx @@ -0,0 +1,23 @@ +import { memo } from 'react' +import { BaseForm } from '../../components/base' +import type { BaseFormProps } from '../../components/base' + +const AuthForm = ({ + formSchemas = [], + defaultValues, + ref, + formFromProps, +}: BaseFormProps) => { + return ( + + ) +} + +export default memo(AuthForm) diff --git a/web/app/components/base/form/hooks/index.ts b/web/app/components/base/form/hooks/index.ts new file mode 100644 index 0000000000..71ccfedef3 --- /dev/null +++ b/web/app/components/base/form/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './use-check-validated' +export * from './use-get-form-values' +export * from './use-get-validators' diff --git a/web/app/components/base/form/hooks/use-check-validated.ts b/web/app/components/base/form/hooks/use-check-validated.ts new file mode 100644 index 0000000000..b4f3ec2b9a --- /dev/null +++ b/web/app/components/base/form/hooks/use-check-validated.ts @@ -0,0 +1,48 @@ +import { useCallback } from 'react' +import type { AnyFormApi } from '@tanstack/react-form' +import { useToastContext } from '@/app/components/base/toast' +import type { FormSchema } from '@/app/components/base/form/types' + +export const useCheckValidated = (form: AnyFormApi, FormSchemas: FormSchema[]) => { + const { notify } = useToastContext() + + const checkValidated = useCallback(() => { + const allError = form?.getAllErrors() + const values = form.state.values + + if (allError) { + const fields = allError.fields + const errorArray = Object.keys(fields).reduce((acc: string[], key: string) => { + const currentSchema = FormSchemas.find(schema => schema.name === key) + const { show_on = [] } = currentSchema || {} + const showOnValues = show_on.reduce((acc, condition) => { + acc[condition.variable] = values[condition.variable] + return acc + }, {} as Record) + const show = show_on?.every((condition) => { + const conditionValue = showOnValues[condition.variable] + return conditionValue === condition.value + }) + const errors: any[] = show ? fields[key].errors : [] + + return [...acc, ...errors] + }, [] as string[]) + + if (errorArray.length) { + notify({ + type: 'error', + message: errorArray[0], + }) + return false + } + + return true + } + + return true + }, [form, notify, FormSchemas]) + + return { + checkValidated, + } +} diff --git a/web/app/components/base/form/hooks/use-get-form-values.ts b/web/app/components/base/form/hooks/use-get-form-values.ts new file mode 100644 index 0000000000..36100a724a --- /dev/null +++ b/web/app/components/base/form/hooks/use-get-form-values.ts @@ -0,0 +1,44 @@ +import { useCallback } from 'react' +import type { AnyFormApi } from '@tanstack/react-form' +import { useCheckValidated } from './use-check-validated' +import type { + FormSchema, + GetValuesOptions, +} from '../types' +import { getTransformedValuesWhenSecretInputPristine } from '../utils' + +export const useGetFormValues = (form: AnyFormApi, formSchemas: FormSchema[]) => { + const { checkValidated } = useCheckValidated(form, formSchemas) + + const getFormValues = useCallback(( + { + needCheckValidatedValues, + needTransformWhenSecretFieldIsPristine, + }: GetValuesOptions, + ) => { + const values = form?.store.state.values || {} + if (!needCheckValidatedValues) { + return { + values, + isCheckValidated: false, + } + } + + if (checkValidated()) { + return { + values: needTransformWhenSecretFieldIsPristine ? getTransformedValuesWhenSecretInputPristine(formSchemas, form) : values, + isCheckValidated: true, + } + } + else { + return { + values: {}, + isCheckValidated: false, + } + } + }, [form, checkValidated, formSchemas]) + + return { + getFormValues, + } +} diff --git a/web/app/components/base/form/hooks/use-get-validators.ts b/web/app/components/base/form/hooks/use-get-validators.ts new file mode 100644 index 0000000000..91754bc1ba --- /dev/null +++ b/web/app/components/base/form/hooks/use-get-validators.ts @@ -0,0 +1,36 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import type { FormSchema } from '../types' + +export const useGetValidators = () => { + const { t } = useTranslation() + const getValidators = useCallback((formSchema: FormSchema) => { + const { + name, + validators, + required, + } = formSchema + let mergedValidators = validators + if (required && !validators) { + mergedValidators = { + onMount: ({ value }: any) => { + if (!value) + return t('common.errorMsg.fieldRequired', { field: name }) + }, + onChange: ({ value }: any) => { + if (!value) + return t('common.errorMsg.fieldRequired', { field: name }) + }, + onBlur: ({ value }: any) => { + if (!value) + return t('common.errorMsg.fieldRequired', { field: name }) + }, + } + } + return mergedValidators + }, [t]) + + return { + getValidators, + } +} diff --git a/web/app/components/base/form/types.ts b/web/app/components/base/form/types.ts new file mode 100644 index 0000000000..c165d2939b --- /dev/null +++ b/web/app/components/base/form/types.ts @@ -0,0 +1,76 @@ +import type { + ForwardedRef, + ReactNode, +} from 'react' +import type { + AnyFormApi, + FieldValidators, +} from '@tanstack/react-form' + +export type TypeWithI18N = { + en_US: T + zh_Hans: T + [key: string]: T +} + +export type FormShowOnObject = { + variable: string + value: string +} + +export enum FormTypeEnum { + textInput = 'text-input', + textNumber = 'number-input', + secretInput = 'secret-input', + select = 'select', + radio = 'radio', + boolean = 'boolean', + files = 'files', + file = 'file', + modelSelector = 'model-selector', + toolSelector = 'tool-selector', + multiToolSelector = 'array[tools]', + appSelector = 'app-selector', + dynamicSelect = 'dynamic-select', +} + +export type FormOption = { + label: TypeWithI18N | string + value: string + show_on?: FormShowOnObject[] + icon?: string +} + +export type AnyValidators = FieldValidators + +export type FormSchema = { + type: FormTypeEnum + name: string + label: string | ReactNode | TypeWithI18N + required: boolean + default?: any + tooltip?: string | TypeWithI18N + show_on?: FormShowOnObject[] + url?: string + scope?: string + help?: string | TypeWithI18N + placeholder?: string | TypeWithI18N + options?: FormOption[] + labelClassName?: string + validators?: AnyValidators +} + +export type FormValues = Record + +export type GetValuesOptions = { + needTransformWhenSecretFieldIsPristine?: boolean + needCheckValidatedValues?: boolean +} +export type FormRefObject = { + getForm: () => AnyFormApi + getFormValues: (obj: GetValuesOptions) => { + values: Record + isCheckValidated: boolean + } +} +export type FormRef = ForwardedRef diff --git a/web/app/components/base/form/utils/index.ts b/web/app/components/base/form/utils/index.ts new file mode 100644 index 0000000000..0abb8d1ad5 --- /dev/null +++ b/web/app/components/base/form/utils/index.ts @@ -0,0 +1 @@ +export * from './secret-input' diff --git a/web/app/components/base/form/utils/secret-input/index.ts b/web/app/components/base/form/utils/secret-input/index.ts new file mode 100644 index 0000000000..e8458f90b2 --- /dev/null +++ b/web/app/components/base/form/utils/secret-input/index.ts @@ -0,0 +1,29 @@ +import type { AnyFormApi } from '@tanstack/react-form' +import type { FormSchema } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' + +export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record) => { + const transformedValues: Record = { ...values } + + isPristineSecretInputNames.forEach((name) => { + if (transformedValues[name]) + transformedValues[name] = '[__HIDDEN__]' + }) + + return transformedValues +} + +export const getTransformedValuesWhenSecretInputPristine = (formSchemas: FormSchema[], form: AnyFormApi) => { + const values = form?.store.state.values || {} + const isPristineSecretInputNames: string[] = [] + for (let i = 0; i < formSchemas.length; i++) { + const schema = formSchemas[i] + if (schema.type === FormTypeEnum.secretInput) { + const fieldMeta = form?.getFieldMeta(schema.name) + if (fieldMeta?.isPristine) + isPristineSecretInputNames.push(schema.name) + } + } + + return transformFormSchemasSecretInput(isPristineSecretInputNames, values) +} diff --git a/web/app/components/base/modal/modal.tsx b/web/app/components/base/modal/modal.tsx new file mode 100644 index 0000000000..e6e9fb8804 --- /dev/null +++ b/web/app/components/base/modal/modal.tsx @@ -0,0 +1,127 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { RiCloseLine } from '@remixicon/react' +import { + PortalToFollowElem, + PortalToFollowElemContent, +} from '@/app/components/base/portal-to-follow-elem' +import Button from '@/app/components/base/button' +import type { ButtonProps } from '@/app/components/base/button' +import cn from '@/utils/classnames' + +type ModalProps = { + onClose?: () => void + size?: 'sm' | 'md' + title: string + subTitle?: string + children?: React.ReactNode + confirmButtonText?: string + onConfirm?: () => void + cancelButtonText?: string + onCancel?: () => void + showExtraButton?: boolean + extraButtonText?: string + extraButtonVariant?: ButtonProps['variant'] + onExtraButtonClick?: () => void + footerSlot?: React.ReactNode + bottomSlot?: React.ReactNode + disabled?: boolean +} +const Modal = ({ + onClose, + size = 'sm', + title, + subTitle, + children, + confirmButtonText, + onConfirm, + cancelButtonText, + onCancel, + showExtraButton, + extraButtonVariant = 'warning', + extraButtonText, + onExtraButtonClick, + footerSlot, + bottomSlot, + disabled, +}: ModalProps) => { + const { t } = useTranslation() + + return ( + + +
e.stopPropagation()} + > +
+ {title} + { + subTitle && ( +
+ {subTitle} +
+ ) + } +
+ +
+
+ { + children && ( +
{children}
+ ) + } +
+
+ {footerSlot} +
+
+ { + showExtraButton && ( + <> + +
+ + ) + } + + +
+
+ {bottomSlot} +
+
+
+ ) +} + +export default memo(Modal) diff --git a/web/app/components/base/select/pure.tsx b/web/app/components/base/select/pure.tsx index 81cc2fbadf..be88c936fd 100644 --- a/web/app/components/base/select/pure.tsx +++ b/web/app/components/base/select/pure.tsx @@ -39,6 +39,9 @@ type PureSelectProps = { itemClassName?: string title?: string }, + placeholder?: string + disabled?: boolean + triggerPopupSameWidth?: boolean } const PureSelect = ({ options, @@ -47,6 +50,9 @@ const PureSelect = ({ containerProps, triggerProps, popupProps, + placeholder, + disabled, + triggerPopupSameWidth, }: PureSelectProps) => { const { t } = useTranslation() const { @@ -74,7 +80,7 @@ const PureSelect = ({ }, [onOpenChange]) const selectedOption = options.find(option => option.value === value) - const triggerText = selectedOption?.label || t('common.placeholder.select') + const triggerText = selectedOption?.label || placeholder || t('common.placeholder.select') return ( handleOpenChange(!mergedOpen)} @@ -135,6 +142,7 @@ const PureSelect = ({ )} title={option.label} onClick={() => { + if (disabled) return onChange?.(option.value) handleOpenChange(false) }} diff --git a/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx new file mode 100644 index 0000000000..295fc4fa9d --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx @@ -0,0 +1,50 @@ +import { + memo, + useState, +} from 'react' +import Button from '@/app/components/base/button' +import type { ButtonProps } from '@/app/components/base/button' +import ApiKeyModal from './api-key-modal' +import type { PluginPayload } from '../types' + +export type AddApiKeyButtonProps = { + pluginPayload: PluginPayload + buttonVariant?: ButtonProps['variant'] + buttonText?: string + disabled?: boolean + onUpdate?: () => void +} +const AddApiKeyButton = ({ + pluginPayload, + buttonVariant = 'secondary-accent', + buttonText = 'use api key', + disabled, + onUpdate, +}: AddApiKeyButtonProps) => { + const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false) + + return ( + <> + + { + isApiKeyModalOpen && ( + setIsApiKeyModalOpen(false)} + onUpdate={onUpdate} + /> + ) + } + + + ) +} + +export default memo(AddApiKeyButton) diff --git a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx new file mode 100644 index 0000000000..599d701341 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx @@ -0,0 +1,259 @@ +import { + memo, + useCallback, + useMemo, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { + RiClipboardLine, + RiEqualizer2Line, + RiInformation2Fill, +} from '@remixicon/react' +import Button from '@/app/components/base/button' +import type { ButtonProps } from '@/app/components/base/button' +import OAuthClientSettings from './oauth-client-settings' +import cn from '@/utils/classnames' +import type { PluginPayload } from '../types' +import { openOAuthPopup } from '@/hooks/use-oauth' +import Badge from '@/app/components/base/badge' +import { + useGetPluginOAuthClientSchemaHook, + useGetPluginOAuthUrlHook, +} from '../hooks/use-credential' +import type { FormSchema } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' +import ActionButton from '@/app/components/base/action-button' +import { useRenderI18nObject } from '@/hooks/use-i18n' + +export type AddOAuthButtonProps = { + pluginPayload: PluginPayload + buttonVariant?: ButtonProps['variant'] + buttonText?: string + className?: string + buttonLeftClassName?: string + buttonRightClassName?: string + dividerClassName?: string + disabled?: boolean + onUpdate?: () => void +} +const AddOAuthButton = ({ + pluginPayload, + buttonVariant = 'primary', + buttonText = 'use oauth', + className, + buttonLeftClassName, + buttonRightClassName, + dividerClassName, + disabled, + onUpdate, +}: AddOAuthButtonProps) => { + const { t } = useTranslation() + const renderI18nObject = useRenderI18nObject() + const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false) + const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload) + const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload) + const { + schema = [], + is_oauth_custom_client_enabled, + is_system_oauth_params_exists, + client_params, + redirect_uri, + } = data || {} + const isConfigured = is_system_oauth_params_exists || is_oauth_custom_client_enabled + const handleOAuth = useCallback(async () => { + const { authorization_url } = await getPluginOAuthUrl() + + if (authorization_url) { + openOAuthPopup( + authorization_url, + () => onUpdate?.(), + ) + } + }, [getPluginOAuthUrl, onUpdate]) + + const renderCustomLabel = useCallback((item: FormSchema) => { + return ( +
+
+
+ +
+
+
+ {t('plugin.auth.clientInfo')} +
+ { + redirect_uri && ( +
+
{redirect_uri}
+ { + navigator.clipboard.writeText(redirect_uri || '') + }} + > + + +
+ ) + } +
+
+
+ {renderI18nObject(item.label as Record)} + { + item.required && ( + * + ) + } +
+
+ ) + }, [t, redirect_uri, renderI18nObject]) + const memorizedSchemas = useMemo(() => { + const result: FormSchema[] = schema.map((item, index) => { + return { + ...item, + label: index === 0 ? renderCustomLabel(item) : item.label, + labelClassName: index === 0 ? 'h-auto' : undefined, + } + }) + if (is_system_oauth_params_exists) { + result.unshift({ + name: '__oauth_client__', + label: t('plugin.auth.oauthClient'), + type: FormTypeEnum.radio, + options: [ + { + label: t('plugin.auth.default'), + value: 'default', + }, + { + label: t('plugin.auth.custom'), + value: 'custom', + }, + ], + required: false, + default: is_oauth_custom_client_enabled ? 'custom' : 'default', + } as FormSchema) + result.forEach((item, index) => { + if (index > 0) { + item.show_on = [ + { + variable: '__oauth_client__', + value: 'custom', + }, + ] + if (client_params) + item.default = client_params[item.name] || item.default + } + }) + } + + return result + }, [schema, renderCustomLabel, t, is_system_oauth_params_exists, is_oauth_custom_client_enabled, client_params]) + + const __auth_client__ = useMemo(() => { + if (isConfigured) { + if (is_oauth_custom_client_enabled) + return 'custom' + return 'default' + } + else { + if (is_system_oauth_params_exists) + return 'default' + return 'custom' + } + }, [isConfigured, is_oauth_custom_client_enabled, is_system_oauth_params_exists]) + + return ( + <> + { + isConfigured && ( + + ) + } + { + !isConfigured && ( + + ) + } + { + isOAuthSettingsOpen && ( + setIsOAuthSettingsOpen(false)} + disabled={disabled || isLoading} + schemas={memorizedSchemas} + onAuth={handleOAuth} + editValues={{ + ...client_params, + __oauth_client__: __auth_client__, + }} + hasOriginalClientParams={Object.keys(client_params || {}).length > 0} + onUpdate={onUpdate} + /> + ) + } + + ) +} + +export default memo(AddOAuthButton) diff --git a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx new file mode 100644 index 0000000000..d582c660b6 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx @@ -0,0 +1,181 @@ +import { + memo, + useCallback, + useMemo, + useRef, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { RiExternalLinkLine } from '@remixicon/react' +import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' +import Modal from '@/app/components/base/modal/modal' +import { CredentialTypeEnum } from '../types' +import AuthForm from '@/app/components/base/form/form-scenarios/auth' +import type { FormRefObject } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' +import { useToastContext } from '@/app/components/base/toast' +import Loading from '@/app/components/base/loading' +import type { PluginPayload } from '../types' +import { + useAddPluginCredentialHook, + useGetPluginCredentialSchemaHook, + useUpdatePluginCredentialHook, +} from '../hooks/use-credential' +import { useRenderI18nObject } from '@/hooks/use-i18n' + +export type ApiKeyModalProps = { + pluginPayload: PluginPayload + onClose?: () => void + editValues?: Record + onRemove?: () => void + disabled?: boolean + onUpdate?: () => void +} +const ApiKeyModal = ({ + pluginPayload, + onClose, + editValues, + onRemove, + disabled, + onUpdate, +}: ApiKeyModalProps) => { + const { t } = useTranslation() + const { notify } = useToastContext() + const [doingAction, setDoingAction] = useState(false) + const doingActionRef = useRef(doingAction) + const handleSetDoingAction = useCallback((value: boolean) => { + doingActionRef.current = value + setDoingAction(value) + }, []) + const { data = [], isLoading } = useGetPluginCredentialSchemaHook(pluginPayload, CredentialTypeEnum.API_KEY) + const formSchemas = useMemo(() => { + return [ + { + type: FormTypeEnum.textInput, + name: '__name__', + label: t('plugin.auth.authorizationName'), + required: false, + }, + ...data, + ] + }, [data, t]) + const defaultValues = formSchemas.reduce((acc, schema) => { + if (schema.default) + acc[schema.name] = schema.default + return acc + }, {} as Record) + const helpField = formSchemas.find(schema => schema.url && schema.help) + const renderI18nObject = useRenderI18nObject() + const { mutateAsync: addPluginCredential } = useAddPluginCredentialHook(pluginPayload) + const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload) + const formRef = useRef(null) + const handleConfirm = useCallback(async () => { + if (doingActionRef.current) + return + const { + isCheckValidated, + values, + } = formRef.current?.getFormValues({ + needCheckValidatedValues: true, + needTransformWhenSecretFieldIsPristine: true, + }) || { isCheckValidated: false, values: {} } + if (!isCheckValidated) + return + + try { + const { + __name__, + __credential_id__, + ...restValues + } = values + + handleSetDoingAction(true) + if (editValues) { + await updatePluginCredential({ + credentials: restValues, + credential_id: __credential_id__, + name: __name__ || '', + }) + } + else { + await addPluginCredential({ + credentials: restValues, + type: CredentialTypeEnum.API_KEY, + name: __name__ || '', + }) + } + notify({ + type: 'success', + message: t('common.api.actionSuccess'), + }) + + onClose?.() + onUpdate?.() + } + finally { + handleSetDoingAction(false) + } + }, [addPluginCredential, onClose, onUpdate, updatePluginCredential, notify, t, editValues, handleSetDoingAction]) + + return ( + + + {renderI18nObject(helpField?.help as any)} + + + + ) + } + bottomSlot={ +
+ + {t('common.modelProvider.encrypted.front')} + + PKCS1_OAEP + + {t('common.modelProvider.encrypted.back')} +
+ } + onConfirm={handleConfirm} + showExtraButton={!!editValues} + onExtraButtonClick={onRemove} + disabled={disabled || isLoading || doingAction} + > + { + isLoading && ( +
+ +
+ ) + } + { + !isLoading && !!data.length && ( + + ) + } +
+ ) +} + +export default memo(ApiKeyModal) diff --git a/web/app/components/plugins/plugin-auth/authorize/index.tsx b/web/app/components/plugins/plugin-auth/authorize/index.tsx new file mode 100644 index 0000000000..f430d8d48e --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/index.tsx @@ -0,0 +1,104 @@ +import { + memo, + useMemo, +} from 'react' +import { useTranslation } from 'react-i18next' +import AddOAuthButton from './add-oauth-button' +import type { AddOAuthButtonProps } from './add-oauth-button' +import AddApiKeyButton from './add-api-key-button' +import type { AddApiKeyButtonProps } from './add-api-key-button' +import type { PluginPayload } from '../types' + +type AuthorizeProps = { + pluginPayload: PluginPayload + theme?: 'primary' | 'secondary' + showDivider?: boolean + canOAuth?: boolean + canApiKey?: boolean + disabled?: boolean + onUpdate?: () => void +} +const Authorize = ({ + pluginPayload, + theme = 'primary', + showDivider = true, + canOAuth, + canApiKey, + disabled, + onUpdate, +}: AuthorizeProps) => { + const { t } = useTranslation() + const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => { + if (theme === 'secondary') { + return { + buttonText: !canApiKey ? t('plugin.auth.useOAuthAuth') : t('plugin.auth.addOAuth'), + buttonVariant: 'secondary', + className: 'hover:bg-components-button-secondary-bg', + buttonLeftClassName: 'hover:bg-components-button-secondary-bg-hover', + buttonRightClassName: 'hover:bg-components-button-secondary-bg-hover', + dividerClassName: 'bg-divider-regular opacity-100', + pluginPayload, + } + } + + return { + buttonText: !canApiKey ? t('plugin.auth.useOAuthAuth') : t('plugin.auth.addOAuth'), + pluginPayload, + } + }, [canApiKey, theme, pluginPayload, t]) + + const apiKeyButtonProps: AddApiKeyButtonProps = useMemo(() => { + if (theme === 'secondary') { + return { + pluginPayload, + buttonVariant: 'secondary', + buttonText: !canOAuth ? t('plugin.auth.useApiAuth') : t('plugin.auth.addApi'), + } + } + return { + pluginPayload, + buttonText: !canOAuth ? t('plugin.auth.useApiAuth') : t('plugin.auth.addApi'), + buttonVariant: !canOAuth ? 'primary' : 'secondary-accent', + } + }, [canOAuth, theme, pluginPayload, t]) + + return ( + <> +
+ { + canOAuth && ( +
+ +
+ ) + } + { + showDivider && canOAuth && canApiKey && ( +
+
+ or +
+
+ ) + } + { + canApiKey && ( +
+ +
+ ) + } +
+ + ) +} + +export default memo(Authorize) diff --git a/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx new file mode 100644 index 0000000000..14c7ed957f --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx @@ -0,0 +1,188 @@ +import { + memo, + useCallback, + useRef, + useState, +} from 'react' +import { RiExternalLinkLine } from '@remixicon/react' +import { + useForm, + useStore, +} from '@tanstack/react-form' +import { useTranslation } from 'react-i18next' +import Modal from '@/app/components/base/modal/modal' +import { + useDeletePluginOAuthCustomClientHook, + useInvalidPluginOAuthClientSchemaHook, + useSetPluginOAuthCustomClientHook, +} from '../hooks/use-credential' +import type { PluginPayload } from '../types' +import AuthForm from '@/app/components/base/form/form-scenarios/auth' +import type { + FormRefObject, + FormSchema, +} from '@/app/components/base/form/types' +import { useToastContext } from '@/app/components/base/toast' +import Button from '@/app/components/base/button' +import { useRenderI18nObject } from '@/hooks/use-i18n' + +type OAuthClientSettingsProps = { + pluginPayload: PluginPayload + onClose?: () => void + editValues?: Record + disabled?: boolean + schemas: FormSchema[] + onAuth?: () => Promise + hasOriginalClientParams?: boolean + onUpdate?: () => void +} +const OAuthClientSettings = ({ + pluginPayload, + onClose, + editValues, + disabled, + schemas, + onAuth, + hasOriginalClientParams, + onUpdate, +}: OAuthClientSettingsProps) => { + const { t } = useTranslation() + const { notify } = useToastContext() + const [doingAction, setDoingAction] = useState(false) + const doingActionRef = useRef(doingAction) + const handleSetDoingAction = useCallback((value: boolean) => { + doingActionRef.current = value + setDoingAction(value) + }, []) + const defaultValues = schemas.reduce((acc, schema) => { + if (schema.default) + acc[schema.name] = schema.default + return acc + }, {} as Record) + const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload) + const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload) + const formRef = useRef(null) + const handleConfirm = useCallback(async () => { + if (doingActionRef.current) + return + + try { + const { + isCheckValidated, + values, + } = formRef.current?.getFormValues({ + needCheckValidatedValues: true, + needTransformWhenSecretFieldIsPristine: true, + }) || { isCheckValidated: false, values: {} } + if (!isCheckValidated) + throw new Error('error') + const { + __oauth_client__, + ...restValues + } = values + + handleSetDoingAction(true) + await setPluginOAuthCustomClient({ + client_params: restValues, + enable_oauth_custom_client: __oauth_client__ === 'custom', + }) + notify({ + type: 'success', + message: t('common.api.actionSuccess'), + }) + + onClose?.() + onUpdate?.() + invalidPluginOAuthClientSchema() + } + finally { + handleSetDoingAction(false) + } + }, [onClose, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, notify, t, handleSetDoingAction]) + + const handleConfirmAndAuthorize = useCallback(async () => { + await handleConfirm() + if (onAuth) + await onAuth() + }, [handleConfirm, onAuth]) + const { mutateAsync: deletePluginOAuthCustomClient } = useDeletePluginOAuthCustomClientHook(pluginPayload) + const handleRemove = useCallback(async () => { + if (doingActionRef.current) + return + + try { + handleSetDoingAction(true) + await deletePluginOAuthCustomClient() + notify({ + type: 'success', + message: t('common.api.actionSuccess'), + }) + onClose?.() + onUpdate?.() + invalidPluginOAuthClientSchema() + } + finally { + handleSetDoingAction(false) + } + }, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, notify, t, handleSetDoingAction, onClose]) + const form = useForm({ + defaultValues: editValues || defaultValues, + }) + const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__) + const helpField = schemas.find(schema => schema.url && schema.help) + const renderI18nObject = useRenderI18nObject() + return ( + + +
+ ) + } + > + <> + + { + helpField && __oauth_client__ === 'custom' && ( + + + {renderI18nObject(helpField?.help as any)} + + + + )} + + + ) +} + +export default memo(OAuthClientSettings) diff --git a/web/app/components/plugins/plugin-auth/authorized-in-node.tsx b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx new file mode 100644 index 0000000000..79189fa585 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorized-in-node.tsx @@ -0,0 +1,113 @@ +import { + memo, + useCallback, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { RiArrowDownSLine } from '@remixicon/react' +import Button from '@/app/components/base/button' +import Indicator from '@/app/components/header/indicator' +import cn from '@/utils/classnames' +import type { + Credential, + PluginPayload, +} from './types' +import { + Authorized, + usePluginAuth, +} from '.' + +type AuthorizedInNodeProps = { + pluginPayload: PluginPayload + onAuthorizationItemClick: (id: string) => void + credentialId?: string +} +const AuthorizedInNode = ({ + pluginPayload, + onAuthorizationItemClick, + credentialId, +}: AuthorizedInNodeProps) => { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const { + canApiKey, + canOAuth, + credentials, + disabled, + invalidPluginCredentialInfo, + } = usePluginAuth(pluginPayload, isOpen || !!credentialId) + const renderTrigger = useCallback((open?: boolean) => { + let label = '' + let removed = false + if (!credentialId) { + label = t('plugin.auth.workspaceDefault') + } + else { + const credential = credentials.find(c => c.id === credentialId) + label = credential ? credential.name : t('plugin.auth.authRemoved') + removed = !credential + } + return ( + + ) + }, [credentialId, credentials, t]) + const extraAuthorizationItems: Credential[] = [ + { + id: '__workspace_default__', + name: t('plugin.auth.workspaceDefault'), + provider: '', + is_default: !credentialId, + isWorkspaceDefault: true, + }, + ] + const handleAuthorizationItemClick = useCallback((id: string) => { + onAuthorizationItemClick(id) + setIsOpen(false) + }, [ + onAuthorizationItemClick, + setIsOpen, + ]) + + return ( + + ) +} + +export default memo(AuthorizedInNode) diff --git a/web/app/components/plugins/plugin-auth/authorized/index.tsx b/web/app/components/plugins/plugin-auth/authorized/index.tsx new file mode 100644 index 0000000000..ac771afdd3 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorized/index.tsx @@ -0,0 +1,342 @@ +import { + memo, + useCallback, + useRef, + useState, +} from 'react' +import { + RiArrowDownSLine, +} from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import type { + PortalToFollowElemOptions, +} from '@/app/components/base/portal-to-follow-elem' +import Button from '@/app/components/base/button' +import Indicator from '@/app/components/header/indicator' +import cn from '@/utils/classnames' +import Confirm from '@/app/components/base/confirm' +import Authorize from '../authorize' +import type { Credential } from '../types' +import { CredentialTypeEnum } from '../types' +import ApiKeyModal from '../authorize/api-key-modal' +import Item from './item' +import { useToastContext } from '@/app/components/base/toast' +import type { PluginPayload } from '../types' +import { + useDeletePluginCredentialHook, + useSetPluginDefaultCredentialHook, + useUpdatePluginCredentialHook, +} from '../hooks/use-credential' + +type AuthorizedProps = { + pluginPayload: PluginPayload + credentials: Credential[] + canOAuth?: boolean + canApiKey?: boolean + disabled?: boolean + renderTrigger?: (open?: boolean) => React.ReactNode + isOpen?: boolean + onOpenChange?: (open: boolean) => void + offset?: PortalToFollowElemOptions['offset'] + placement?: PortalToFollowElemOptions['placement'] + triggerPopupSameWidth?: boolean + popupClassName?: string + disableSetDefault?: boolean + onItemClick?: (id: string) => void + extraAuthorizationItems?: Credential[] + showItemSelectedIcon?: boolean + selectedCredentialId?: string + onUpdate?: () => void +} +const Authorized = ({ + pluginPayload, + credentials, + canOAuth, + canApiKey, + disabled, + renderTrigger, + isOpen, + onOpenChange, + offset = 8, + placement = 'bottom-start', + triggerPopupSameWidth = true, + popupClassName, + disableSetDefault, + onItemClick, + extraAuthorizationItems, + showItemSelectedIcon, + selectedCredentialId, + onUpdate, +}: AuthorizedProps) => { + const { t } = useTranslation() + const { notify } = useToastContext() + const [isLocalOpen, setIsLocalOpen] = useState(false) + const mergedIsOpen = isOpen ?? isLocalOpen + const setMergedIsOpen = useCallback((open: boolean) => { + if (onOpenChange) + onOpenChange(open) + + setIsLocalOpen(open) + }, [onOpenChange]) + const oAuthCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.OAUTH2) + const apiKeyCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.API_KEY) + const pendingOperationCredentialId = useRef(null) + const [deleteCredentialId, setDeleteCredentialId] = useState(null) + const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload) + const openConfirm = useCallback((credentialId?: string) => { + if (credentialId) + pendingOperationCredentialId.current = credentialId + + setDeleteCredentialId(pendingOperationCredentialId.current) + }, []) + const closeConfirm = useCallback(() => { + setDeleteCredentialId(null) + pendingOperationCredentialId.current = null + }, []) + const [doingAction, setDoingAction] = useState(false) + const doingActionRef = useRef(doingAction) + const handleSetDoingAction = useCallback((doing: boolean) => { + doingActionRef.current = doing + setDoingAction(doing) + }, []) + const handleConfirm = useCallback(async () => { + if (doingActionRef.current) + return + if (!pendingOperationCredentialId.current) { + setDeleteCredentialId(null) + return + } + try { + handleSetDoingAction(true) + await deletePluginCredential({ credential_id: pendingOperationCredentialId.current }) + notify({ + type: 'success', + message: t('common.api.actionSuccess'), + }) + onUpdate?.() + setDeleteCredentialId(null) + pendingOperationCredentialId.current = null + } + finally { + handleSetDoingAction(false) + } + }, [deletePluginCredential, onUpdate, notify, t, handleSetDoingAction]) + const [editValues, setEditValues] = useState | null>(null) + const handleEdit = useCallback((id: string, values: Record) => { + pendingOperationCredentialId.current = id + setEditValues(values) + }, []) + const handleRemove = useCallback(() => { + setDeleteCredentialId(pendingOperationCredentialId.current) + }, []) + const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload) + const handleSetDefault = useCallback(async (id: string) => { + if (doingActionRef.current) + return + try { + handleSetDoingAction(true) + await setPluginDefaultCredential(id) + notify({ + type: 'success', + message: t('common.api.actionSuccess'), + }) + onUpdate?.() + } + finally { + handleSetDoingAction(false) + } + }, [setPluginDefaultCredential, onUpdate, notify, t, handleSetDoingAction]) + const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload) + const handleRename = useCallback(async (payload: { + credential_id: string + name: string + }) => { + if (doingActionRef.current) + return + try { + handleSetDoingAction(true) + await updatePluginCredential(payload) + notify({ + type: 'success', + message: t('common.api.actionSuccess'), + }) + onUpdate?.() + } + finally { + handleSetDoingAction(false) + } + }, [updatePluginCredential, notify, t, handleSetDoingAction, onUpdate]) + + return ( + <> + + setMergedIsOpen(!mergedIsOpen)} + asChild + > + { + renderTrigger + ? renderTrigger(mergedIsOpen) + : ( + + ) + } + + +
+
+ { + !!extraAuthorizationItems?.length && ( +
+ { + extraAuthorizationItems.map(credential => ( + + )) + } +
+ ) + } + { + !!oAuthCredentials.length && ( +
+
+ OAuth +
+ { + oAuthCredentials.map(credential => ( + + )) + } +
+ ) + } + { + !!apiKeyCredentials.length && ( +
+
+ API Keys +
+ { + apiKeyCredentials.map(credential => ( + + )) + } +
+ ) + } +
+
+
+ +
+
+
+
+ { + deleteCredentialId && ( + + ) + } + { + !!editValues && ( + { + setEditValues(null) + pendingOperationCredentialId.current = null + }} + onRemove={handleRemove} + disabled={disabled || doingAction} + onUpdate={onUpdate} + /> + ) + } + + ) +} + +export default memo(Authorized) diff --git a/web/app/components/plugins/plugin-auth/authorized/item.tsx b/web/app/components/plugins/plugin-auth/authorized/item.tsx new file mode 100644 index 0000000000..5508bcc324 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/authorized/item.tsx @@ -0,0 +1,219 @@ +import { + memo, + useMemo, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { + RiCheckLine, + RiDeleteBinLine, + RiEditLine, + RiEqualizer2Line, +} from '@remixicon/react' +import Indicator from '@/app/components/header/indicator' +import Badge from '@/app/components/base/badge' +import ActionButton from '@/app/components/base/action-button' +import Tooltip from '@/app/components/base/tooltip' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import cn from '@/utils/classnames' +import type { Credential } from '../types' +import { CredentialTypeEnum } from '../types' + +type ItemProps = { + credential: Credential + disabled?: boolean + onDelete?: (id: string) => void + onEdit?: (id: string, values: Record) => void + onSetDefault?: (id: string) => void + onRename?: (payload: { + credential_id: string + name: string + }) => void + disableRename?: boolean + disableEdit?: boolean + disableDelete?: boolean + disableSetDefault?: boolean + onItemClick?: (id: string) => void + showSelectedIcon?: boolean + selectedCredentialId?: string +} +const Item = ({ + credential, + disabled, + onDelete, + onEdit, + onSetDefault, + onRename, + disableRename, + disableEdit, + disableDelete, + disableSetDefault, + onItemClick, + showSelectedIcon, + selectedCredentialId, +}: ItemProps) => { + const { t } = useTranslation() + const [renaming, setRenaming] = useState(false) + const [renameValue, setRenameValue] = useState(credential.name) + const isOAuth = credential.credential_type === CredentialTypeEnum.OAUTH2 + const showAction = useMemo(() => { + return !(disableRename && disableEdit && disableDelete && disableSetDefault) + }, [disableRename, disableEdit, disableDelete, disableSetDefault]) + + return ( +
onItemClick?.(credential.id === '__workspace_default__' ? '' : credential.id)} + > + { + renaming && ( +
+ setRenameValue(e.target.value)} + placeholder={t('common.placeholder.input')} + onClick={e => e.stopPropagation()} + /> + + +
+ ) + } + { + !renaming && ( +
+ { + showSelectedIcon && ( +
+ { + selectedCredentialId === credential.id && ( + + ) + } +
+ ) + } + +
+ {credential.name} +
+ { + credential.is_default && ( + + {t('plugin.auth.default')} + + ) + } +
+ ) + } + { + showAction && !renaming && ( +
+ { + !credential.is_default && !disableSetDefault && ( + + ) + } + { + !disableRename && ( + + { + e.stopPropagation() + setRenaming(true) + setRenameValue(credential.name) + }} + > + + + + ) + } + { + !isOAuth && !disableEdit && ( + + { + e.stopPropagation() + onEdit?.( + credential.id, + { + ...credential.credentials, + __name__: credential.name, + __credential_id__: credential.id, + }, + ) + }} + > + + + + ) + } + { + !disableDelete && ( + + { + e.stopPropagation() + onDelete?.(credential.id) + }} + > + + + + ) + } +
+ ) + } +
+ ) +} + +export default memo(Item) diff --git a/web/app/components/plugins/plugin-auth/hooks/use-credential.ts b/web/app/components/plugins/plugin-auth/hooks/use-credential.ts new file mode 100644 index 0000000000..5a7a497ad9 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/use-credential.ts @@ -0,0 +1,88 @@ +import { + useAddPluginCredential, + useDeletePluginCredential, + useDeletePluginOAuthCustomClient, + useGetPluginCredentialInfo, + useGetPluginCredentialSchema, + useGetPluginOAuthClientSchema, + useGetPluginOAuthUrl, + useInvalidPluginCredentialInfo, + useInvalidPluginOAuthClientSchema, + useSetPluginDefaultCredential, + useSetPluginOAuthCustomClient, + useUpdatePluginCredential, +} from '@/service/use-plugins-auth' +import { useGetApi } from './use-get-api' +import type { PluginPayload } from '../types' +import type { CredentialTypeEnum } from '../types' + +export const useGetPluginCredentialInfoHook = (pluginPayload: PluginPayload, enable?: boolean) => { + const apiMap = useGetApi(pluginPayload) + return useGetPluginCredentialInfo(enable ? apiMap.getCredentialInfo : '') +} + +export const useDeletePluginCredentialHook = (pluginPayload: PluginPayload) => { + const apiMap = useGetApi(pluginPayload) + + return useDeletePluginCredential(apiMap.deleteCredential) +} + +export const useInvalidPluginCredentialInfoHook = (pluginPayload: PluginPayload) => { + const apiMap = useGetApi(pluginPayload) + + return useInvalidPluginCredentialInfo(apiMap.getCredentialInfo) +} + +export const useSetPluginDefaultCredentialHook = (pluginPayload: PluginPayload) => { + const apiMap = useGetApi(pluginPayload) + + return useSetPluginDefaultCredential(apiMap.setDefaultCredential) +} + +export const useGetPluginCredentialSchemaHook = (pluginPayload: PluginPayload, credentialType: CredentialTypeEnum) => { + const apiMap = useGetApi(pluginPayload) + + return useGetPluginCredentialSchema(apiMap.getCredentialSchema(credentialType)) +} + +export const useAddPluginCredentialHook = (pluginPayload: PluginPayload) => { + const apiMap = useGetApi(pluginPayload) + + return useAddPluginCredential(apiMap.addCredential) +} + +export const useUpdatePluginCredentialHook = (pluginPayload: PluginPayload) => { + const apiMap = useGetApi(pluginPayload) + + return useUpdatePluginCredential(apiMap.updateCredential) +} + +export const useGetPluginOAuthUrlHook = (pluginPayload: PluginPayload) => { + const apiMap = useGetApi(pluginPayload) + + return useGetPluginOAuthUrl(apiMap.getOauthUrl) +} + +export const useGetPluginOAuthClientSchemaHook = (pluginPayload: PluginPayload) => { + const apiMap = useGetApi(pluginPayload) + + return useGetPluginOAuthClientSchema(apiMap.getOauthClientSchema) +} + +export const useInvalidPluginOAuthClientSchemaHook = (pluginPayload: PluginPayload) => { + const apiMap = useGetApi(pluginPayload) + + return useInvalidPluginOAuthClientSchema(apiMap.getOauthClientSchema) +} + +export const useSetPluginOAuthCustomClientHook = (pluginPayload: PluginPayload) => { + const apiMap = useGetApi(pluginPayload) + + return useSetPluginOAuthCustomClient(apiMap.setCustomOauthClient) +} + +export const useDeletePluginOAuthCustomClientHook = (pluginPayload: PluginPayload) => { + const apiMap = useGetApi(pluginPayload) + + return useDeletePluginOAuthCustomClient(apiMap.deleteCustomOAuthClient) +} diff --git a/web/app/components/plugins/plugin-auth/hooks/use-get-api.ts b/web/app/components/plugins/plugin-auth/hooks/use-get-api.ts new file mode 100644 index 0000000000..14199ddc4d --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/use-get-api.ts @@ -0,0 +1,41 @@ +import { + AuthCategory, +} from '../types' +import type { + CredentialTypeEnum, + PluginPayload, +} from '../types' + +export const useGetApi = ({ category = AuthCategory.tool, provider }: PluginPayload) => { + if (category === AuthCategory.tool) { + return { + getCredentialInfo: `/workspaces/current/tool-provider/builtin/${provider}/credential/info`, + setDefaultCredential: `/workspaces/current/tool-provider/builtin/${provider}/default-credential`, + getCredentials: `/workspaces/current/tool-provider/builtin/${provider}/credentials`, + addCredential: `/workspaces/current/tool-provider/builtin/${provider}/add`, + updateCredential: `/workspaces/current/tool-provider/builtin/${provider}/update`, + deleteCredential: `/workspaces/current/tool-provider/builtin/${provider}/delete`, + getCredentialSchema: (credential_type: CredentialTypeEnum) => `/workspaces/current/tool-provider/builtin/${provider}/credential/schema/${credential_type}`, + getOauthUrl: `/oauth/plugin/${provider}/tool/authorization-url`, + getOauthClientSchema: `/workspaces/current/tool-provider/builtin/${provider}/oauth/client-schema`, + setCustomOauthClient: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`, + getCustomOAuthClientValues: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`, + deleteCustomOAuthClient: `/workspaces/current/tool-provider/builtin/${provider}/oauth/custom-client`, + } + } + + return { + getCredentialInfo: '', + setDefaultCredential: '', + getCredentials: '', + addCredential: '', + updateCredential: '', + deleteCredential: '', + getCredentialSchema: () => '', + getOauthUrl: '', + getOauthClientSchema: '', + setCustomOauthClient: '', + getCustomOAuthClientValues: '', + deleteCustomOAuthClient: '', + } +} diff --git a/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts b/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts new file mode 100644 index 0000000000..e449a4bb65 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/hooks/use-plugin-auth.ts @@ -0,0 +1,25 @@ +import { useAppContext } from '@/context/app-context' +import { + useGetPluginCredentialInfoHook, + useInvalidPluginCredentialInfoHook, +} from './use-credential' +import { CredentialTypeEnum } from '../types' +import type { PluginPayload } from '../types' + +export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) => { + const { data } = useGetPluginCredentialInfoHook(pluginPayload, enable) + const { isCurrentWorkspaceManager } = useAppContext() + const isAuthorized = !!data?.credentials.length + const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2) + const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY) + const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload) + + return { + isAuthorized, + canOAuth, + canApiKey, + credentials: data?.credentials || [], + disabled: !isCurrentWorkspaceManager, + invalidPluginCredentialInfo, + } +} diff --git a/web/app/components/plugins/plugin-auth/index.tsx b/web/app/components/plugins/plugin-auth/index.tsx new file mode 100644 index 0000000000..e4f6ae8b2f --- /dev/null +++ b/web/app/components/plugins/plugin-auth/index.tsx @@ -0,0 +1,6 @@ +export { default as PluginAuth } from './plugin-auth' +export { default as Authorized } from './authorized' +export { default as AuthorizedInNode } from './authorized-in-node' +export { default as PluginAuthInAgent } from './plugin-auth-in-agent' +export { usePluginAuth } from './hooks/use-plugin-auth' +export * from './types' diff --git a/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx b/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx new file mode 100644 index 0000000000..f3557f3d6f --- /dev/null +++ b/web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx @@ -0,0 +1,123 @@ +import { + memo, + useCallback, + useState, +} from 'react' +import { RiArrowDownSLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import Authorize from './authorize' +import Authorized from './authorized' +import type { + Credential, + PluginPayload, +} from './types' +import { usePluginAuth } from './hooks/use-plugin-auth' +import Button from '@/app/components/base/button' +import Indicator from '@/app/components/header/indicator' +import cn from '@/utils/classnames' + +type PluginAuthInAgentProps = { + pluginPayload: PluginPayload + credentialId?: string + onAuthorizationItemClick?: (id: string) => void +} +const PluginAuthInAgent = ({ + pluginPayload, + credentialId, + onAuthorizationItemClick, +}: PluginAuthInAgentProps) => { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const { + isAuthorized, + canOAuth, + canApiKey, + credentials, + disabled, + invalidPluginCredentialInfo, + } = usePluginAuth(pluginPayload, true) + + const extraAuthorizationItems: Credential[] = [ + { + id: '__workspace_default__', + name: t('plugin.auth.workspaceDefault'), + provider: '', + is_default: !credentialId, + isWorkspaceDefault: true, + }, + ] + + const handleAuthorizationItemClick = useCallback((id: string) => { + onAuthorizationItemClick?.(id) + setIsOpen(false) + }, [ + onAuthorizationItemClick, + setIsOpen, + ]) + + const renderTrigger = useCallback((isOpen?: boolean) => { + let label = '' + let removed = false + if (!credentialId) { + label = t('plugin.auth.workspaceDefault') + } + else { + const credential = credentials.find(c => c.id === credentialId) + label = credential ? credential.name : t('plugin.auth.authRemoved') + removed = !credential + } + return ( + + ) + }, [credentialId, credentials, t]) + + return ( + <> + { + !isAuthorized && ( + + ) + } + { + isAuthorized && ( + + ) + } + + ) +} + +export default memo(PluginAuthInAgent) diff --git a/web/app/components/plugins/plugin-auth/plugin-auth.tsx b/web/app/components/plugins/plugin-auth/plugin-auth.tsx new file mode 100644 index 0000000000..76b405a750 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/plugin-auth.tsx @@ -0,0 +1,59 @@ +import { memo } from 'react' +import Authorize from './authorize' +import Authorized from './authorized' +import type { PluginPayload } from './types' +import { usePluginAuth } from './hooks/use-plugin-auth' +import cn from '@/utils/classnames' + +type PluginAuthProps = { + pluginPayload: PluginPayload + children?: React.ReactNode + className?: string +} +const PluginAuth = ({ + pluginPayload, + children, + className, +}: PluginAuthProps) => { + const { + isAuthorized, + canOAuth, + canApiKey, + credentials, + disabled, + invalidPluginCredentialInfo, + } = usePluginAuth(pluginPayload, !!pluginPayload.provider) + + return ( +
+ { + !isAuthorized && ( + + ) + } + { + isAuthorized && !children && ( + + ) + } + { + isAuthorized && children + } +
+ ) +} + +export default memo(PluginAuth) diff --git a/web/app/components/plugins/plugin-auth/types.ts b/web/app/components/plugins/plugin-auth/types.ts new file mode 100644 index 0000000000..ad41733bde --- /dev/null +++ b/web/app/components/plugins/plugin-auth/types.ts @@ -0,0 +1,25 @@ +export enum AuthCategory { + tool = 'tool', + datasource = 'datasource', + model = 'model', +} + +export type PluginPayload = { + category: AuthCategory + provider: string +} + +export enum CredentialTypeEnum { + OAUTH2 = 'oauth2', + API_KEY = 'api-key', +} + +export type Credential = { + id: string + name: string + provider: string + credential_type?: CredentialTypeEnum + is_default: boolean + credentials?: Record + isWorkspaceDefault?: boolean +} diff --git a/web/app/components/plugins/plugin-auth/utils.ts b/web/app/components/plugins/plugin-auth/utils.ts new file mode 100644 index 0000000000..d264cfb198 --- /dev/null +++ b/web/app/components/plugins/plugin-auth/utils.ts @@ -0,0 +1,10 @@ +export const transformFormSchemasSecretInput = (isPristineSecretInputNames: string[], values: Record) => { + const transformedValues: Record = { ...values } + + isPristineSecretInputNames.forEach((name) => { + if (transformedValues[name]) + transformedValues[name] = '[__HIDDEN__]' + }) + + return transformedValues +} diff --git a/web/app/components/plugins/plugin-detail-panel/action-list.tsx b/web/app/components/plugins/plugin-detail-panel/action-list.tsx index 2505b6d5aa..040c728630 100644 --- a/web/app/components/plugins/plugin-detail-panel/action-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/action-list.tsx @@ -1,17 +1,9 @@ -import React, { useMemo, useState } from 'react' +import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useAppContext } from '@/context/app-context' -import Button from '@/app/components/base/button' -import Toast from '@/app/components/base/toast' -import Indicator from '@/app/components/header/indicator' import ToolItem from '@/app/components/tools/provider/tool-item' -import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials' import { useAllToolProviders, useBuiltinTools, - useInvalidateAllToolProviders, - useRemoveProviderCredentials, - useUpdateProviderCredentials, } from '@/service/use-tools' import type { PluginDetail } from '@/app/components/plugins/types' @@ -23,35 +15,14 @@ const ActionList = ({ detail, }: Props) => { const { t } = useTranslation() - const { isCurrentWorkspaceManager } = useAppContext() const providerBriefInfo = detail.declaration.tool.identity const providerKey = `${detail.plugin_id}/${providerBriefInfo.name}` const { data: collectionList = [] } = useAllToolProviders() - const invalidateAllToolProviders = useInvalidateAllToolProviders() const provider = useMemo(() => { return collectionList.find(collection => collection.name === providerKey) }, [collectionList, providerKey]) const { data } = useBuiltinTools(providerKey) - const [showSettingAuth, setShowSettingAuth] = useState(false) - - const handleCredentialSettingUpdate = () => { - invalidateAllToolProviders() - Toast.notify({ - type: 'success', - message: t('common.api.actionSuccess'), - }) - setShowSettingAuth(false) - } - - const { mutate: updatePermission, isPending } = useUpdateProviderCredentials({ - onSuccess: handleCredentialSettingUpdate, - }) - - const { mutate: removePermission } = useRemoveProviderCredentials({ - onSuccess: handleCredentialSettingUpdate, - }) - if (!data || !provider) return null @@ -60,26 +31,7 @@ const ActionList = ({
{t('plugin.detailPanel.actionNum', { num: data.length, action: data.length > 1 ? 'actions' : 'action' })} - {provider.is_team_authorization && provider.allow_delete && ( - - )}
- {!provider.is_team_authorization && provider.allow_delete && ( - - )}
{data.map(tool => ( @@ -93,18 +45,6 @@ const ActionList = ({ /> ))}
- {showSettingAuth && ( - setShowSettingAuth(false)} - onSaved={async value => updatePermission({ - providerName: provider.name, - credentials: value, - })} - onRemove={async () => removePermission(provider.name)} - isSaving={isPending} - /> - )}
) } diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx index 0a5a8b87d6..124e133c2b 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -36,6 +36,9 @@ import { useInvalidateAllToolProviders } from '@/service/use-tools' import { API_PREFIX } from '@/config' import cn from '@/utils/classnames' import { getMarketplaceUrl } from '@/utils/var' +import { PluginAuth } from '@/app/components/plugins/plugin-auth' +import { AuthCategory } from '@/app/components/plugins/plugin-auth' +import { useAllToolProviders } from '@/service/use-tools' const i18nPrefix = 'plugin.action' @@ -68,7 +71,14 @@ const DetailHeader = ({ meta, plugin_id, } = detail - const { author, category, name, label, description, icon, verified } = detail.declaration + const { author, category, name, label, description, icon, verified, tool } = detail.declaration + const isTool = category === PluginType.tool + const providerBriefInfo = tool?.identity + const providerKey = `${plugin_id}/${providerBriefInfo?.name}` + const { data: collectionList = [] } = useAllToolProviders(isTool) + const provider = useMemo(() => { + return collectionList.find(collection => collection.name === providerKey) + }, [collectionList, providerKey]) const isFromGitHub = source === PluginSource.github const isFromMarketplace = source === PluginSource.marketplace @@ -262,7 +272,17 @@ const DetailHeader = ({
- + + { + category === PluginType.tool && ( + + ) + } {isShowPluginInfo && ( = ({ } as any) } - // authorization - const { isCurrentWorkspaceManager } = useAppContext() - const [isShowSettingAuth, setShowSettingAuth] = useState(false) - const handleCredentialSettingUpdate = () => { - invalidateAllBuiltinTools() - Toast.notify({ - type: 'success', - message: t('common.api.actionSuccess'), - }) - setShowSettingAuth(false) - onShowChange(false) - } - - const { mutate: updatePermission } = useUpdateProviderCredentials({ - onSuccess: handleCredentialSettingUpdate, - }) - // install from marketplace const currentTool = useMemo(() => { return currentProvider?.tools.find(tool => tool.name === value?.tool_name) @@ -226,6 +203,12 @@ const ToolSelector: FC = ({ invalidateAllBuiltinTools() invalidateInstalledPluginList() } + const handleAuthorizationItemClick = (id: string) => { + onSelect({ + ...value, + credential_id: id, + } as any) + } return ( <> @@ -264,7 +247,6 @@ const ToolSelector: FC = ({ onSwitchChange={handleEnabledChange} onDelete={onDelete} noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization} - onAuth={() => setShowSettingAuth(true)} uninstalled={!currentProvider && inMarketPlace} versionMismatch={currentProvider && inMarketPlace && !currentTool} installInfo={manifest?.latest_package_identifier} @@ -284,171 +266,131 @@ const ToolSelector: FC = ({ )} -
- {!isShowSettingAuth && ( - <> -
{t(`plugin.detailPanel.toolSelector.${isEdit ? 'toolSetting' : 'title'}`)}
- {/* base form */} -
-
-
{t('plugin.detailPanel.toolSelector.toolLabel')}
- - } - isShow={panelShowState || isShowChooseTool} - onShowChange={trigger ? onPanelShowStateChange as any : setIsShowChooseTool} - disabled={false} - supportAddCustomTool - onSelect={handleSelectTool} - onSelectMultiple={handleSelectMultipleTool} - scope={scope} - selectedTools={selectedTools} - canChooseMCPTool={canChooseMCPTool} - /> -
-
-
{t('plugin.detailPanel.toolSelector.descriptionLabel')}
-