Compare commits

..
Author SHA1 Message Date
Harry f3761c26e9 Merge remote-tracking branch 'origin/main' into feat/llm-node-support-tools 2026-01-05 18:17:05 +08:00
-LAN-GitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
a9e2c05a10 feat(graph-engine): add command to update variables at runtime (#30563)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-05 16:47:34 +08:00
-LAN-GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
6f8bd58e19 feat(graph-engine): make layer runtime state non-null and bound early (#30552)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-05 16:43:42 +08:00
591ca05c84 feat(logstore): make graph field optional via env variable LOGSTORE… (#30554)
Co-authored-by: 阿永 <ayong.dy@alibaba-inc.com>
2026-01-05 16:12:41 +08:00
Stephen ZhouandGitHub a72044aa86 chore: fix lint in i18n (#30571) 2026-01-05 16:12:12 +08:00
LeworkandGitHub 34f3b288a7 chore(docker): update nltk data download process to include unstructured download_nltk_packages (#28876) 2026-01-05 15:50:33 +08:00
-LAN-andGitHub a99ac3fe0d refactor(models): Add mapped type hints to MessageAnnotation (#27751) 2026-01-05 15:50:03 +08:00
Stephen ZhouandGitHub 52149c0d9b chore(web): add ESLint rules for i18n JSON validation (#30491) 2026-01-05 15:49:31 +08:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
631f999f65 refactor: use contains_any instead of Chaining where = where | f (#30559)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-05 15:48:31 +08:00
hsiongGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
be3ef9f050 fix: #30511 [Bug] knowledge_retrieval_node fails when using Rerank Model: "Working outside of application context" and add regression test (#30549)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-05 15:02:21 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
93a85ae98a chore(deps): bump @amplitude/analytics-browser from 2.31.4 to 2.33.1 in /web (#30538)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 15:05:04 +09:00
wangxiaoleiandGitHub e3e19c437a fix: fix db env not work (#30541) 2026-01-05 11:10:45 +08:00
hsiongandGitHub 693daea474 fix: INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH settings (#30463) 2026-01-05 11:10:04 +08:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
bc317a0009 feat: return data_source_info and data_source_detail_dict (#29912)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-05 11:04:03 +08:00
c158dfa198 fix: support to change NEXT_PUBLIC_BASE_PATH env using --build-arg in docker build (#29836)
Co-authored-by: root <root@KIMI-DESKTOP-01.mchrcloud.com>
2026-01-05 11:03:12 +08:00
MariesandGitHub 79913590ae fix(api): surface subscription deletion errors to users (#30333) 2026-01-05 11:02:04 +08:00
wangxiaoleiandGitHub f1fff0a243 fix: fix WorkflowExecution.outputs containing non-JSON-serializable o… (#30464) 2026-01-05 10:57:23 +08:00
hsiongandGitHub 4bb08b93d7 chore: update dockerignore (#30460) 2026-01-05 10:55:14 +08:00
wangxiaoleiandGitHub d0564ac63c feat: add flask command file-usage (#30500) 2026-01-05 10:52:21 +08:00
-LAN-andGitHub eb321ad614 chore: Add a new rule for import lint (#30526) 2026-01-05 10:48:14 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7128d71cf7 chore(deps-dev): bump intersystems-irispython from 5.3.0 to 5.3.1 in /api (#30540)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 10:47:39 +08:00
-LAN-GitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
95edbad1c7 refactor(workflow): add Jinja2 renderer abstraction for template transform (#30535)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-05 10:46:37 +08:00
-LAN-andGitHub 154abdd915 chore: Update PR template lint command (#30533) 2026-01-05 10:46:01 +08:00
zxhlyh d132abcdb4 merge main 2025-12-29 15:55:45 +08:00
zxhlyh d60348572e feat: llm node support tools 2025-12-29 14:55:26 +08:00
zxhlyh 0cff94d90e Merge branch 'main' into feat/llm-node-support-tools 2025-12-25 13:45:49 +08:00
zxhlyh a7859de625 feat: llm node support tools 2025-12-24 14:15:55 +08:00
95 changed files with 2024 additions and 996 deletions
+1 -1
View File
@@ -20,4 +20,4 @@
- [x] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!)
- [x] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
- [x] I've updated the documentation accordingly.
- [x] I ran `dev/reformat`(backend) and `cd web && npx lint-staged`(frontend) to appease the lint gods
- [x] I ran `make lint` and `make type-check` (backend) and `cd web && npx lint-staged` (frontend) to appease the lint gods
+3 -2
View File
@@ -60,9 +60,10 @@ check:
@echo "✅ Code check complete"
lint:
@echo "🔧 Running ruff format, check with fixes, and import linter..."
@echo "🔧 Running ruff format, check with fixes, import linter, and dotenv-linter..."
@uv run --project api --dev sh -c 'ruff format ./api && ruff check --fix ./api'
@uv run --directory api --dev lint-imports
@uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example
@echo "✅ Linting complete"
type-check:
@@ -122,7 +123,7 @@ help:
@echo "Backend Code Quality:"
@echo " make format - Format code with ruff"
@echo " make check - Check code with ruff"
@echo " make lint - Format and fix code with ruff"
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
@echo " make type-check - Run type checking with basedpyright"
@echo " make test - Run backend unit tests"
@echo ""
+4
View File
@@ -575,6 +575,10 @@ LOGSTORE_DUAL_WRITE_ENABLED=false
# Enable dual-read fallback to SQL database when LogStore returns no results (default: true)
# Useful for migration scenarios where historical data exists only in SQL database
LOGSTORE_DUAL_READ_ENABLED=true
# Control flag for whether to write the `graph` field to LogStore.
# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field;
# otherwise write an empty {} instead. Defaults to writing the `graph` field.
LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true
# Celery beat configuration
CELERY_BEAT_SCHEDULER_TIME=1
+25
View File
@@ -3,9 +3,11 @@ root_packages =
core
configs
controllers
extensions
models
tasks
services
include_external_packages = True
[importlinter:contract:workflow]
name = Workflow
@@ -33,6 +35,29 @@ ignore_imports =
core.workflow.nodes.loop.loop_node -> core.workflow.graph
core.workflow.nodes.loop.loop_node -> core.workflow.graph_engine.command_channels
[importlinter:contract:workflow-infrastructure-dependencies]
name = Workflow Infrastructure Dependencies
type = forbidden
source_modules =
core.workflow
forbidden_modules =
extensions.ext_database
extensions.ext_redis
allow_indirect_imports = True
ignore_imports =
core.workflow.nodes.agent.agent_node -> extensions.ext_database
core.workflow.nodes.datasource.datasource_node -> extensions.ext_database
core.workflow.nodes.knowledge_index.knowledge_index_node -> extensions.ext_database
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> extensions.ext_database
core.workflow.nodes.llm.file_saver -> extensions.ext_database
core.workflow.nodes.llm.llm_utils -> extensions.ext_database
core.workflow.nodes.llm.node -> extensions.ext_database
core.workflow.nodes.tool.tool_node -> extensions.ext_database
core.workflow.nodes.variable_assigner.common.impl -> extensions.ext_database
core.workflow.graph_engine.command_channels.redis_channel -> extensions.ext_redis
core.workflow.graph_engine.manager -> extensions.ext_redis
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> extensions.ext_redis
[importlinter:contract:rsc]
name = RSC
type = layers
+2 -1
View File
@@ -79,7 +79,8 @@ COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV}
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
# Download nltk data
RUN mkdir -p /usr/local/share/nltk_data && NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.download('punkt'); nltk.download('averaged_perceptron_tagger'); nltk.download('stopwords')" \
RUN mkdir -p /usr/local/share/nltk_data \
&& NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; from unstructured.nlp.tokenize import download_nltk_packages; nltk.download('punkt'); nltk.download('averaged_perceptron_tagger'); nltk.download('stopwords'); download_nltk_packages()" \
&& chmod -R 755 /usr/local/share/nltk_data
ENV TIKTOKEN_CACHE_DIR=/app/api/.tiktoken_cache
+212 -1
View File
@@ -235,7 +235,7 @@ def migrate_annotation_vector_database():
if annotations:
for annotation in annotations:
document = Document(
page_content=annotation.question,
page_content=annotation.question_text,
metadata={"annotation_id": annotation.id, "app_id": app.id, "doc_id": annotation.id},
)
documents.append(document)
@@ -1184,6 +1184,217 @@ def remove_orphaned_files_on_storage(force: bool):
click.echo(click.style(f"Removed {removed_files} orphaned files, with {error_files} errors.", fg="yellow"))
@click.command("file-usage", help="Query file usages and show where files are referenced.")
@click.option("--file-id", type=str, default=None, help="Filter by file UUID.")
@click.option("--key", type=str, default=None, help="Filter by storage key.")
@click.option("--src", type=str, default=None, help="Filter by table.column pattern (e.g., 'documents.%' or '%.icon').")
@click.option("--limit", type=int, default=100, help="Limit number of results (default: 100).")
@click.option("--offset", type=int, default=0, help="Offset for pagination (default: 0).")
@click.option("--json", "output_json", is_flag=True, help="Output results in JSON format.")
def file_usage(
file_id: str | None,
key: str | None,
src: str | None,
limit: int,
offset: int,
output_json: bool,
):
"""
Query file usages and show where files are referenced in the database.
This command reuses the same reference checking logic as clear-orphaned-file-records
and displays detailed information about where each file is referenced.
"""
# define tables and columns to process
files_tables = [
{"table": "upload_files", "id_column": "id", "key_column": "key"},
{"table": "tool_files", "id_column": "id", "key_column": "file_key"},
]
ids_tables = [
{"type": "uuid", "table": "message_files", "column": "upload_file_id", "pk_column": "id"},
{"type": "text", "table": "documents", "column": "data_source_info", "pk_column": "id"},
{"type": "text", "table": "document_segments", "column": "content", "pk_column": "id"},
{"type": "text", "table": "messages", "column": "answer", "pk_column": "id"},
{"type": "text", "table": "workflow_node_executions", "column": "inputs", "pk_column": "id"},
{"type": "text", "table": "workflow_node_executions", "column": "process_data", "pk_column": "id"},
{"type": "text", "table": "workflow_node_executions", "column": "outputs", "pk_column": "id"},
{"type": "text", "table": "conversations", "column": "introduction", "pk_column": "id"},
{"type": "text", "table": "conversations", "column": "system_instruction", "pk_column": "id"},
{"type": "text", "table": "accounts", "column": "avatar", "pk_column": "id"},
{"type": "text", "table": "apps", "column": "icon", "pk_column": "id"},
{"type": "text", "table": "sites", "column": "icon", "pk_column": "id"},
{"type": "json", "table": "messages", "column": "inputs", "pk_column": "id"},
{"type": "json", "table": "messages", "column": "message", "pk_column": "id"},
]
# Stream file usages with pagination to avoid holding all results in memory
paginated_usages = []
total_count = 0
# First, build a mapping of file_id -> storage_key from the base tables
file_key_map = {}
for files_table in files_tables:
query = f"SELECT {files_table['id_column']}, {files_table['key_column']} FROM {files_table['table']}"
with db.engine.begin() as conn:
rs = conn.execute(sa.text(query))
for row in rs:
file_key_map[str(row[0])] = f"{files_table['table']}:{row[1]}"
# If filtering by key or file_id, verify it exists
if file_id and file_id not in file_key_map:
if output_json:
click.echo(json.dumps({"error": f"File ID {file_id} not found in base tables"}))
else:
click.echo(click.style(f"File ID {file_id} not found in base tables.", fg="red"))
return
if key:
valid_prefixes = {f"upload_files:{key}", f"tool_files:{key}"}
matching_file_ids = [fid for fid, fkey in file_key_map.items() if fkey in valid_prefixes]
if not matching_file_ids:
if output_json:
click.echo(json.dumps({"error": f"Key {key} not found in base tables"}))
else:
click.echo(click.style(f"Key {key} not found in base tables.", fg="red"))
return
guid_regexp = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
# For each reference table/column, find matching file IDs and record the references
for ids_table in ids_tables:
src_filter = f"{ids_table['table']}.{ids_table['column']}"
# Skip if src filter doesn't match (use fnmatch for wildcard patterns)
if src:
if "%" in src or "_" in src:
import fnmatch
# Convert SQL LIKE wildcards to fnmatch wildcards (% -> *, _ -> ?)
pattern = src.replace("%", "*").replace("_", "?")
if not fnmatch.fnmatch(src_filter, pattern):
continue
else:
if src_filter != src:
continue
if ids_table["type"] == "uuid":
# Direct UUID match
query = (
f"SELECT {ids_table['pk_column']}, {ids_table['column']} "
f"FROM {ids_table['table']} WHERE {ids_table['column']} IS NOT NULL"
)
with db.engine.begin() as conn:
rs = conn.execute(sa.text(query))
for row in rs:
record_id = str(row[0])
ref_file_id = str(row[1])
if ref_file_id not in file_key_map:
continue
storage_key = file_key_map[ref_file_id]
# Apply filters
if file_id and ref_file_id != file_id:
continue
if key and not storage_key.endswith(key):
continue
# Only collect items within the requested page range
if offset <= total_count < offset + limit:
paginated_usages.append(
{
"src": f"{ids_table['table']}.{ids_table['column']}",
"record_id": record_id,
"file_id": ref_file_id,
"key": storage_key,
}
)
total_count += 1
elif ids_table["type"] in ("text", "json"):
# Extract UUIDs from text/json content
column_cast = f"{ids_table['column']}::text" if ids_table["type"] == "json" else ids_table["column"]
query = (
f"SELECT {ids_table['pk_column']}, {column_cast} "
f"FROM {ids_table['table']} WHERE {ids_table['column']} IS NOT NULL"
)
with db.engine.begin() as conn:
rs = conn.execute(sa.text(query))
for row in rs:
record_id = str(row[0])
content = str(row[1])
# Find all UUIDs in the content
import re
uuid_pattern = re.compile(guid_regexp, re.IGNORECASE)
matches = uuid_pattern.findall(content)
for ref_file_id in matches:
if ref_file_id not in file_key_map:
continue
storage_key = file_key_map[ref_file_id]
# Apply filters
if file_id and ref_file_id != file_id:
continue
if key and not storage_key.endswith(key):
continue
# Only collect items within the requested page range
if offset <= total_count < offset + limit:
paginated_usages.append(
{
"src": f"{ids_table['table']}.{ids_table['column']}",
"record_id": record_id,
"file_id": ref_file_id,
"key": storage_key,
}
)
total_count += 1
# Output results
if output_json:
result = {
"total": total_count,
"offset": offset,
"limit": limit,
"usages": paginated_usages,
}
click.echo(json.dumps(result, indent=2))
else:
click.echo(
click.style(f"Found {total_count} file usages (showing {len(paginated_usages)} results)", fg="white")
)
click.echo("")
if not paginated_usages:
click.echo(click.style("No file usages found matching the specified criteria.", fg="yellow"))
return
# Print table header
click.echo(
click.style(
f"{'Src (Table.Column)':<50} {'Record ID':<40} {'File ID':<40} {'Storage Key':<60}",
fg="cyan",
)
)
click.echo(click.style("-" * 190, fg="white"))
# Print each usage
for usage in paginated_usages:
click.echo(f"{usage['src']:<50} {usage['record_id']:<40} {usage['file_id']:<40} {usage['key']:<60}")
# Show pagination info
if offset + limit < total_count:
click.echo("")
click.echo(
click.style(
f"Showing {offset + 1}-{offset + len(paginated_usages)} of {total_count} results", fg="white"
)
)
click.echo(click.style(f"Use --offset {offset + limit} to see next page", fg="white"))
@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")
@@ -16,7 +16,6 @@ class MilvusConfig(BaseSettings):
description="Authentication token for Milvus, if token-based authentication is enabled",
default=None,
)
MILVUS_USER: str | None = Field(
description="Username for authenticating with Milvus, if username/password authentication is enabled",
default=None,
@@ -751,12 +751,12 @@ class DocumentApi(DocumentResource):
elif metadata == "without":
dataset_process_rules = DatasetService.get_process_rules(dataset_id)
document_process_rules = document.dataset_process_rule.to_dict() if document.dataset_process_rule else {}
data_source_info = document.data_source_detail_dict
response = {
"id": document.id,
"position": document.position,
"data_source_type": document.data_source_type,
"data_source_info": data_source_info,
"data_source_info": document.data_source_info_dict,
"data_source_detail_dict": document.data_source_detail_dict,
"dataset_process_rule_id": document.dataset_process_rule_id,
"dataset_process_rule": dataset_process_rules,
"document_process_rule": document_process_rules,
@@ -784,12 +784,12 @@ class DocumentApi(DocumentResource):
else:
dataset_process_rules = DatasetService.get_process_rules(dataset_id)
document_process_rules = document.dataset_process_rule.to_dict() if document.dataset_process_rule else {}
data_source_info = document.data_source_detail_dict
response = {
"id": document.id,
"position": document.position,
"data_source_type": document.data_source_type,
"data_source_info": data_source_info,
"data_source_info": document.data_source_info_dict,
"data_source_detail_dict": document.data_source_detail_dict,
"dataset_process_rule_id": document.dataset_process_rule_id,
"dataset_process_rule": dataset_process_rules,
"document_process_rule": document_process_rules,
@@ -4,12 +4,11 @@ from typing import Any
from flask import make_response, redirect, request
from flask_restx import Resource, reqparse
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, Forbidden
from configs import dify_config
from constants import HIDDEN_VALUE, UNKNOWN_VALUE
from controllers.web.error import NotFoundError
from core.model_runtime.utils.encoders import jsonable_encoder
from core.plugin.entities.plugin_daemon import CredentialType
@@ -44,6 +43,12 @@ class TriggerSubscriptionUpdateRequest(BaseModel):
parameters: Mapping[str, Any] | None = Field(default=None, description="The parameters for the subscription")
properties: Mapping[str, Any] | None = Field(default=None, description="The properties for the subscription")
@model_validator(mode="after")
def check_at_least_one_field(self):
if all(v is None for v in (self.name, self.credentials, self.parameters, self.properties)):
raise ValueError("At least one of name, credentials, parameters, or properties must be provided")
return self
class TriggerSubscriptionVerifyRequest(BaseModel):
"""Request payload for verifying subscription credentials."""
@@ -333,7 +338,7 @@ class TriggerSubscriptionUpdateApi(Resource):
user = current_user
assert user.current_tenant_id is not None
args = TriggerSubscriptionUpdateRequest.model_validate(console_ns.payload)
request = TriggerSubscriptionUpdateRequest.model_validate(console_ns.payload)
subscription = TriggerProviderService.get_subscription_by_id(
tenant_id=user.current_tenant_id,
@@ -345,50 +350,32 @@ class TriggerSubscriptionUpdateApi(Resource):
provider_id = TriggerProviderID(subscription.provider_id)
try:
# rename only
if (
args.name is not None
and args.credentials is None
and args.parameters is None
and args.properties is None
):
# For rename only, just update the name
rename = request.name is not None and not any((request.credentials, request.parameters, request.properties))
# When credential type is UNAUTHORIZED, it indicates the subscription was manually created
# For Manually created subscription, they dont have credentials, parameters
# They only have name and properties(which is input by user)
manually_created = subscription.credential_type == CredentialType.UNAUTHORIZED
if rename or manually_created:
TriggerProviderService.update_trigger_subscription(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
name=args.name,
name=request.name,
properties=request.properties,
)
return 200
# rebuild for create automatically by the provider
match subscription.credential_type:
case CredentialType.UNAUTHORIZED:
TriggerProviderService.update_trigger_subscription(
tenant_id=user.current_tenant_id,
subscription_id=subscription_id,
name=args.name,
properties=args.properties,
)
return 200
case CredentialType.API_KEY | CredentialType.OAUTH2:
if args.credentials:
new_credentials: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else subscription.credentials.get(key, UNKNOWN_VALUE)
for key, value in args.credentials.items()
}
else:
new_credentials = subscription.credentials
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=user.current_tenant_id,
name=args.name,
provider_id=provider_id,
subscription_id=subscription_id,
credentials=new_credentials,
parameters=args.parameters or subscription.parameters,
)
return 200
case _:
raise BadRequest("Invalid credential type")
# For the rest cases(API_KEY, OAUTH2)
# we need to call third party provider(e.g. GitHub) to rebuild the subscription
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=user.current_tenant_id,
name=request.name,
provider_id=provider_id,
subscription_id=subscription_id,
credentials=request.credentials or subscription.credentials,
parameters=request.parameters or subscription.parameters,
)
return 200
except ValueError as e:
raise BadRequest(str(e))
except Exception as e:
@@ -75,7 +75,7 @@ class AnnotationReplyFeature:
AppAnnotationService.add_annotation_history(
annotation.id,
app_record.id,
annotation.question,
annotation.question_text,
annotation.content,
query,
user_id,
@@ -66,6 +66,7 @@ class PauseStatePersistenceLayer(GraphEngineLayer):
"""
if isinstance(session_factory, Engine):
session_factory = sessionmaker(session_factory)
super().__init__()
self._session_maker = session_factory
self._state_owner_user_id = state_owner_user_id
self._generate_entity = generate_entity
@@ -98,8 +99,6 @@ class PauseStatePersistenceLayer(GraphEngineLayer):
if not isinstance(event, GraphRunPausedEvent):
return
assert self.graph_runtime_state is not None
entity_wrapper: _GenerateEntityUnion
if isinstance(self._generate_entity, WorkflowAppGenerateEntity):
entity_wrapper = _WorkflowGenerateEntityWrapper(entity=self._generate_entity)
+1 -4
View File
@@ -33,6 +33,7 @@ class TriggerPostLayer(GraphEngineLayer):
trigger_log_id: str,
session_maker: sessionmaker[Session],
):
super().__init__()
self.trigger_log_id = trigger_log_id
self.start_time = start_time
self.cfs_plan_scheduler_entity = cfs_plan_scheduler_entity
@@ -57,10 +58,6 @@ class TriggerPostLayer(GraphEngineLayer):
elapsed_time = (datetime.now(UTC) - self.start_time).total_seconds()
# Extract relevant data from result
if not self.graph_runtime_state:
logger.exception("Graph runtime state is not set")
return
outputs = self.graph_runtime_state.outputs
# BASICLY, workflow_execution_id is the same as workflow_run_id
@@ -66,6 +66,8 @@ class WeaviateVector(BaseVector):
in a Weaviate collection.
"""
_DOCUMENT_ID_PROPERTY = "document_id"
def __init__(self, collection_name: str, config: WeaviateConfig, attributes: list):
"""
Initializes the Weaviate vector store.
@@ -353,15 +355,12 @@ class WeaviateVector(BaseVector):
return []
col = self._client.collections.use(self._collection_name)
props = list({*self._attributes, "document_id", Field.TEXT_KEY.value})
props = list({*self._attributes, self._DOCUMENT_ID_PROPERTY, Field.TEXT_KEY.value})
where = None
doc_ids = kwargs.get("document_ids_filter") or []
if doc_ids:
ors = [Filter.by_property("document_id").equal(x) for x in doc_ids]
where = ors[0]
for f in ors[1:]:
where = where | f
where = Filter.by_property(self._DOCUMENT_ID_PROPERTY).contains_any(doc_ids)
top_k = int(kwargs.get("top_k", 4))
score_threshold = float(kwargs.get("score_threshold") or 0.0)
@@ -408,10 +407,7 @@ class WeaviateVector(BaseVector):
where = None
doc_ids = kwargs.get("document_ids_filter") or []
if doc_ids:
ors = [Filter.by_property("document_id").equal(x) for x in doc_ids]
where = ors[0]
for f in ors[1:]:
where = where | f
where = Filter.by_property(self._DOCUMENT_ID_PROPERTY).contains_any(doc_ids)
top_k = int(kwargs.get("top_k", 4))
+31 -31
View File
@@ -1474,38 +1474,38 @@ class DatasetRetrieval:
if cancel_event and cancel_event.is_set():
break
# Skip second reranking when there is only one dataset
if reranking_enable and dataset_count > 1:
# do rerank for searched documents
data_post_processor = DataPostProcessor(tenant_id, reranking_mode, reranking_model, weights, False)
if query:
all_documents_item = data_post_processor.invoke(
query=query,
documents=all_documents_item,
score_threshold=score_threshold,
top_n=top_k,
query_type=QueryType.TEXT_QUERY,
)
if attachment_id:
all_documents_item = data_post_processor.invoke(
documents=all_documents_item,
score_threshold=score_threshold,
top_n=top_k,
query_type=QueryType.IMAGE_QUERY,
query=attachment_id,
)
else:
if index_type == IndexTechniqueType.ECONOMY:
if not query:
all_documents_item = []
else:
all_documents_item = self.calculate_keyword_score(query, all_documents_item, top_k)
elif index_type == IndexTechniqueType.HIGH_QUALITY:
all_documents_item = self.calculate_vector_score(all_documents_item, top_k, score_threshold)
# Skip second reranking when there is only one dataset
if reranking_enable and dataset_count > 1:
# do rerank for searched documents
data_post_processor = DataPostProcessor(tenant_id, reranking_mode, reranking_model, weights, False)
if query:
all_documents_item = data_post_processor.invoke(
query=query,
documents=all_documents_item,
score_threshold=score_threshold,
top_n=top_k,
query_type=QueryType.TEXT_QUERY,
)
if attachment_id:
all_documents_item = data_post_processor.invoke(
documents=all_documents_item,
score_threshold=score_threshold,
top_n=top_k,
query_type=QueryType.IMAGE_QUERY,
query=attachment_id,
)
else:
all_documents_item = all_documents_item[:top_k] if top_k else all_documents_item
if all_documents_item:
all_documents.extend(all_documents_item)
if index_type == IndexTechniqueType.ECONOMY:
if not query:
all_documents_item = []
else:
all_documents_item = self.calculate_keyword_score(query, all_documents_item, top_k)
elif index_type == IndexTechniqueType.HIGH_QUALITY:
all_documents_item = self.calculate_vector_score(all_documents_item, top_k, score_threshold)
else:
all_documents_item = all_documents_item[:top_k] if top_k else all_documents_item
if all_documents_item:
all_documents.extend(all_documents_item)
except Exception as e:
if cancel_event:
cancel_event.set()
+3
View File
@@ -64,6 +64,9 @@ engine.layer(DebugLoggingLayer(level="INFO"))
engine.layer(ExecutionLimitsLayer(max_nodes=100))
```
`engine.layer()` binds the read-only runtime state before execution, so layer hooks
can assume `graph_runtime_state` is available.
### Event-Driven Architecture
All node executions emit events for monitoring and integration:
@@ -9,7 +9,7 @@ Each instance uses a unique key for its command queue.
import json
from typing import TYPE_CHECKING, Any, final
from ..entities.commands import AbortCommand, CommandType, GraphEngineCommand, PauseCommand
from ..entities.commands import AbortCommand, CommandType, GraphEngineCommand, PauseCommand, UpdateVariablesCommand
if TYPE_CHECKING:
from extensions.ext_redis import RedisClientWrapper
@@ -113,6 +113,8 @@ class RedisChannel:
return AbortCommand.model_validate(data)
if command_type == CommandType.PAUSE:
return PauseCommand.model_validate(data)
if command_type == CommandType.UPDATE_VARIABLES:
return UpdateVariablesCommand.model_validate(data)
# For other command types, use base class
return GraphEngineCommand.model_validate(data)
@@ -5,11 +5,12 @@ This package handles external commands sent to the engine
during execution.
"""
from .command_handlers import AbortCommandHandler, PauseCommandHandler
from .command_handlers import AbortCommandHandler, PauseCommandHandler, UpdateVariablesCommandHandler
from .command_processor import CommandProcessor
__all__ = [
"AbortCommandHandler",
"CommandProcessor",
"PauseCommandHandler",
"UpdateVariablesCommandHandler",
]
@@ -4,9 +4,10 @@ from typing import final
from typing_extensions import override
from core.workflow.entities.pause_reason import SchedulingPause
from core.workflow.runtime import VariablePool
from ..domain.graph_execution import GraphExecution
from ..entities.commands import AbortCommand, GraphEngineCommand, PauseCommand
from ..entities.commands import AbortCommand, GraphEngineCommand, PauseCommand, UpdateVariablesCommand
from .command_processor import CommandHandler
logger = logging.getLogger(__name__)
@@ -31,3 +32,25 @@ class PauseCommandHandler(CommandHandler):
reason = command.reason
pause_reason = SchedulingPause(message=reason)
execution.pause(pause_reason)
@final
class UpdateVariablesCommandHandler(CommandHandler):
def __init__(self, variable_pool: VariablePool) -> None:
self._variable_pool = variable_pool
@override
def handle(self, command: GraphEngineCommand, execution: GraphExecution) -> None:
assert isinstance(command, UpdateVariablesCommand)
for update in command.updates:
try:
variable = update.value
self._variable_pool.add(variable.selector, variable)
logger.debug("Updated variable %s for workflow %s", variable.selector, execution.workflow_id)
except ValueError as exc:
logger.warning(
"Skipping invalid variable selector %s for workflow %s: %s",
getattr(update.value, "selector", None),
execution.workflow_id,
exc,
)
@@ -5,17 +5,21 @@ This module defines command types that can be sent to a running GraphEngine
instance to control its execution flow.
"""
from enum import StrEnum
from collections.abc import Sequence
from enum import StrEnum, auto
from typing import Any
from pydantic import BaseModel, Field
from core.variables.variables import VariableUnion
class CommandType(StrEnum):
"""Types of commands that can be sent to GraphEngine."""
ABORT = "abort"
PAUSE = "pause"
ABORT = auto()
PAUSE = auto()
UPDATE_VARIABLES = auto()
class GraphEngineCommand(BaseModel):
@@ -37,3 +41,16 @@ class PauseCommand(GraphEngineCommand):
command_type: CommandType = Field(default=CommandType.PAUSE, description="Type of command")
reason: str = Field(default="unknown reason", description="reason for pause")
class VariableUpdate(BaseModel):
"""Represents a single variable update instruction."""
value: VariableUnion = Field(description="New variable value")
class UpdateVariablesCommand(GraphEngineCommand):
"""Command to update a group of variables in the variable pool."""
command_type: CommandType = Field(default=CommandType.UPDATE_VARIABLES, description="Type of command")
updates: Sequence[VariableUpdate] = Field(default_factory=list, description="Variable updates")
+17 -9
View File
@@ -30,8 +30,13 @@ from core.workflow.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWr
if TYPE_CHECKING: # pragma: no cover - used only for static analysis
from core.workflow.runtime.graph_runtime_state import GraphProtocol
from .command_processing import AbortCommandHandler, CommandProcessor, PauseCommandHandler
from .entities.commands import AbortCommand, PauseCommand
from .command_processing import (
AbortCommandHandler,
CommandProcessor,
PauseCommandHandler,
UpdateVariablesCommandHandler,
)
from .entities.commands import AbortCommand, PauseCommand, UpdateVariablesCommand
from .error_handler import ErrorHandler
from .event_management import EventHandler, EventManager
from .graph_state_manager import GraphStateManager
@@ -140,6 +145,9 @@ class GraphEngine:
pause_handler = PauseCommandHandler()
self._command_processor.register_handler(PauseCommand, pause_handler)
update_variables_handler = UpdateVariablesCommandHandler(self._graph_runtime_state.variable_pool)
self._command_processor.register_handler(UpdateVariablesCommand, update_variables_handler)
# === Extensibility ===
# Layers allow plugins to extend engine functionality
self._layers: list[GraphEngineLayer] = []
@@ -212,9 +220,16 @@ class GraphEngine:
if id(node.graph_runtime_state) != expected_state_id:
raise ValueError(f"GraphRuntimeState consistency violation: Node '{node.id}' has a different instance")
def _bind_layer_context(
self,
layer: GraphEngineLayer,
) -> None:
layer.initialize(ReadOnlyGraphRuntimeStateWrapper(self._graph_runtime_state), self._command_channel)
def layer(self, layer: GraphEngineLayer) -> "GraphEngine":
"""Add a layer for extending functionality."""
self._layers.append(layer)
self._bind_layer_context(layer)
return self
def run(self) -> Generator[GraphEngineEvent, None, None]:
@@ -301,14 +316,7 @@ class GraphEngine:
def _initialize_layers(self) -> None:
"""Initialize layers with context."""
self._event_manager.set_layers(self._layers)
# Create a read-only wrapper for the runtime state
read_only_state = ReadOnlyGraphRuntimeStateWrapper(self._graph_runtime_state)
for layer in self._layers:
try:
layer.initialize(read_only_state, self._command_channel)
except Exception as e:
logger.warning("Failed to initialize layer %s: %s", layer.__class__.__name__, e)
try:
layer.on_graph_start()
except Exception as e:
@@ -8,7 +8,7 @@ Pluggable middleware for engine extensions.
Abstract base class for layers.
- `initialize()` - Receive runtime context
- `initialize()` - Receive runtime context (runtime state is bound here and always available to hooks)
- `on_graph_start()` - Execution start hook
- `on_event()` - Process all events
- `on_graph_end()` - Execution end hook
@@ -34,6 +34,9 @@ engine.layer(debug_layer)
engine.run()
```
`engine.layer()` binds the read-only runtime state before execution, so
`graph_runtime_state` is always available inside layer hooks.
## Custom Layers
```python
+19 -6
View File
@@ -13,6 +13,14 @@ from core.workflow.nodes.base.node import Node
from core.workflow.runtime import ReadOnlyGraphRuntimeState
class GraphEngineLayerNotInitializedError(Exception):
"""Raised when a layer's runtime state is accessed before initialization."""
def __init__(self, layer_name: str | None = None) -> None:
name = layer_name or "GraphEngineLayer"
super().__init__(f"{name} runtime state is not initialized. Bind the layer to a GraphEngine before access.")
class GraphEngineLayer(ABC):
"""
Abstract base class for GraphEngine layers.
@@ -28,22 +36,27 @@ class GraphEngineLayer(ABC):
def __init__(self) -> None:
"""Initialize the layer. Subclasses can override with custom parameters."""
self.graph_runtime_state: ReadOnlyGraphRuntimeState | None = None
self._graph_runtime_state: ReadOnlyGraphRuntimeState | None = None
self.command_channel: CommandChannel | None = None
@property
def graph_runtime_state(self) -> ReadOnlyGraphRuntimeState:
if self._graph_runtime_state is None:
raise GraphEngineLayerNotInitializedError(type(self).__name__)
return self._graph_runtime_state
def initialize(self, graph_runtime_state: ReadOnlyGraphRuntimeState, command_channel: CommandChannel) -> None:
"""
Initialize the layer with engine dependencies.
Called by GraphEngine before execution starts to inject the read-only runtime state
and command channel. This allows layers to observe engine context and send
commands, but prevents direct state modification.
Called by GraphEngine to inject the read-only runtime state and command channel.
This is invoked when the layer is registered with a `GraphEngine` instance.
Implementations should be idempotent.
Args:
graph_runtime_state: Read-only view of the runtime state
command_channel: Channel for sending commands to the engine
"""
self.graph_runtime_state = graph_runtime_state
self._graph_runtime_state = graph_runtime_state
self.command_channel = command_channel
@abstractmethod
@@ -109,10 +109,8 @@ class DebugLoggingLayer(GraphEngineLayer):
self.logger.info("=" * 80)
self.logger.info("🚀 GRAPH EXECUTION STARTED")
self.logger.info("=" * 80)
if self.graph_runtime_state:
# Log initial state
self.logger.info("Initial State:")
# Log initial state
self.logger.info("Initial State:")
@override
def on_event(self, event: GraphEngineEvent) -> None:
@@ -243,8 +241,7 @@ class DebugLoggingLayer(GraphEngineLayer):
self.logger.info(" Node retries: %s", self.retry_count)
# Log final state if available
if self.graph_runtime_state and self.include_outputs:
if self.graph_runtime_state.outputs:
self.logger.info("Final outputs: %s", self._format_dict(self.graph_runtime_state.outputs))
if self.include_outputs and self.graph_runtime_state.outputs:
self.logger.info("Final outputs: %s", self._format_dict(self.graph_runtime_state.outputs))
self.logger.info("=" * 80)
@@ -337,8 +337,6 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
if update_finished:
execution.finished_at = naive_utc_now()
runtime_state = self.graph_runtime_state
if runtime_state is None:
return
execution.total_tokens = runtime_state.total_tokens
execution.total_steps = runtime_state.node_run_steps
execution.outputs = execution.outputs or runtime_state.outputs
@@ -404,6 +402,4 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
def _system_variables(self) -> Mapping[str, Any]:
runtime_state = self.graph_runtime_state
if runtime_state is None:
return {}
return runtime_state.variable_pool.get_by_prefix(SYSTEM_VARIABLE_NODE_ID)
+18 -3
View File
@@ -3,14 +3,20 @@ GraphEngine Manager for sending control commands via Redis channel.
This module provides a simplified interface for controlling workflow executions
using the new Redis command channel, without requiring user permission checks.
Supports stop, pause, and resume operations.
"""
import logging
from collections.abc import Sequence
from typing import final
from core.workflow.graph_engine.command_channels.redis_channel import RedisChannel
from core.workflow.graph_engine.entities.commands import AbortCommand, GraphEngineCommand, PauseCommand
from core.workflow.graph_engine.entities.commands import (
AbortCommand,
GraphEngineCommand,
PauseCommand,
UpdateVariablesCommand,
VariableUpdate,
)
from extensions.ext_redis import redis_client
logger = logging.getLogger(__name__)
@@ -23,7 +29,6 @@ class GraphEngineManager:
This class provides a simple interface for controlling workflow executions
by sending commands through Redis channels, without user validation.
Supports stop and pause operations.
"""
@staticmethod
@@ -45,6 +50,16 @@ class GraphEngineManager:
pause_command = PauseCommand(reason=reason or "User requested pause")
GraphEngineManager._send_command(task_id, pause_command)
@staticmethod
def send_update_variables_command(task_id: str, updates: Sequence[VariableUpdate]) -> None:
"""Send a command to update variables in a running workflow."""
if not updates:
return
update_command = UpdateVariablesCommand(updates=updates)
GraphEngineManager._send_command(task_id, update_command)
@staticmethod
def _send_command(task_id: str, command: GraphEngineCommand) -> None:
"""Send a command to the workflow-specific Redis channel."""
+16
View File
@@ -11,6 +11,11 @@ from core.workflow.graph import NodeFactory
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.code.code_node import CodeNode
from core.workflow.nodes.code.limits import CodeNodeLimits
from core.workflow.nodes.template_transform.template_renderer import (
CodeExecutorJinja2TemplateRenderer,
Jinja2TemplateRenderer,
)
from core.workflow.nodes.template_transform.template_transform_node import TemplateTransformNode
from libs.typing import is_str, is_str_dict
from .node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
@@ -37,6 +42,7 @@ class DifyNodeFactory(NodeFactory):
code_executor: type[CodeExecutor] | None = None,
code_providers: Sequence[type[CodeNodeProvider]] | None = None,
code_limits: CodeNodeLimits | None = None,
template_renderer: Jinja2TemplateRenderer | None = None,
) -> None:
self.graph_init_params = graph_init_params
self.graph_runtime_state = graph_runtime_state
@@ -54,6 +60,7 @@ class DifyNodeFactory(NodeFactory):
max_string_array_length=dify_config.CODE_MAX_STRING_ARRAY_LENGTH,
max_object_array_length=dify_config.CODE_MAX_OBJECT_ARRAY_LENGTH,
)
self._template_renderer = template_renderer or CodeExecutorJinja2TemplateRenderer()
@override
def create_node(self, node_config: dict[str, object]) -> Node:
@@ -107,6 +114,15 @@ class DifyNodeFactory(NodeFactory):
code_limits=self._code_limits,
)
if node_type == NodeType.TEMPLATE_TRANSFORM:
return TemplateTransformNode(
id=node_id,
config=node_config,
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
template_renderer=self._template_renderer,
)
return node_class(
id=node_id,
config=node_config,
@@ -0,0 +1,40 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Protocol
from core.helper.code_executor.code_executor import CodeExecutionError, CodeExecutor, CodeLanguage
class TemplateRenderError(ValueError):
"""Raised when rendering a Jinja2 template fails."""
class Jinja2TemplateRenderer(Protocol):
"""Render Jinja2 templates for template transform nodes."""
def render_template(self, template: str, variables: Mapping[str, Any]) -> str:
"""Render a Jinja2 template with provided variables."""
raise NotImplementedError
class CodeExecutorJinja2TemplateRenderer(Jinja2TemplateRenderer):
"""Adapter that renders Jinja2 templates via CodeExecutor."""
_code_executor: type[CodeExecutor]
def __init__(self, code_executor: type[CodeExecutor] | None = None) -> None:
self._code_executor = code_executor or CodeExecutor
def render_template(self, template: str, variables: Mapping[str, Any]) -> str:
try:
result = self._code_executor.execute_workflow_code_template(
language=CodeLanguage.JINJA2, code=template, inputs=variables
)
except CodeExecutionError as exc:
raise TemplateRenderError(str(exc)) from exc
rendered = result.get("result")
if not isinstance(rendered, str):
raise TemplateRenderError("Template render result must be a string.")
return rendered
@@ -1,18 +1,44 @@
from collections.abc import Mapping, Sequence
from typing import Any
from typing import TYPE_CHECKING, Any
from configs import dify_config
from core.helper.code_executor.code_executor import CodeExecutionError, CodeExecutor, CodeLanguage
from core.workflow.enums import NodeType, WorkflowNodeExecutionStatus
from core.workflow.node_events import NodeRunResult
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.template_transform.entities import TemplateTransformNodeData
from core.workflow.nodes.template_transform.template_renderer import (
CodeExecutorJinja2TemplateRenderer,
Jinja2TemplateRenderer,
TemplateRenderError,
)
if TYPE_CHECKING:
from core.workflow.entities import GraphInitParams
from core.workflow.runtime import GraphRuntimeState
MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH = dify_config.TEMPLATE_TRANSFORM_MAX_LENGTH
class TemplateTransformNode(Node[TemplateTransformNodeData]):
node_type = NodeType.TEMPLATE_TRANSFORM
_template_renderer: Jinja2TemplateRenderer
def __init__(
self,
id: str,
config: Mapping[str, Any],
graph_init_params: "GraphInitParams",
graph_runtime_state: "GraphRuntimeState",
*,
template_renderer: Jinja2TemplateRenderer | None = None,
) -> None:
super().__init__(
id=id,
config=config,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
)
self._template_renderer = template_renderer or CodeExecutorJinja2TemplateRenderer()
@classmethod
def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]:
@@ -39,13 +65,11 @@ class TemplateTransformNode(Node[TemplateTransformNodeData]):
variables[variable_name] = value.to_object() if value else None
# Run code
try:
result = CodeExecutor.execute_workflow_code_template(
language=CodeLanguage.JINJA2, code=self.node_data.template, inputs=variables
)
except CodeExecutionError as e:
rendered = self._template_renderer.render_template(self.node_data.template, variables)
except TemplateRenderError as e:
return NodeRunResult(inputs=variables, status=WorkflowNodeExecutionStatus.FAILED, error=str(e))
if len(result["result"]) > MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH:
if len(rendered) > MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH:
return NodeRunResult(
inputs=variables,
status=WorkflowNodeExecutionStatus.FAILED,
@@ -53,7 +77,7 @@ class TemplateTransformNode(Node[TemplateTransformNodeData]):
)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=variables, outputs={"output": result["result"]}
status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=variables, outputs={"output": rendered}
)
@classmethod
+2
View File
@@ -11,6 +11,7 @@ def init_app(app: DifyApp):
create_tenant,
extract_plugins,
extract_unique_plugins,
file_usage,
fix_app_site_missing,
install_plugins,
install_rag_pipeline_plugins,
@@ -47,6 +48,7 @@ def init_app(app: DifyApp):
clear_free_plan_tenant_expired_logs,
clear_orphaned_file_records,
remove_orphaned_files_on_storage,
file_usage,
setup_system_tool_oauth_client,
setup_system_trigger_oauth_client,
cleanup_orphaned_draft_variables,
+7
View File
@@ -53,3 +53,10 @@ def _setup_gevent_compatibility():
def init_app(app: DifyApp):
db.init_app(app)
_setup_gevent_compatibility()
# Eagerly build the engine so pool_size/max_overflow/etc. come from config
try:
with app.app_context():
_ = db.engine # triggers engine creation with the configured options
except Exception:
logger.exception("Failed to initialize SQLAlchemy engine during app startup")
@@ -22,6 +22,18 @@ from models.enums import WorkflowRunTriggeredFrom
logger = logging.getLogger(__name__)
def to_serializable(obj):
"""
Convert non-JSON-serializable objects into JSON-compatible formats.
- Uses `to_dict()` if it's a callable method.
- Falls back to string representation.
"""
if hasattr(obj, "to_dict") and callable(obj.to_dict):
return obj.to_dict()
return str(obj)
class LogstoreWorkflowExecutionRepository(WorkflowExecutionRepository):
def __init__(
self,
@@ -69,6 +81,11 @@ class LogstoreWorkflowExecutionRepository(WorkflowExecutionRepository):
# Set to True to enable dual-write for safe migration, False to use LogStore only
self._enable_dual_write = os.environ.get("LOGSTORE_DUAL_WRITE_ENABLED", "true").lower() == "true"
# Control flag for whether to write the `graph` field to LogStore.
# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field;
# otherwise write an empty {} instead. Defaults to writing the `graph` field.
self._enable_put_graph_field = os.environ.get("LOGSTORE_ENABLE_PUT_GRAPH_FIELD", "true").lower() == "true"
def _to_logstore_model(self, domain_model: WorkflowExecution) -> list[tuple[str, str]]:
"""
Convert a domain model to a logstore model (List[Tuple[str, str]]).
@@ -108,9 +125,24 @@ class LogstoreWorkflowExecutionRepository(WorkflowExecutionRepository):
),
("type", domain_model.workflow_type.value),
("version", domain_model.workflow_version),
("graph", json.dumps(domain_model.graph, ensure_ascii=False) if domain_model.graph else "{}"),
("inputs", json.dumps(domain_model.inputs, ensure_ascii=False) if domain_model.inputs else "{}"),
("outputs", json.dumps(domain_model.outputs, ensure_ascii=False) if domain_model.outputs else "{}"),
(
"graph",
json.dumps(domain_model.graph, ensure_ascii=False, default=to_serializable)
if domain_model.graph and self._enable_put_graph_field
else "{}",
),
(
"inputs",
json.dumps(domain_model.inputs, ensure_ascii=False, default=to_serializable)
if domain_model.inputs
else "{}",
),
(
"outputs",
json.dumps(domain_model.outputs, ensure_ascii=False, default=to_serializable)
if domain_model.outputs
else "{}",
),
("status", domain_model.status.value),
("error_message", domain_model.error_message or ""),
("total_tokens", str(domain_model.total_tokens)),
+10 -5
View File
@@ -1419,15 +1419,20 @@ class MessageAnnotation(Base):
app_id: Mapped[str] = mapped_column(StringUUID)
conversation_id: Mapped[str | None] = mapped_column(StringUUID, sa.ForeignKey("conversations.id"))
message_id: Mapped[str | None] = mapped_column(StringUUID)
question = mapped_column(LongText, nullable=True)
content = mapped_column(LongText, nullable=False)
question: Mapped[str | None] = mapped_column(LongText, nullable=True)
content: Mapped[str] = mapped_column(LongText, nullable=False)
hit_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0"))
account_id = mapped_column(StringUUID, nullable=False)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = mapped_column(
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
)
@property
def question_text(self) -> str:
"""Return a non-null question string, falling back to the answer content."""
return self.question or self.content
@property
def account(self):
account = db.session.query(Account).where(Account.id == self.account_id).first()
+2 -2
View File
@@ -77,7 +77,7 @@ class AppAnnotationService:
if annotation_setting:
add_annotation_to_index_task.delay(
annotation.id,
annotation.question,
question,
current_tenant_id,
app_id,
annotation_setting.collection_binding_id,
@@ -253,7 +253,7 @@ class AppAnnotationService:
if app_annotation_setting:
update_annotation_to_index_task.delay(
annotation.id,
annotation.question,
annotation.question_text,
current_tenant_id,
app_id,
app_annotation_setting.collection_binding_id,
+45 -106
View File
@@ -853,7 +853,7 @@ class TriggerProviderService:
"""
Create a subscription builder for rebuilding an existing subscription.
This method creates a builder pre-filled with data from the rebuild request,
This method rebuild the subscription by call DELETE and CREATE API of the third party provider(e.g. GitHub)
keeping the same subscription_id and endpoint_id so the webhook URL remains unchanged.
:param tenant_id: Tenant ID
@@ -868,111 +868,50 @@ class TriggerProviderService:
if not provider_controller:
raise ValueError(f"Provider {provider_id} not found")
# Use distributed lock to prevent race conditions on the same subscription
lock_key = f"trigger_subscription_rebuild_lock:{tenant_id}_{subscription_id}"
with redis_client.lock(lock_key, timeout=20):
with Session(db.engine, expire_on_commit=False) as session:
try:
# Get subscription within the transaction
subscription: TriggerSubscription | None = (
session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first()
)
if not subscription:
raise ValueError(f"Subscription {subscription_id} not found")
subscription = TriggerProviderService.get_subscription_by_id(
tenant_id=tenant_id,
subscription_id=subscription_id,
)
if not subscription:
raise ValueError(f"Subscription {subscription_id} not found")
credential_type = CredentialType.of(subscription.credential_type)
if credential_type not in [CredentialType.OAUTH2, CredentialType.API_KEY]:
raise ValueError("Credential type not supported for rebuild")
credential_type = CredentialType.of(subscription.credential_type)
if credential_type not in {CredentialType.OAUTH2, CredentialType.API_KEY}:
raise ValueError(f"Credential type {credential_type} not supported for auto creation")
# Decrypt existing credentials for merging
credential_encrypter, _ = create_trigger_provider_encrypter_for_subscription(
tenant_id=tenant_id,
controller=provider_controller,
subscription=subscription,
)
decrypted_credentials = dict(credential_encrypter.decrypt(subscription.credentials))
# Delete the previous subscription
user_id = subscription.user_id
unsubscribe_result = TriggerManager.unsubscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
subscription=subscription.to_entity(),
credentials=subscription.credentials,
credential_type=credential_type,
)
if not unsubscribe_result.success:
raise ValueError(f"Failed to delete previous subscription: {unsubscribe_result.message}")
# Merge credentials: if caller passed HIDDEN_VALUE, retain existing decrypted value
merged_credentials: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else decrypted_credentials.get(key, UNKNOWN_VALUE)
for key, value in credentials.items()
}
user_id = subscription.user_id
# TODO: Trying to invoke update api of the plugin trigger provider
# FALLBACK: If the update api is not implemented,
# delete the previous subscription and create a new one
# Unsubscribe the previous subscription (external call, but we'll handle errors)
try:
TriggerManager.unsubscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
subscription=subscription.to_entity(),
credentials=decrypted_credentials,
credential_type=credential_type,
)
except Exception as e:
logger.exception("Error unsubscribing trigger during rebuild", exc_info=e)
# Continue anyway - the subscription might already be deleted externally
# Create a new subscription with the same subscription_id and endpoint_id (external call)
new_subscription: TriggerSubscriptionEntity = TriggerManager.subscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
endpoint=generate_plugin_trigger_endpoint_url(subscription.endpoint_id),
parameters=parameters,
credentials=merged_credentials,
credential_type=credential_type,
)
# Update the subscription in the same transaction
# Inline update logic to reuse the same session
if name is not None and name != subscription.name:
existing = (
session.query(TriggerSubscription)
.filter_by(tenant_id=tenant_id, provider_id=str(provider_id), name=name)
.first()
)
if existing and existing.id != subscription.id:
raise ValueError(f"Subscription name '{name}' already exists for this provider")
subscription.name = name
# Update parameters
subscription.parameters = dict(parameters)
# Update credentials with merged (and encrypted) values
subscription.credentials = dict(credential_encrypter.encrypt(merged_credentials))
# Update properties
if new_subscription.properties:
properties_encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=provider_controller.get_properties_schema(),
cache=NoOpProviderCredentialCache(),
)
subscription.properties = dict(properties_encrypter.encrypt(dict(new_subscription.properties)))
# Update expiration timestamp
if new_subscription.expires_at is not None:
subscription.expires_at = new_subscription.expires_at
# Commit the transaction
session.commit()
# Clear subscription cache
delete_cache_for_subscription(
tenant_id=tenant_id,
provider_id=subscription.provider_id,
subscription_id=subscription.id,
)
except Exception as e:
# Rollback on any error
session.rollback()
logger.exception("Failed to rebuild trigger subscription", exc_info=e)
raise
# Create a new subscription with the same subscription_id and endpoint_id
new_credentials: dict[str, Any] = {
key: value if value != HIDDEN_VALUE else subscription.credentials.get(key, UNKNOWN_VALUE)
for key, value in credentials.items()
}
new_subscription: TriggerSubscriptionEntity = TriggerManager.subscribe_trigger(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
endpoint=generate_plugin_trigger_endpoint_url(subscription.endpoint_id),
parameters=parameters,
credentials=new_credentials,
credential_type=credential_type,
)
TriggerProviderService.update_trigger_subscription(
tenant_id=tenant_id,
subscription_id=subscription.id,
name=name,
parameters=parameters,
credentials=new_credentials,
properties=new_subscription.properties,
expires_at=new_subscription.expires_at,
)
@@ -98,7 +98,7 @@ def enable_annotation_reply_task(
if annotations:
for annotation in annotations:
document = Document(
page_content=annotation.question,
page_content=annotation.question_text,
metadata={"annotation_id": annotation.id, "app_id": app_id, "doc_id": annotation.id},
)
documents.append(document)
@@ -35,6 +35,7 @@ from core.model_runtime.entities.llm_entities import LLMUsage
from core.workflow.entities.pause_reason import SchedulingPause
from core.workflow.enums import WorkflowExecutionStatus
from core.workflow.graph_engine.entities.commands import GraphEngineCommand
from core.workflow.graph_engine.layers.base import GraphEngineLayerNotInitializedError
from core.workflow.graph_events.graph import GraphRunPausedEvent
from core.workflow.runtime.graph_runtime_state import GraphRuntimeState
from core.workflow.runtime.graph_runtime_state_protocol import ReadOnlyGraphRuntimeState
@@ -569,10 +570,10 @@ class TestPauseStatePersistenceLayerTestContainers:
"""Test that layer requires proper initialization before handling events."""
# Arrange
layer = self._create_pause_state_persistence_layer()
# Don't initialize - graph_runtime_state should not be set
# Don't initialize - graph_runtime_state should be uninitialized
event = GraphRunPausedEvent(reasons=[SchedulingPause(message="test pause")])
# Act & Assert - Should raise AttributeError
with pytest.raises(AttributeError):
# Act & Assert - Should raise GraphEngineLayerNotInitializedError
with pytest.raises(GraphEngineLayerNotInitializedError):
layer.on_event(event)
@@ -474,64 +474,6 @@ class TestTriggerProviderService:
assert subscription.name == original_name
assert subscription.parameters == original_parameters
def test_rebuild_trigger_subscription_unsubscribe_error_continues(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test that unsubscribe errors are handled gracefully and operation continues.
This test verifies:
- Unsubscribe errors are caught and logged but don't stop the rebuild
- Rebuild continues even if unsubscribe fails
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
provider_id = TriggerProviderID("test_org/test_plugin/test_provider")
credential_type = CredentialType.API_KEY
original_credentials = {"api_key": "original-key"}
subscription = self._create_test_subscription(
db_session_with_containers,
tenant.id,
account.id,
provider_id,
credential_type,
original_credentials,
mock_external_service_dependencies,
)
# Make unsubscribe_trigger raise an error (should be caught and continue)
mock_external_service_dependencies["trigger_manager"].unsubscribe_trigger.side_effect = ValueError(
"Unsubscribe failed"
)
new_subscription_entity = TriggerSubscriptionEntity(
endpoint=subscription.endpoint_id,
parameters={},
properties={},
expires_at=-1,
)
mock_external_service_dependencies["trigger_manager"].subscribe_trigger.return_value = new_subscription_entity
# Execute rebuild - should succeed despite unsubscribe error
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=tenant.id,
provider_id=provider_id,
subscription_id=subscription.id,
credentials={"api_key": "new-key"},
parameters={},
)
# Verify subscribe was still called (operation continued)
mock_external_service_dependencies["trigger_manager"].subscribe_trigger.assert_called_once()
# Verify subscription was updated
db.session.refresh(subscription)
assert subscription.parameters == {}
def test_rebuild_trigger_subscription_subscription_not_found(
self, db_session_with_containers, mock_external_service_dependencies
):
@@ -558,70 +500,6 @@ class TestTriggerProviderService:
parameters={},
)
def test_rebuild_trigger_subscription_provider_not_found(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test error when provider is not found.
This test verifies:
- Proper error is raised when provider doesn't exist
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
provider_id = TriggerProviderID("non_existent_org/non_existent_plugin/non_existent_provider")
# Make get_trigger_provider return None
mock_external_service_dependencies["trigger_manager"].get_trigger_provider.return_value = None
with pytest.raises(ValueError, match="Provider.*not found"):
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=tenant.id,
provider_id=provider_id,
subscription_id=fake.uuid4(),
credentials={},
parameters={},
)
def test_rebuild_trigger_subscription_unsupported_credential_type(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Test error when credential type is not supported for rebuild.
This test verifies:
- Proper error is raised for unsupported credential types (not OAUTH2 or API_KEY)
"""
fake = Faker()
account, tenant = self._create_test_account_and_tenant(
db_session_with_containers, mock_external_service_dependencies
)
provider_id = TriggerProviderID("test_org/test_plugin/test_provider")
credential_type = CredentialType.UNAUTHORIZED # Not supported
subscription = self._create_test_subscription(
db_session_with_containers,
tenant.id,
account.id,
provider_id,
credential_type,
{},
mock_external_service_dependencies,
)
with pytest.raises(ValueError, match="Credential type not supported for rebuild"):
TriggerProviderService.rebuild_trigger_subscription(
tenant_id=tenant.id,
provider_id=provider_id,
subscription_id=subscription.id,
credentials={},
parameters={},
)
def test_rebuild_trigger_subscription_name_uniqueness_check(
self, db_session_with_containers, mock_external_service_dependencies
):
@@ -0,0 +1,145 @@
"""
Test for document detail API data_source_info serialization fix.
This test verifies that the document detail API returns both data_source_info
and data_source_detail_dict for all data_source_type values, including "local_file".
"""
import json
from typing import Generic, Literal, NotRequired, TypedDict, TypeVar, Union
from models.dataset import Document
class LocalFileInfo(TypedDict):
file_path: str
size: int
created_at: NotRequired[str]
class UploadFileInfo(TypedDict):
upload_file_id: str
class NotionImportInfo(TypedDict):
notion_page_id: str
workspace_id: str
class WebsiteCrawlInfo(TypedDict):
url: str
job_id: str
RawInfo = Union[LocalFileInfo, UploadFileInfo, NotionImportInfo, WebsiteCrawlInfo]
T_type = TypeVar("T_type", bound=str)
T_info = TypeVar("T_info", bound=Union[LocalFileInfo, UploadFileInfo, NotionImportInfo, WebsiteCrawlInfo])
class Case(TypedDict, Generic[T_type, T_info]):
data_source_type: T_type
data_source_info: str
expected_raw: T_info
LocalFileCase = Case[Literal["local_file"], LocalFileInfo]
UploadFileCase = Case[Literal["upload_file"], UploadFileInfo]
NotionImportCase = Case[Literal["notion_import"], NotionImportInfo]
WebsiteCrawlCase = Case[Literal["website_crawl"], WebsiteCrawlInfo]
AnyCase = Union[LocalFileCase, UploadFileCase, NotionImportCase, WebsiteCrawlCase]
case_1: LocalFileCase = {
"data_source_type": "local_file",
"data_source_info": json.dumps({"file_path": "/tmp/test.txt", "size": 1024}),
"expected_raw": {"file_path": "/tmp/test.txt", "size": 1024},
}
# ERROR: Expected LocalFileInfo, but got WebsiteCrawlInfo
case_2: LocalFileCase = {
"data_source_type": "local_file",
"data_source_info": "...",
"expected_raw": {"file_path": "https://google.com", "size": 123},
}
cases: list[AnyCase] = [case_1]
class TestDocumentDetailDataSourceInfo:
"""Test cases for document detail API data_source_info serialization."""
def test_data_source_info_dict_returns_raw_data(self):
"""Test that data_source_info_dict returns raw JSON data for all data_source_type values."""
# Test data for different data_source_type values
for case in cases:
document = Document(
data_source_type=case["data_source_type"],
data_source_info=case["data_source_info"],
)
# Test data_source_info_dict (raw data)
raw_result = document.data_source_info_dict
assert raw_result == case["expected_raw"], f"Failed for {case['data_source_type']}"
# Verify raw_result is always a valid dict
assert isinstance(raw_result, dict)
def test_local_file_data_source_info_without_db_context(self):
"""Test that local_file type data_source_info_dict works without database context."""
test_data: LocalFileInfo = {
"file_path": "/local/path/document.txt",
"size": 512,
"created_at": "2024-01-01T00:00:00Z",
}
document = Document(
data_source_type="local_file",
data_source_info=json.dumps(test_data),
)
# data_source_info_dict should return the raw data (this doesn't need DB context)
raw_data = document.data_source_info_dict
assert raw_data == test_data
assert isinstance(raw_data, dict)
# Verify the data contains expected keys for pipeline mode
assert "file_path" in raw_data
assert "size" in raw_data
def test_notion_and_website_crawl_data_source_detail(self):
"""Test that notion_import and website_crawl return raw data in data_source_detail_dict."""
# Test notion_import
notion_data: NotionImportInfo = {"notion_page_id": "page-123", "workspace_id": "ws-456"}
document = Document(
data_source_type="notion_import",
data_source_info=json.dumps(notion_data),
)
# data_source_detail_dict should return raw data for notion_import
detail_result = document.data_source_detail_dict
assert detail_result == notion_data
# Test website_crawl
website_data: WebsiteCrawlInfo = {"url": "https://example.com", "job_id": "job-789"}
document = Document(
data_source_type="website_crawl",
data_source_info=json.dumps(website_data),
)
# data_source_detail_dict should return raw data for website_crawl
detail_result = document.data_source_detail_dict
assert detail_result == website_data
def test_local_file_data_source_detail_dict_without_db(self):
"""Test that local_file returns empty data_source_detail_dict (this doesn't need DB context)."""
# Test local_file - this should work without database context since it returns {} early
document = Document(
data_source_type="local_file",
data_source_info=json.dumps({"file_path": "/tmp/test.txt"}),
)
# Should return empty dict for local_file type (handled in the model)
detail_result = document.data_source_detail_dict
assert detail_result == {}
@@ -15,6 +15,7 @@ from core.app.layers.pause_state_persist_layer import (
from core.variables.segments import Segment
from core.workflow.entities.pause_reason import SchedulingPause
from core.workflow.graph_engine.entities.commands import GraphEngineCommand
from core.workflow.graph_engine.layers.base import GraphEngineLayerNotInitializedError
from core.workflow.graph_events.graph import (
GraphRunFailedEvent,
GraphRunPausedEvent,
@@ -209,8 +210,9 @@ class TestPauseStatePersistenceLayer:
assert layer._session_maker is session_factory
assert layer._state_owner_user_id == state_owner_user_id
assert not hasattr(layer, "graph_runtime_state")
assert not hasattr(layer, "command_channel")
with pytest.raises(GraphEngineLayerNotInitializedError):
_ = layer.graph_runtime_state
assert layer.command_channel is None
def test_initialize_sets_dependencies(self):
session_factory = Mock(name="session_factory")
@@ -295,7 +297,7 @@ class TestPauseStatePersistenceLayer:
mock_factory.assert_not_called()
mock_repo.create_workflow_pause.assert_not_called()
def test_on_event_raises_attribute_error_when_graph_runtime_state_is_none(self):
def test_on_event_raises_when_graph_runtime_state_is_uninitialized(self):
session_factory = Mock(name="session_factory")
layer = PauseStatePersistenceLayer(
session_factory=session_factory,
@@ -305,7 +307,7 @@ class TestPauseStatePersistenceLayer:
event = TestDataFactory.create_graph_run_paused_event()
with pytest.raises(AttributeError):
with pytest.raises(GraphEngineLayerNotInitializedError):
layer.on_event(event)
def test_on_event_asserts_when_workflow_execution_id_missing(self, monkeypatch: pytest.MonkeyPatch):
@@ -0,0 +1,113 @@
import threading
from unittest.mock import Mock, patch
from uuid import uuid4
import pytest
from flask import Flask, current_app
from core.rag.models.document import Document
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
from models.dataset import Dataset
class TestRetrievalService:
@pytest.fixture
def mock_dataset(self) -> Dataset:
dataset = Mock(spec=Dataset)
dataset.id = str(uuid4())
dataset.tenant_id = str(uuid4())
dataset.name = "test_dataset"
dataset.indexing_technique = "high_quality"
dataset.provider = "dify"
return dataset
def test_multiple_retrieve_reranking_with_app_context(self, mock_dataset):
"""
Repro test for current bug:
reranking runs after `with flask_app.app_context():` exits.
`_multiple_retrieve_thread` catches exceptions and stores them into `thread_exceptions`,
so we must assert from that list (not from an outer try/except).
"""
dataset_retrieval = DatasetRetrieval()
flask_app = Flask(__name__)
tenant_id = str(uuid4())
# second dataset to ensure dataset_count > 1 reranking branch
secondary_dataset = Mock(spec=Dataset)
secondary_dataset.id = str(uuid4())
secondary_dataset.provider = "dify"
secondary_dataset.indexing_technique = "high_quality"
# retriever returns 1 doc into internal list (all_documents_item)
document = Document(
page_content="Context aware doc",
metadata={
"doc_id": "doc1",
"score": 0.95,
"document_id": str(uuid4()),
"dataset_id": mock_dataset.id,
},
provider="dify",
)
def fake_retriever(
flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids
):
all_documents.append(document)
called = {"init": 0, "invoke": 0}
class ContextRequiredPostProcessor:
def __init__(self, *args, **kwargs):
called["init"] += 1
# will raise RuntimeError if no Flask app context exists
_ = current_app.name
def invoke(self, *args, **kwargs):
called["invoke"] += 1
_ = current_app.name
return kwargs.get("documents") or args[1]
# output list from _multiple_retrieve_thread
all_documents: list[Document] = []
# IMPORTANT: _multiple_retrieve_thread swallows exceptions and appends them here
thread_exceptions: list[Exception] = []
def target():
with patch.object(dataset_retrieval, "_retriever", side_effect=fake_retriever):
with patch(
"core.rag.retrieval.dataset_retrieval.DataPostProcessor",
ContextRequiredPostProcessor,
):
dataset_retrieval._multiple_retrieve_thread(
flask_app=flask_app,
available_datasets=[mock_dataset, secondary_dataset],
metadata_condition=None,
metadata_filter_document_ids=None,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=True,
reranking_mode="reranking_model",
reranking_model={
"reranking_provider_name": "cohere",
"reranking_model_name": "rerank-v2",
},
weights=None,
top_k=3,
score_threshold=0.0,
query="test query",
attachment_id=None,
dataset_count=2, # force reranking branch
thread_exceptions=thread_exceptions, # ✅ key
)
t = threading.Thread(target=target)
t.start()
t.join()
# Ensure reranking branch was actually executed
assert called["init"] >= 1, "DataPostProcessor was never constructed; reranking branch may not have run."
# Current buggy code should record an exception (not raise it)
assert not thread_exceptions, thread_exceptions
@@ -3,8 +3,15 @@
import json
from unittest.mock import MagicMock
from core.variables import IntegerVariable, StringVariable
from core.workflow.graph_engine.command_channels.redis_channel import RedisChannel
from core.workflow.graph_engine.entities.commands import AbortCommand, CommandType, GraphEngineCommand
from core.workflow.graph_engine.entities.commands import (
AbortCommand,
CommandType,
GraphEngineCommand,
UpdateVariablesCommand,
VariableUpdate,
)
class TestRedisChannel:
@@ -148,6 +155,43 @@ class TestRedisChannel:
assert commands[0].command_type == CommandType.ABORT
assert isinstance(commands[1], AbortCommand)
def test_fetch_commands_with_update_variables_command(self):
"""Test fetching update variables command from Redis."""
mock_redis = MagicMock()
pending_pipe = MagicMock()
fetch_pipe = MagicMock()
pending_context = MagicMock()
fetch_context = MagicMock()
pending_context.__enter__.return_value = pending_pipe
pending_context.__exit__.return_value = None
fetch_context.__enter__.return_value = fetch_pipe
fetch_context.__exit__.return_value = None
mock_redis.pipeline.side_effect = [pending_context, fetch_context]
update_command = UpdateVariablesCommand(
updates=[
VariableUpdate(
value=StringVariable(name="foo", value="bar", selector=["node1", "foo"]),
),
VariableUpdate(
value=IntegerVariable(name="baz", value=123, selector=["node2", "baz"]),
),
]
)
command_json = json.dumps(update_command.model_dump())
pending_pipe.execute.return_value = [b"1", 1]
fetch_pipe.execute.return_value = [[command_json.encode()], 1]
channel = RedisChannel(mock_redis, "test:key")
commands = channel.fetch_commands()
assert len(commands) == 1
assert isinstance(commands[0], UpdateVariablesCommand)
assert isinstance(commands[0].updates[0].value, StringVariable)
assert list(commands[0].updates[0].value.selector) == ["node1", "foo"]
assert commands[0].updates[0].value.value == "bar"
def test_fetch_commands_skips_invalid_json(self):
"""Test that invalid JSON commands are skipped."""
mock_redis = MagicMock()
@@ -0,0 +1,56 @@
from __future__ import annotations
import pytest
from core.workflow.graph_engine import GraphEngine
from core.workflow.graph_engine.command_channels import InMemoryChannel
from core.workflow.graph_engine.layers.base import (
GraphEngineLayer,
GraphEngineLayerNotInitializedError,
)
from core.workflow.graph_events import GraphEngineEvent
from ..test_table_runner import WorkflowRunner
class LayerForTest(GraphEngineLayer):
def on_graph_start(self) -> None:
pass
def on_event(self, event: GraphEngineEvent) -> None:
pass
def on_graph_end(self, error: Exception | None) -> None:
pass
def test_layer_runtime_state_raises_when_uninitialized() -> None:
layer = LayerForTest()
with pytest.raises(GraphEngineLayerNotInitializedError):
_ = layer.graph_runtime_state
def test_layer_runtime_state_available_after_engine_layer() -> None:
runner = WorkflowRunner()
fixture_data = runner.load_fixture("simple_passthrough_workflow")
graph, graph_runtime_state = runner.create_graph_from_fixture(
fixture_data,
inputs={"query": "test layer state"},
)
engine = GraphEngine(
workflow_id="test_workflow",
graph=graph,
graph_runtime_state=graph_runtime_state,
command_channel=InMemoryChannel(),
)
layer = LayerForTest()
engine.layer(layer)
outputs = layer.graph_runtime_state.outputs
ready_queue_size = layer.graph_runtime_state.ready_queue_size
assert outputs == {}
assert isinstance(ready_queue_size, int)
assert ready_queue_size >= 0
@@ -4,12 +4,19 @@ import time
from unittest.mock import MagicMock
from core.app.entities.app_invoke_entities import InvokeFrom
from core.variables import IntegerVariable, StringVariable
from core.workflow.entities.graph_init_params import GraphInitParams
from core.workflow.entities.pause_reason import SchedulingPause
from core.workflow.graph import Graph
from core.workflow.graph_engine import GraphEngine
from core.workflow.graph_engine.command_channels import InMemoryChannel
from core.workflow.graph_engine.entities.commands import AbortCommand, CommandType, PauseCommand
from core.workflow.graph_engine.entities.commands import (
AbortCommand,
CommandType,
PauseCommand,
UpdateVariablesCommand,
VariableUpdate,
)
from core.workflow.graph_events import GraphRunAbortedEvent, GraphRunPausedEvent, GraphRunStartedEvent
from core.workflow.nodes.start.start_node import StartNode
from core.workflow.runtime import GraphRuntimeState, VariablePool
@@ -180,3 +187,67 @@ def test_pause_command():
graph_execution = engine.graph_runtime_state.graph_execution
assert graph_execution.pause_reasons == [SchedulingPause(message="User requested pause")]
def test_update_variables_command_updates_pool():
"""Test that GraphEngine updates variable pool via update variables command."""
shared_runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=time.perf_counter())
shared_runtime_state.variable_pool.add(("node1", "foo"), "old value")
mock_graph = MagicMock(spec=Graph)
mock_graph.nodes = {}
mock_graph.edges = {}
mock_graph.root_node = MagicMock()
mock_graph.root_node.id = "start"
start_node = StartNode(
id="start",
config={"id": "start", "data": {"title": "start", "variables": []}},
graph_init_params=GraphInitParams(
tenant_id="test_tenant",
app_id="test_app",
workflow_id="test_workflow",
graph_config={},
user_id="test_user",
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
call_depth=0,
),
graph_runtime_state=shared_runtime_state,
)
mock_graph.nodes["start"] = start_node
mock_graph.get_outgoing_edges = MagicMock(return_value=[])
mock_graph.get_incoming_edges = MagicMock(return_value=[])
command_channel = InMemoryChannel()
engine = GraphEngine(
workflow_id="test_workflow",
graph=mock_graph,
graph_runtime_state=shared_runtime_state,
command_channel=command_channel,
)
update_command = UpdateVariablesCommand(
updates=[
VariableUpdate(
value=StringVariable(name="foo", value="new value", selector=["node1", "foo"]),
),
VariableUpdate(
value=IntegerVariable(name="bar", value=123, selector=["node2", "bar"]),
),
]
)
command_channel.send_command(update_command)
list(engine.run())
updated_existing = shared_runtime_state.variable_pool.get(["node1", "foo"])
added_new = shared_runtime_state.variable_pool.get(["node2", "bar"])
assert updated_existing is not None
assert updated_existing.value == "new value"
assert added_new is not None
assert added_new.value == 123
@@ -5,8 +5,8 @@ from core.workflow.graph_engine.entities.graph import Graph
from core.workflow.graph_engine.entities.graph_init_params import GraphInitParams
from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState
from core.helper.code_executor.code_executor import CodeExecutionError
from core.workflow.enums import ErrorStrategy, NodeType, WorkflowNodeExecutionStatus
from core.workflow.nodes.template_transform.template_renderer import TemplateRenderError
from core.workflow.nodes.template_transform.template_transform_node import TemplateTransformNode
from models.workflow import WorkflowType
@@ -127,7 +127,9 @@ class TestTemplateTransformNode:
"""Test version class method."""
assert TemplateTransformNode.version() == "1"
@patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template")
@patch(
"core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template"
)
def test_run_simple_template(
self, mock_execute, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params
):
@@ -145,7 +147,7 @@ class TestTemplateTransformNode:
mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector))
# Setup mock executor
mock_execute.return_value = {"result": "Hello Alice, you are 30 years old!"}
mock_execute.return_value = "Hello Alice, you are 30 years old!"
node = TemplateTransformNode(
id="test_node",
@@ -162,7 +164,9 @@ class TestTemplateTransformNode:
assert result.inputs["name"] == "Alice"
assert result.inputs["age"] == 30
@patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template")
@patch(
"core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template"
)
def test_run_with_none_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params):
"""Test _run with None variable values."""
node_data = {
@@ -172,7 +176,7 @@ class TestTemplateTransformNode:
}
mock_graph_runtime_state.variable_pool.get.return_value = None
mock_execute.return_value = {"result": "Value: "}
mock_execute.return_value = "Value: "
node = TemplateTransformNode(
id="test_node",
@@ -187,13 +191,15 @@ class TestTemplateTransformNode:
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.inputs["value"] is None
@patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template")
@patch(
"core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template"
)
def test_run_with_code_execution_error(
self, mock_execute, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params
):
"""Test _run when code execution fails."""
mock_graph_runtime_state.variable_pool.get.return_value = MagicMock()
mock_execute.side_effect = CodeExecutionError("Template syntax error")
mock_execute.side_effect = TemplateRenderError("Template syntax error")
node = TemplateTransformNode(
id="test_node",
@@ -208,14 +214,16 @@ class TestTemplateTransformNode:
assert result.status == WorkflowNodeExecutionStatus.FAILED
assert "Template syntax error" in result.error
@patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template")
@patch(
"core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template"
)
@patch("core.workflow.nodes.template_transform.template_transform_node.MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH", 10)
def test_run_output_length_exceeds_limit(
self, mock_execute, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params
):
"""Test _run when output exceeds maximum length."""
mock_graph_runtime_state.variable_pool.get.return_value = MagicMock()
mock_execute.return_value = {"result": "This is a very long output that exceeds the limit"}
mock_execute.return_value = "This is a very long output that exceeds the limit"
node = TemplateTransformNode(
id="test_node",
@@ -230,7 +238,9 @@ class TestTemplateTransformNode:
assert result.status == WorkflowNodeExecutionStatus.FAILED
assert "Output length exceeds" in result.error
@patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template")
@patch(
"core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template"
)
def test_run_with_complex_jinja2_template(
self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params
):
@@ -257,7 +267,7 @@ class TestTemplateTransformNode:
("sys", "show_total"): mock_show_total,
}
mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector))
mock_execute.return_value = {"result": "apple, banana, orange (Total: 3)"}
mock_execute.return_value = "apple, banana, orange (Total: 3)"
node = TemplateTransformNode(
id="test_node",
@@ -292,7 +302,9 @@ class TestTemplateTransformNode:
assert mapping["node_123.var1"] == ["sys", "input1"]
assert mapping["node_123.var2"] == ["sys", "input2"]
@patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template")
@patch(
"core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template"
)
def test_run_with_empty_variables(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params):
"""Test _run with no variables (static template)."""
node_data = {
@@ -301,7 +313,7 @@ class TestTemplateTransformNode:
"template": "This is a static message.",
}
mock_execute.return_value = {"result": "This is a static message."}
mock_execute.return_value = "This is a static message."
node = TemplateTransformNode(
id="test_node",
@@ -317,7 +329,9 @@ class TestTemplateTransformNode:
assert result.outputs["output"] == "This is a static message."
assert result.inputs == {}
@patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template")
@patch(
"core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template"
)
def test_run_with_numeric_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params):
"""Test _run with numeric variable values."""
node_data = {
@@ -339,7 +353,7 @@ class TestTemplateTransformNode:
("sys", "quantity"): mock_quantity,
}
mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector))
mock_execute.return_value = {"result": "Total: $31.5"}
mock_execute.return_value = "Total: $31.5"
node = TemplateTransformNode(
id="test_node",
@@ -354,7 +368,9 @@ class TestTemplateTransformNode:
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["output"] == "Total: $31.5"
@patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template")
@patch(
"core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template"
)
def test_run_with_dict_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params):
"""Test _run with dictionary variable values."""
node_data = {
@@ -367,7 +383,7 @@ class TestTemplateTransformNode:
mock_user.to_object.return_value = {"name": "John Doe", "email": "john@example.com"}
mock_graph_runtime_state.variable_pool.get.return_value = mock_user
mock_execute.return_value = {"result": "Name: John Doe, Email: john@example.com"}
mock_execute.return_value = "Name: John Doe, Email: john@example.com"
node = TemplateTransformNode(
id="test_node",
@@ -383,7 +399,9 @@ class TestTemplateTransformNode:
assert "John Doe" in result.outputs["output"]
assert "john@example.com" in result.outputs["output"]
@patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template")
@patch(
"core.workflow.nodes.template_transform.template_transform_node.CodeExecutorJinja2TemplateRenderer.render_template"
)
def test_run_with_list_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params):
"""Test _run with list variable values."""
node_data = {
@@ -396,7 +414,7 @@ class TestTemplateTransformNode:
mock_tags.to_object.return_value = ["python", "ai", "workflow"]
mock_graph_runtime_state.variable_pool.get.return_value = mock_tags
mock_execute.return_value = {"result": "Tags: #python #ai #workflow "}
mock_execute.return_value = "Tags: #python #ai #workflow "
node = TemplateTransformNode(
id="test_node",
Generated
+6 -6
View File
@@ -2955,14 +2955,14 @@ wheels = [
[[package]]
name = "intersystems-irispython"
version = "5.3.0"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/56/16d93576b50408d97a5cbbd055d8da024d585e96a360e2adc95b41ae6284/intersystems_irispython-5.3.0-cp38.cp39.cp310.cp311.cp312.cp313-cp38.cp39.cp310.cp311.cp312.cp313-macosx_10_9_universal2.whl", hash = "sha256:59d3176a35867a55b1ab69a6b5c75438b460291bccb254c2d2f4173be08b6e55", size = 6594480, upload-time = "2025-10-09T20:47:27.629Z" },
{ url = "https://files.pythonhosted.org/packages/99/bc/19e144ee805ea6ee0df6342a711e722c84347c05a75b3bf040c5fbe19982/intersystems_irispython-5.3.0-cp38.cp39.cp310.cp311.cp312.cp313-cp38.cp39.cp310.cp311.cp312.cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56bccefd1997c25f9f9f6c4086214c18d4fdaac0a93319d4b21dd9a6c59c9e51", size = 14779928, upload-time = "2025-10-09T20:47:30.564Z" },
{ url = "https://files.pythonhosted.org/packages/e6/fb/59ba563a80b39e9450b4627b5696019aa831dce27dacc3831b8c1e669102/intersystems_irispython-5.3.0-cp38.cp39.cp310.cp311.cp312.cp313-cp38.cp39.cp310.cp311.cp312.cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e160adc0785c55bb64e4264b8e99075691a15b0afa5d8d529f1b4bac7e57b81", size = 14422035, upload-time = "2025-10-09T20:47:32.552Z" },
{ url = "https://files.pythonhosted.org/packages/c1/68/ade8ad43f0ed1e5fba60e1710fa5ddeb01285f031e465e8c006329072e63/intersystems_irispython-5.3.0-cp38.cp39.cp310.cp311.cp312.cp313-cp38.cp39.cp310.cp311.cp312.cp313-win32.whl", hash = "sha256:820f2c5729119e5173a5bf6d6ac2a41275c4f1ffba6af6c59ea313ecd8f499cc", size = 2824316, upload-time = "2025-10-09T20:47:28.998Z" },
{ url = "https://files.pythonhosted.org/packages/f4/03/cd45cb94e42c01dc525efebf3c562543a18ee55b67fde4022665ca672351/intersystems_irispython-5.3.0-cp38.cp39.cp310.cp311.cp312.cp313-cp38.cp39.cp310.cp311.cp312.cp313-win_amd64.whl", hash = "sha256:fc07ec24bc50b6f01573221cd7d86f2937549effe31c24af8db118e0131e340c", size = 3463297, upload-time = "2025-10-09T20:47:34.636Z" },
{ url = "https://files.pythonhosted.org/packages/33/5b/8eac672a6ef26bef6ef79a7c9557096167b50c4d3577d558ae6999c195fe/intersystems_irispython-5.3.1-cp38.cp39.cp310.cp311.cp312.cp313.cp314-cp38.cp39.cp310.cp311.cp312.cp313.cp314-macosx_10_9_universal2.whl", hash = "sha256:634c9b4ec620837d830ff49543aeb2797a1ce8d8570a0e868398b85330dfcc4d", size = 6736686, upload-time = "2025-12-19T16:24:57.734Z" },
{ url = "https://files.pythonhosted.org/packages/ba/17/bab3e525ffb6711355f7feea18c1b7dced9c2484cecbcdd83f74550398c0/intersystems_irispython-5.3.1-cp38.cp39.cp310.cp311.cp312.cp313.cp314-cp38.cp39.cp310.cp311.cp312.cp313.cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cf912f30f85e2a42f2c2ea77fbeb98a24154d5ea7428a50382786a684ec4f583", size = 16005259, upload-time = "2025-12-19T16:25:05.578Z" },
{ url = "https://files.pythonhosted.org/packages/39/59/9bb79d9e32e3e55fc9aed8071a797b4497924cbc6457cea9255bb09320b7/intersystems_irispython-5.3.1-cp38.cp39.cp310.cp311.cp312.cp313.cp314-cp38.cp39.cp310.cp311.cp312.cp313.cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5659a6bb57593910f2a2417eddb9f5dc2f93a337ead6ddca778f557b8a359a", size = 15638040, upload-time = "2025-12-19T16:24:54.429Z" },
{ url = "https://files.pythonhosted.org/packages/cf/47/654ccf9c5cca4f5491f070888544165c9e2a6a485e320ea703e4e38d2358/intersystems_irispython-5.3.1-cp38.cp39.cp310.cp311.cp312.cp313.cp314-cp38.cp39.cp310.cp311.cp312.cp313.cp314-win32.whl", hash = "sha256:583e4f17088c1e0530f32efda1c0ccb02993cbc22035bc8b4c71d8693b04ee7e", size = 2879644, upload-time = "2025-12-19T16:24:59.945Z" },
{ url = "https://files.pythonhosted.org/packages/68/95/19cc13d09f1b4120bd41b1434509052e1d02afd27f2679266d7ad9cc1750/intersystems_irispython-5.3.1-cp38.cp39.cp310.cp311.cp312.cp313.cp314-cp38.cp39.cp310.cp311.cp312.cp313.cp314-win_amd64.whl", hash = "sha256:1d5d40450a0cdeec2a1f48d12d946a8a8ffc7c128576fcae7d58e66e3a127eae", size = 3522092, upload-time = "2025-12-19T16:25:01.834Z" },
]
[[package]]
+4
View File
@@ -1077,6 +1077,10 @@ LOGSTORE_DUAL_WRITE_ENABLED=false
# Enable dual-read fallback to SQL database when LogStore returns no results (default: true)
# Useful for migration scenarios where historical data exists only in SQL database
LOGSTORE_DUAL_READ_ENABLED=true
# Control flag for whether to write the `graph` field to LogStore.
# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field;
# otherwise write an empty {} instead. Defaults to writing the `graph` field.
LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true
# HTTP request node in workflow configuration
HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760
+1
View File
@@ -475,6 +475,7 @@ x-shared-env: &shared-api-worker-env
ALIYUN_SLS_LOGSTORE_TTL: ${ALIYUN_SLS_LOGSTORE_TTL:-365}
LOGSTORE_DUAL_WRITE_ENABLED: ${LOGSTORE_DUAL_WRITE_ENABLED:-false}
LOGSTORE_DUAL_READ_ENABLED: ${LOGSTORE_DUAL_READ_ENABLED:-true}
LOGSTORE_ENABLE_PUT_GRAPH_FIELD: ${LOGSTORE_ENABLE_PUT_GRAPH_FIELD:-true}
HTTP_REQUEST_NODE_MAX_BINARY_SIZE: ${HTTP_REQUEST_NODE_MAX_BINARY_SIZE:-10485760}
HTTP_REQUEST_NODE_MAX_TEXT_SIZE: ${HTTP_REQUEST_NODE_MAX_TEXT_SIZE:-1048576}
HTTP_REQUEST_NODE_SSL_VERIFY: ${HTTP_REQUEST_NODE_SSL_VERIFY:-True}
+5 -1
View File
@@ -233,4 +233,8 @@ ALIYUN_SLS_LOGSTORE_TTL=365
LOGSTORE_DUAL_WRITE_ENABLED=true
# Enable dual-read fallback to SQL database when LogStore returns no results (default: true)
# Useful for migration scenarios where historical data exists only in SQL database
LOGSTORE_DUAL_READ_ENABLED=true
LOGSTORE_DUAL_READ_ENABLED=true
# Control flag for whether to write the `graph` field to LogStore.
# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field;
# otherwise write an empty {} instead. Defaults to writing the `graph` field.
LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dify-client",
"version": "3.1.0",
"version": "3.0.0",
"description": "This is the Node.js SDK for the Dify.AI API, which allows you to easily integrate Dify.AI into your Node.js applications.",
"type": "module",
"main": "./dist/index.js",
@@ -53,6 +53,9 @@
"publish:check": "./scripts/publish.sh --dry-run",
"publish:npm": "./scripts/publish.sh"
},
"dependencies": {
"axios": "^1.13.2"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.3",
+191
View File
@@ -7,6 +7,10 @@ settings:
importers:
.:
dependencies:
axios:
specifier: ^1.13.2
version: 1.13.2
devDependencies:
'@eslint/js':
specifier: ^9.39.2
@@ -537,6 +541,12 @@ packages:
ast-v8-to-istanbul@0.3.10:
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.13.2:
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -556,6 +566,10 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@@ -579,6 +593,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@@ -609,9 +627,33 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
@@ -706,11 +748,35 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -719,10 +785,26 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -824,6 +906,18 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -922,6 +1016,9 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1578,6 +1675,16 @@ snapshots:
estree-walker: 3.0.3
js-tokens: 9.0.1
asynckit@0.4.0: {}
axios@1.13.2:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
balanced-match@1.0.2: {}
brace-expansion@1.1.12:
@@ -1596,6 +1703,11 @@ snapshots:
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
callsites@3.1.0: {}
chai@6.2.2: {}
@@ -1615,6 +1727,10 @@ snapshots:
color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@4.1.1: {}
concat-map@0.0.1: {}
@@ -1635,8 +1751,31 @@ snapshots:
deep-is@0.1.4: {}
delayed-stream@1.0.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
@@ -1772,17 +1911,59 @@ snapshots:
flatted@3.3.3: {}
follow-redirects@1.15.11: {}
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
globals@14.0.0: {}
gopd@1.2.0: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
html-escaper@2.0.2: {}
ignore@5.3.2: {}
@@ -1874,6 +2055,14 @@ snapshots:
dependencies:
semver: 7.7.3
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@@ -1958,6 +2147,8 @@ snapshots:
prelude-ls@1.2.1: {}
proxy-from-env@1.1.0: {}
punycode@2.3.1: {}
readdirp@4.1.2: {}
+114 -151
View File
@@ -1,3 +1,4 @@
import axios from "axios";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -11,46 +12,17 @@ import {
} from "../errors/dify-error";
import { HttpClient } from "./client";
// Helper to create a mock fetch response
const createMockResponse = (options = {}) => {
const {
ok = true,
status = 200,
headers = {},
body = null,
data = null,
} = options;
const headersObj = new Headers(headers);
const response = {
ok,
status,
headers: headersObj,
body,
json: vi.fn().mockResolvedValue(data),
text: vi.fn().mockResolvedValue(typeof data === 'string' ? data : JSON.stringify(data)),
blob: vi.fn().mockResolvedValue(new Blob()),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
};
return response;
};
describe("HttpClient", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers(); // Ensure real timers are used by default
});
it("builds requests with auth headers and JSON content type", async () => {
const mockFetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
headers: { "x-request-id": "req" },
data: { ok: true },
})
);
global.fetch = mockFetch;
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: { ok: true },
headers: { "x-request-id": "req" },
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
const response = await client.request({
@@ -60,20 +32,19 @@ describe("HttpClient", () => {
});
expect(response.requestId).toBe("req");
const [url, config] = mockFetch.mock.calls[0];
const config = mockRequest.mock.calls[0][0];
expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["Content-Type"]).toBe("application/json");
expect(url).toContain("/chat-messages");
expect(config.responseType).toBe("json");
});
it("serializes array query params", async () => {
const mockFetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: "ok",
headers: {},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
await client.requestRaw({
@@ -82,31 +53,21 @@ describe("HttpClient", () => {
query: { tag_ids: ["a", "b"], limit: 2 },
});
const [url] = mockFetch.mock.calls[0];
expect(url).toContain("tag_ids=a&tag_ids=b&limit=2");
const config = mockRequest.mock.calls[0][0];
const queryString = config.paramsSerializer.serialize({
tag_ids: ["a", "b"],
limit: 2,
});
expect(queryString).toBe("tag_ids=a&tag_ids=b&limit=2");
});
it("returns SSE stream helpers", async () => {
// Create a mock web ReadableStream from Node stream data
const chunks = ['data: {"text":"hi"}\n\n'];
let index = 0;
const webStream = new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(new TextEncoder().encode(chunks[index++]));
} else {
controller.close();
}
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
headers: new Headers({ "x-request-id": "req" }),
body: webStream,
data: Readable.from(["data: {\"text\":\"hi\"}\n\n"]),
headers: { "x-request-id": "req" },
});
global.fetch = mockFetch;
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestStream({
@@ -121,26 +82,12 @@ describe("HttpClient", () => {
});
it("returns binary stream helpers", async () => {
// Create a mock web ReadableStream from Node stream data
const chunks = ["chunk"];
let index = 0;
const webStream = new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(new TextEncoder().encode(chunks[index++]));
} else {
controller.close();
}
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
headers: new Headers({ "x-request-id": "req" }),
body: webStream,
data: Readable.from(["chunk"]),
headers: { "x-request-id": "req" },
});
global.fetch = mockFetch;
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
const stream = await client.requestBinaryStream({
@@ -154,13 +101,12 @@ describe("HttpClient", () => {
});
it("respects form-data headers", async () => {
const mockFetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: "ok",
headers: {},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
const form = {
@@ -174,7 +120,7 @@ describe("HttpClient", () => {
data: form,
});
const [, config] = mockFetch.mock.calls[0];
const config = mockRequest.mock.calls[0][0];
expect(config.headers["content-type"]).toBe(
"multipart/form-data; boundary=abc"
);
@@ -182,27 +128,30 @@ describe("HttpClient", () => {
});
it("maps 401 and 429 errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 401,
data: { message: "unauthorized" },
})
);
headers: {},
},
});
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(AuthenticationError);
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 429,
headers: { "retry-after": "2" },
data: { message: "rate" },
})
);
headers: { "retry-after": "2" },
},
});
const error = await client
.requestRaw({ method: "GET", path: "/meta" })
.catch((err) => err);
@@ -211,96 +160,109 @@ describe("HttpClient", () => {
});
it("maps validation and upload errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 422,
data: { message: "invalid" },
})
);
headers: {},
},
});
await expect(
client.requestRaw({ method: "POST", path: "/chat-messages", data: { user: "u" } })
).rejects.toBeInstanceOf(ValidationError);
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
config: { url: "/files/upload" },
response: {
status: 400,
data: { message: "bad upload" },
})
);
headers: {},
},
});
await expect(
client.requestRaw({ method: "POST", path: "/files/upload", data: { user: "u" } })
).rejects.toBeInstanceOf(FileUploadError);
});
it("maps timeout and network errors", async () => {
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
// Test AbortError (which is what timeout produces)
global.fetch = vi.fn().mockRejectedValue(new DOMException("aborted", "AbortError"));
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
});
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(TimeoutError);
// Test network error
global.fetch = vi.fn().mockRejectedValue(new Error("network"));
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
message: "network",
});
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(NetworkError);
});
it("retries on timeout errors", async () => {
const mockFetch = vi.fn()
.mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
.mockResolvedValueOnce(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 1, retryDelay: 0 });
mockRequest
.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
})
.mockResolvedValueOnce({ status: 200, data: "ok", headers: {} });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockRequest).toHaveBeenCalledTimes(2);
});
it("validates query parameters before request", async () => {
const mockFetch = vi.fn();
global.fetch = mockFetch;
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test" });
await expect(
client.requestRaw({ method: "GET", path: "/meta", query: { user: 1 } })
).rejects.toBeInstanceOf(ValidationError);
expect(mockFetch).not.toHaveBeenCalled();
expect(mockRequest).not.toHaveBeenCalled();
});
it("returns APIError for other http failures", async () => {
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
ok: false,
status: 500,
data: { message: "server" },
})
);
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", maxRetries: 0 });
mockRequest.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500, data: { message: "server" }, headers: {} },
});
await expect(
client.requestRaw({ method: "GET", path: "/meta" })
).rejects.toBeInstanceOf(APIError);
});
it("logs requests and responses when enableLogging is true", async () => {
global.fetch = vi.fn().mockResolvedValue(
createMockResponse({
status: 200,
data: { ok: true },
})
);
const mockRequest = vi.fn().mockResolvedValue({
status: 200,
data: { ok: true },
headers: {},
});
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({ apiKey: "test", enableLogging: true });
@@ -313,15 +275,8 @@ describe("HttpClient", () => {
});
it("logs retry attempts when enableLogging is true", async () => {
const mockFetch = vi.fn()
.mockRejectedValueOnce(new DOMException("aborted", "AbortError"))
.mockResolvedValueOnce(
createMockResponse({
status: 200,
data: "ok",
})
);
global.fetch = mockFetch;
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {});
const client = new HttpClient({
@@ -331,6 +286,14 @@ describe("HttpClient", () => {
enableLogging: true,
});
mockRequest
.mockRejectedValueOnce({
isAxiosError: true,
code: "ECONNABORTED",
message: "timeout",
})
.mockResolvedValueOnce({ status: 200, data: "ok", headers: {} });
await client.requestRaw({ method: "GET", path: "/meta" });
expect(consoleInfo).toHaveBeenCalledWith(
+88 -174
View File
@@ -1,3 +1,10 @@
import axios from "axios";
import type {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import type { Readable } from "node:stream";
import {
DEFAULT_BASE_URL,
@@ -12,8 +19,8 @@ import type {
QueryParams,
RequestMethod,
} from "../types/common";
import type { DifyError } from "../errors/dify-error";
import {
DifyError,
APIError,
AuthenticationError,
FileUploadError,
@@ -29,15 +36,13 @@ import { validateParams } from "../client/validation";
const DEFAULT_USER_AGENT = "dify-client-node";
export type ResponseType = "json" | "stream" | "text" | "blob" | "arraybuffer";
export type RequestOptions = {
method: RequestMethod;
path: string;
query?: QueryParams;
data?: unknown;
headers?: Headers;
responseType?: ResponseType;
responseType?: AxiosRequestConfig["responseType"];
};
export type HttpClientSettings = Required<
@@ -55,15 +60,12 @@ const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({
enableLogging: config.enableLogging ?? false,
});
const normalizeHeaders = (headers: Record<string, string | string[] | number | undefined>): Headers => {
const normalizeHeaders = (headers: AxiosResponse["headers"]): Headers => {
const result: Headers = {};
if (!headers) {
return result;
}
Object.entries(headers).forEach(([key, value]) => {
if (value === undefined) {
return;
}
if (Array.isArray(value)) {
result[key.toLowerCase()] = value.join(", ");
} else if (typeof value === "string") {
@@ -119,28 +121,6 @@ const parseRetryAfterSeconds = (headerValue?: string): number | undefined => {
return undefined;
};
const shouldParseJson = (contentType: string | null, text: string): boolean => {
if (contentType) {
return contentType.toLowerCase().includes("application/json");
}
return text.length > 0;
};
const parseResponseBody = (text: string, contentType: string | null): unknown => {
if (!shouldParseJson(contentType, text)) {
return text;
}
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
// Fallback to raw text if JSON parsing fails
return text;
}
};
const isReadableStream = (value: unknown): value is Readable => {
if (!value || typeof value !== "object") {
return false;
@@ -148,34 +128,17 @@ const isReadableStream = (value: unknown): value is Readable => {
return typeof (value as { pipe?: unknown }).pipe === "function";
};
const createBodyFactory = (
method: RequestMethod,
data?: unknown
): { getBody: () => BodyInit | undefined; canRetry: boolean } => {
if (method === "GET" || data === undefined) {
return { getBody: () => undefined, canRetry: true };
}
if (isReadableStream(data)) {
return { getBody: () => data as unknown as BodyInit, canRetry: false };
}
if (isFormData(data)) {
return { getBody: () => data as BodyInit, canRetry: true };
}
const jsonBody = JSON.stringify(data);
return { getBody: () => jsonBody, canRetry: true };
};
const isUploadLikeRequest = (url: string): boolean => {
const isUploadLikeRequest = (config?: AxiosRequestConfig): boolean => {
const url = (config?.url ?? "").toLowerCase();
if (!url) {
return false;
}
const lowerUrl = url.toLowerCase();
return (
lowerUrl.includes("upload") ||
lowerUrl.includes("/files/") ||
lowerUrl.includes("audio-to-text") ||
lowerUrl.includes("create_by_file") ||
lowerUrl.includes("update_by_file")
url.includes("upload") ||
url.includes("/files/") ||
url.includes("audio-to-text") ||
url.includes("create_by_file") ||
url.includes("update_by_file")
);
};
@@ -196,71 +159,75 @@ const resolveErrorMessage = (status: number, responseBody: unknown): string => {
return `Request failed with status code ${status}`;
};
const mapFetchError = (
error: unknown,
url: string,
response?: Response,
responseBody?: unknown
): DifyError => {
if (response) {
const status = response.status;
const headers = normalizeHeaders(Object.fromEntries(response.headers.entries()));
const requestId = resolveRequestId(headers);
const message = resolveErrorMessage(status, responseBody);
const mapAxiosError = (error: unknown): DifyError => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
const status = axiosError.response.status;
const headers = normalizeHeaders(axiosError.response.headers);
const requestId = resolveRequestId(headers);
const responseBody = axiosError.response.data;
const message = resolveErrorMessage(status, responseBody);
if (status === 401) {
return new AuthenticationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 429) {
const retryAfter = parseRetryAfterSeconds(headers["retry-after"]);
return new RateLimitError(message, {
statusCode: status,
responseBody,
requestId,
retryAfter,
});
}
if (status === 422) {
return new ValidationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 400) {
if (isUploadLikeRequest(url)) {
return new FileUploadError(message, {
if (status === 401) {
return new AuthenticationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 429) {
const retryAfter = parseRetryAfterSeconds(headers["retry-after"]);
return new RateLimitError(message, {
statusCode: status,
responseBody,
requestId,
retryAfter,
});
}
if (status === 422) {
return new ValidationError(message, {
statusCode: status,
responseBody,
requestId,
});
}
if (status === 400) {
if (isUploadLikeRequest(axiosError.config)) {
return new FileUploadError(message, {
statusCode: status,
responseBody,
requestId,
});
}
}
return new APIError(message, {
statusCode: status,
responseBody,
requestId,
});
}
return new APIError(message, {
statusCode: status,
responseBody,
requestId,
});
if (axiosError.code === "ECONNABORTED") {
return new TimeoutError("Request timed out", { cause: axiosError });
}
return new NetworkError(axiosError.message, { cause: axiosError });
}
if (error instanceof Error) {
if (error.name === "AbortError" || error.message.includes("aborted")) {
return new TimeoutError("Request timed out", { cause: error });
}
return new NetworkError(error.message, { cause: error });
}
return new NetworkError("Unexpected network error", { cause: error });
};
export class HttpClient {
private axios: AxiosInstance;
private settings: HttpClientSettings;
constructor(config: DifyClientConfig) {
this.settings = normalizeSettings(config);
this.axios = axios.create({
baseURL: this.settings.baseUrl,
timeout: this.settings.timeout * 1000,
});
}
updateApiKey(apiKey: string): void {
@@ -308,11 +275,7 @@ export class HttpClient {
});
}
async requestRaw(options: RequestOptions): Promise<{
status: number;
data: unknown;
headers: Headers;
}> {
async requestRaw(options: RequestOptions): Promise<AxiosResponse> {
const { method, path, query, data, headers, responseType } = options;
const { apiKey, enableLogging, maxRetries, retryDelay, timeout } =
this.settings;
@@ -349,93 +312,44 @@ export class HttpClient {
requestHeaders["Content-Type"] = "application/json";
}
let url = buildRequestUrl(this.settings.baseUrl, path);
const queryString = buildQueryString(query);
if (queryString) {
url += `?${queryString}`;
}
const url = buildRequestUrl(this.settings.baseUrl, path);
if (enableLogging) {
console.info(`dify-client-node request ${method} ${url}`);
}
const { getBody, canRetry: canRetryBody } = createBodyFactory(method, data);
const axiosConfig: AxiosRequestConfig = {
method,
url: path,
params: query,
paramsSerializer: {
serialize: (params) => buildQueryString(params as QueryParams),
},
headers: requestHeaders,
responseType: responseType ?? "json",
timeout: timeout * 1000,
};
if (method !== "GET" && data !== undefined) {
axiosConfig.data = data;
}
let attempt = 0;
// `attempt` is a zero-based retry counter
// Total attempts = 1 (initial) + maxRetries
// e.g., maxRetries=3 means: attempt 0 (initial), then retries at 1, 2, 3
while (true) {
// Create a new AbortController for each attempt to handle timeouts properly
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout * 1000);
try {
const response = await fetch(url, {
method,
headers: requestHeaders,
body: getBody(),
signal: abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const contentType = response.headers.get("content-type");
// Read body as text first to avoid "Body has already been read" error
const text = await response.text();
const responseBody = parseResponseBody(text, contentType);
throw mapFetchError(new Error(`HTTP ${response.status}`), url, response, responseBody);
}
const response = await this.axios.request(axiosConfig);
if (enableLogging) {
console.info(
`dify-client-node response ${response.status} ${method} ${url}`
);
}
let responseData: unknown;
if (responseType === "stream") {
// For Node.js, we need to convert web streams to Node.js streams
if (response.body) {
const { Readable } = await import("node:stream");
// Type assertion needed: DOM ReadableStream vs Node.js stream types are incompatible
// but Readable.fromWeb handles the conversion correctly at runtime
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
responseData = Readable.fromWeb(response.body as any);
} else {
throw new Error("Response body is null");
}
} else if (responseType === "text") {
responseData = await response.text();
} else if (responseType === "blob") {
responseData = await response.blob();
} else if (responseType === "arraybuffer") {
responseData = await response.arrayBuffer();
} else {
// json or default
const contentType = response.headers.get("content-type");
// Read body as text first to handle malformed JSON gracefully
const text = await response.text();
responseData = parseResponseBody(text, contentType);
}
return {
status: response.status,
data: responseData,
headers: Object.fromEntries(response.headers.entries()),
};
return response;
} catch (error) {
clearTimeout(timeoutId);
let mapped: DifyError;
if (error instanceof DifyError) {
mapped = error;
} else {
mapped = mapFetchError(error, url);
}
if (!canRetryBody || !shouldRetry(mapped, attempt, maxRetries)) {
const mapped = mapAxiosError(error);
if (!shouldRetry(mapped, attempt, maxRetries)) {
throw mapped;
}
const retryAfterSeconds =
+113 -125
View File
@@ -1,44 +1,27 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatClient, DifyClient, WorkflowClient, BASE_URL, routes } from "./index";
import axios from "axios";
const mockFetch = vi.fn();
const mockRequest = vi.fn();
// Helper to create a mock fetch response
const createMockResponse = (options = {}) => {
const {
ok = true,
status = 200,
headers = {},
body = null,
data = null,
} = options;
const headersObj = new Headers(headers);
const response = {
ok,
status,
headers: headersObj,
body,
json: vi.fn().mockResolvedValue(data),
text: vi.fn().mockResolvedValue(typeof data === 'string' ? data : JSON.stringify(data)),
blob: vi.fn().mockResolvedValue(new Blob()),
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)),
};
return response;
const setupAxiosMock = () => {
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
};
beforeEach(() => {
vi.restoreAllMocks();
mockFetch.mockReset();
global.fetch = mockFetch;
mockRequest.mockReset();
setupAxiosMock();
});
describe("Client", () => {
it("should create a client", () => {
new DifyClient("test");
// Just verify client can be created successfully
expect(true).toBe(true);
expect(axios.create).toHaveBeenCalledWith({
baseURL: BASE_URL,
timeout: 60000,
});
});
it("should update the api key", () => {
@@ -54,37 +37,41 @@ describe("Send Requests", () => {
const difyClient = new DifyClient("test");
const method = "GET";
const endpoint = routes.application.url();
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "response",
})
);
mockRequest.mockResolvedValue({
status: 200,
data: "response",
headers: {},
});
await difyClient.sendRequest(method, endpoint);
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(endpoint);
expect(config.method).toBe(method);
expect(config.headers.Authorization).toBe("Bearer test");
const requestConfig = mockRequest.mock.calls[0][0];
expect(requestConfig).toMatchObject({
method,
url: endpoint,
params: undefined,
responseType: "json",
timeout: 60000,
});
expect(requestConfig.headers.Authorization).toBe("Bearer test");
});
it("uses the getMeta route configuration", async () => {
const difyClient = new DifyClient("test");
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await difyClient.getMeta("end-user");
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.getMeta.url());
expect(url).toContain("user=end-user");
expect(config.method).toBe(routes.getMeta.method);
expect(config.headers.Authorization).toBe("Bearer test");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getMeta.method,
url: routes.getMeta.url(),
params: { user: "end-user" },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
});
});
@@ -110,52 +97,49 @@ describe("File uploads", () => {
it("does not override multipart boundary headers for FormData", async () => {
const difyClient = new DifyClient("test");
const form = new globalThis.FormData();
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await difyClient.fileUpload(form, "end-user");
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.fileUpload.url());
expect(config.method).toBe(routes.fileUpload.method);
expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["content-type"]).toBe("multipart/form-data; boundary=test");
expect(config.body).toBe(form);
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.fileUpload.method,
url: routes.fileUpload.url(),
params: undefined,
headers: expect.objectContaining({
Authorization: "Bearer test",
"content-type": "multipart/form-data; boundary=test",
}),
responseType: "json",
timeout: 60000,
data: form,
}));
});
});
describe("Workflow client", () => {
it("uses tasks stop path for workflow stop", async () => {
const workflowClient = new WorkflowClient("test");
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "stopped",
})
);
mockRequest.mockResolvedValue({ status: 200, data: "stopped", headers: {} });
await workflowClient.stop("task-1", "end-user");
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.stopWorkflow.url("task-1"));
expect(config.method).toBe(routes.stopWorkflow.method);
expect(config.headers.Authorization).toBe("Bearer test");
expect(config.headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(config.body)).toEqual({ user: "end-user" });
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.stopWorkflow.method,
url: routes.stopWorkflow.url("task-1"),
params: undefined,
headers: expect.objectContaining({
Authorization: "Bearer test",
"Content-Type": "application/json",
}),
responseType: "json",
timeout: 60000,
data: { user: "end-user" },
}));
});
it("maps workflow log filters to service api params", async () => {
const workflowClient = new WorkflowClient("test");
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await workflowClient.getLogs({
createdAtAfter: "2024-01-01T00:00:00Z",
@@ -166,74 +150,78 @@ describe("Workflow client", () => {
limit: 10,
});
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain("/workflows/logs");
expect(url).toContain("created_at__after=2024-01-01T00%3A00%3A00Z");
expect(url).toContain("created_at__before=2024-01-02T00%3A00%3A00Z");
expect(url).toContain("created_by_end_user_session_id=sess-1");
expect(url).toContain("created_by_account=acc-1");
expect(url).toContain("page=2");
expect(url).toContain("limit=10");
expect(config.method).toBe("GET");
expect(config.headers.Authorization).toBe("Bearer test");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: "GET",
url: "/workflows/logs",
params: {
created_at__after: "2024-01-01T00:00:00Z",
created_at__before: "2024-01-02T00:00:00Z",
created_by_end_user_session_id: "sess-1",
created_by_account: "acc-1",
page: 2,
limit: 10,
},
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
});
});
describe("Chat client", () => {
it("places user in query for suggested messages", async () => {
const chatClient = new ChatClient("test");
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await chatClient.getSuggested("msg-1", "end-user");
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.getSuggested.url("msg-1"));
expect(url).toContain("user=end-user");
expect(config.method).toBe(routes.getSuggested.method);
expect(config.headers.Authorization).toBe("Bearer test");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getSuggested.method,
url: routes.getSuggested.url("msg-1"),
params: { user: "end-user" },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
});
it("uses last_id when listing conversations", async () => {
const chatClient = new ChatClient("test");
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await chatClient.getConversations("end-user", "last-1", 10);
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain(routes.getConversations.url());
expect(url).toContain("user=end-user");
expect(url).toContain("last_id=last-1");
expect(url).toContain("limit=10");
expect(config.method).toBe(routes.getConversations.method);
expect(config.headers.Authorization).toBe("Bearer test");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: routes.getConversations.method,
url: routes.getConversations.url(),
params: { user: "end-user", last_id: "last-1", limit: 10 },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
});
it("lists app feedbacks without user params", async () => {
const chatClient = new ChatClient("test");
mockFetch.mockResolvedValue(
createMockResponse({
status: 200,
data: "ok",
})
);
mockRequest.mockResolvedValue({ status: 200, data: "ok", headers: {} });
await chatClient.getAppFeedbacks(1, 20);
const [url, config] = mockFetch.mock.calls[0];
expect(url).toContain("/app/feedbacks");
expect(url).toContain("page=1");
expect(url).toContain("limit=20");
expect(config.method).toBe("GET");
expect(config.headers.Authorization).toBe("Bearer test");
expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({
method: "GET",
url: "/app/feedbacks",
params: { page: 1, limit: 20 },
headers: expect.objectContaining({
Authorization: "Bearer test",
}),
responseType: "json",
timeout: 60000,
}));
});
});
+6 -5
View File
@@ -1,15 +1,16 @@
import axios from "axios";
import { vi } from "vitest";
import { HttpClient } from "../src/http/client";
export const createHttpClient = (configOverrides = {}) => {
const mockFetch = vi.fn();
global.fetch = mockFetch;
const mockRequest = vi.fn();
vi.spyOn(axios, "create").mockReturnValue({ request: mockRequest });
const client = new HttpClient({ apiKey: "test", ...configOverrides });
return { client, mockFetch };
return { client, mockRequest };
};
export const createHttpClientWithSpies = (configOverrides = {}) => {
const { client, mockFetch } = createHttpClient(configOverrides);
const { client, mockRequest } = createHttpClient(configOverrides);
const request = vi
.spyOn(client, "request")
.mockResolvedValue({ data: "ok", status: 200, headers: {} });
@@ -21,7 +22,7 @@ export const createHttpClientWithSpies = (configOverrides = {}) => {
.mockResolvedValue({ data: null });
return {
client,
mockFetch,
mockRequest,
request,
requestStream,
requestBinaryStream,
+8
View File
@@ -7,8 +7,12 @@ logs
# node
node_modules
dist
build
coverage
.husky
.next
.pnpm-store
# vscode
.vscode
@@ -22,3 +26,7 @@ node_modules
# Jetbrains
.idea
# git
.git
.gitignore
+2
View File
@@ -47,6 +47,8 @@ NEXT_PUBLIC_TOP_K_MAX_VALUE=10
# The maximum number of tokens for segmentation
NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
# Used by web/docker/entrypoint.sh to overwrite/export NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH at container startup (Docker only)
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
# Maximum loop count in the workflow
NEXT_PUBLIC_LOOP_NODE_MAX_COUNT=100
+2 -1
View File
@@ -12,7 +12,8 @@ RUN apk add --no-cache tzdata
RUN corepack enable
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV NEXT_PUBLIC_BASE_PATH=""
ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH="$NEXT_PUBLIC_BASE_PATH"
# install packages
@@ -6,17 +6,20 @@ export type BoxProps = {
className?: string
children?: ReactNode
withBorderBottom?: boolean
withBorderTop?: boolean
}
export const Box = memo(({
className,
children,
withBorderBottom,
withBorderTop,
}: BoxProps) => {
return (
<div
className={cn(
'py-2',
withBorderBottom && 'border-b border-divider-subtle',
withBorderTop && 'border-t border-divider-subtle',
className,
)}
>
@@ -9,6 +9,7 @@ import { cn } from '@/utils/classnames'
export type FieldTitleProps = {
title?: string
className?: string
operation?: ReactNode
subTitle?: string | ReactNode
tooltip?: string
@@ -19,6 +20,7 @@ export type FieldTitleProps = {
}
export const FieldTitle = memo(({
title,
className,
operation,
subTitle,
tooltip,
@@ -31,7 +33,7 @@ export const FieldTitle = memo(({
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
return (
<div className={cn('mb-0.5', !!subTitle && 'mb-1')}>
<div className={cn('mb-0.5', !!subTitle && 'mb-1', className)}>
<div
className="group/collapse flex items-center justify-between py-1"
onClick={() => {
@@ -6,17 +6,20 @@ export type GroupProps = {
className?: string
children?: ReactNode
withBorderBottom?: boolean
withBorderTop?: boolean
}
export const Group = memo(({
className,
children,
withBorderBottom,
withBorderTop,
}: GroupProps) => {
return (
<div
className={cn(
'px-4 py-2',
withBorderBottom && 'border-b border-divider-subtle',
withBorderTop && 'border-t border-divider-subtle',
className,
)}
>
@@ -1,4 +1,6 @@
import type { CommonNodeType } from '@/app/components/workflow/types'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks'
const useNodeCrud = <T>(id: string, data: CommonNodeType<T>) => {
@@ -18,3 +20,27 @@ const useNodeCrud = <T>(id: string, data: CommonNodeType<T>) => {
}
export default useNodeCrud
export const useNodeCurdKit = <T>(id: string) => {
const store = useStoreApi()
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const getNodeData = useCallback(() => {
const { getNodes } = store.getState()
const nodes = getNodes()
return nodes.find(node => node.id === id)
}, [store, id])
const handleNodeDataUpdate = useCallback((data: Partial<CommonNodeType<T>>) => {
handleNodeDataUpdateWithSyncDraft({
id,
data,
})
}, [id, handleNodeDataUpdateWithSyncDraft])
return {
getNodeData,
handleNodeDataUpdate,
}
}
@@ -0,0 +1,53 @@
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/multiple-tool-selector'
import { BoxGroup } from '@/app/components/workflow/nodes/_base/components/layout'
import MaxIterations from './max-iterations'
import { useNodeTools } from './use-node-tools'
type ToolsProps = {
nodeId: string
tools?: ToolValue[]
maxIterations?: number
}
const Tools = ({
nodeId,
tools = [],
maxIterations = 10,
}: ToolsProps) => {
const { t } = useTranslation()
const {
handleToolsChange,
handleMaxIterationsChange,
} = useNodeTools(nodeId)
return (
<BoxGroup
boxProps={{
withBorderBottom: true,
withBorderTop: true,
}}
groupProps={{
className: 'px-0',
}}
>
<MultipleToolSelector
nodeId={nodeId}
nodeOutputVars={[]}
availableNodes={[]}
value={tools}
label={t(`nodes.llm.tools.title`, { ns: 'workflow' })}
tooltip={t(`nodes.llm.tools.title`, { ns: 'workflow' })}
onChange={handleToolsChange}
supportCollapse
/>
<MaxIterations
value={maxIterations}
onChange={handleMaxIterationsChange}
/>
</BoxGroup>
)
}
export default memo(Tools)
@@ -0,0 +1,40 @@
import { memo } from 'react'
import { InputNumber } from '@/app/components/base/input-number'
import Slider from '@/app/components/base/slider'
import Tooltip from '@/app/components/base/tooltip'
type MaxIterationsProps = {
value?: number
onChange?: (value: number) => void
}
const MaxIterations = ({ value = 10, onChange }: MaxIterationsProps) => {
return (
<div className="mt-3 flex h-10 items-center">
<div className="system-sm-semibold mr-0.5 truncate uppercase text-text-secondary">Max Iterations</div>
<Tooltip
popupContent="Max Iterations is the maximum number of iterations to run the tool."
triggerClassName="shrink-0 w-4 h-4"
/>
<div className="mr-3 flex grow items-center justify-end">
<Slider
className="w-[124px]"
value={value}
onChange={onChange ?? (() => {})}
min={1}
max={99}
step={1}
/>
</div>
<InputNumber
className="w-10 shrink-0"
value={value}
onChange={onChange ?? (() => {})}
min={1}
max={99}
step={1}
/>
</div>
)
}
export default memo(MaxIterations)
@@ -0,0 +1,23 @@
import type { LLMNodeType } from '../../types'
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import { useNodeCurdKit } from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
export const useNodeTools = (nodeId: string) => {
const { handleNodeDataUpdate } = useNodeCurdKit<LLMNodeType>(nodeId)
const handleToolsChange = (tools: ToolValue[]) => {
handleNodeDataUpdate({
tools,
})
}
const handleMaxIterationsChange = (maxIterations: number) => {
handleNodeDataUpdate({
max_iterations: maxIterations,
})
}
return {
handleToolsChange,
handleMaxIterationsChange,
}
}
@@ -22,6 +22,7 @@ import VarReferencePicker from '../_base/components/variable/var-reference-picke
import ConfigPrompt from './components/config-prompt'
import ReasoningFormatConfig from './components/reasoning-format-config'
import StructureOutput from './components/structure-output'
import Tools from './components/tools'
import useConfig from './use-config'
const i18nPrefix = 'nodes.llm'
@@ -233,6 +234,12 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
</>
)}
<Tools
nodeId={id}
tools={inputs.tools}
maxIterations={inputs.max_iterations}
/>
{/* Vision: GPT4-vision and so on */}
<ConfigVision
nodeId={id}
@@ -1,5 +1,18 @@
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
import type { CommonNodeType, Memory, ModelConfig, PromptItem, ValueSelector, Variable, VisionSetting } from '@/app/components/workflow/types'
export type Tool = {
enabled: boolean
type: string
provider_name: 'plugin' | 'builtin' | 'api' | 'workflow' | 'app' | 'dataset-retrieval'
tool_name: string
plugin_unique_identifier?: string
credential_id?: string
parameters?: Record<string, any>
settings?: Record<string, any>
extra?: Record<string, any>
}
export type LLMNodeType = CommonNodeType & {
model: ModelConfig
prompt_template: PromptItem[] | PromptItem
@@ -18,6 +31,8 @@ export type LLMNodeType = CommonNodeType & {
structured_output_enabled?: boolean
structured_output?: StructuredOutput
reasoning_format?: 'tagged' | 'separated'
tools?: ToolValue[]
max_iterations?: number
}
export enum Type {
+4
View File
@@ -1,6 +1,8 @@
import noAsAnyInT from './rules/no-as-any-in-t.js'
import noExtraKeys from './rules/no-extra-keys.js'
import noLegacyNamespacePrefix from './rules/no-legacy-namespace-prefix.js'
import requireNsOption from './rules/require-ns-option.js'
import validI18nKeys from './rules/valid-i18n-keys.js'
/** @type {import('eslint').ESLint.Plugin} */
const plugin = {
@@ -10,8 +12,10 @@ const plugin = {
},
rules: {
'no-as-any-in-t': noAsAnyInT,
'no-extra-keys': noExtraKeys,
'no-legacy-namespace-prefix': noLegacyNamespacePrefix,
'require-ns-option': requireNsOption,
'valid-i18n-keys': validI18nKeys,
},
}
+70
View File
@@ -0,0 +1,70 @@
import fs from 'node:fs'
import path, { normalize, sep } from 'node:path'
/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'problem',
docs: {
description: 'Ensure non-English JSON files don\'t have extra keys not present in en-US',
},
fixable: 'code',
},
create(context) {
return {
Program(node) {
const { filename, sourceCode } = context
if (!filename.endsWith('.json'))
return
const parts = normalize(filename).split(sep)
// e.g., i18n/ar-TN/common.json -> jsonFile = common.json, lang = ar-TN
const jsonFile = parts.at(-1)
const lang = parts.at(-2)
// Skip English files
if (lang === 'en-US')
return
let currentJson = {}
let englishJson = {}
try {
currentJson = JSON.parse(sourceCode.text)
// Look for the same filename in en-US folder
// e.g., i18n/ar-TN/common.json -> i18n/en-US/common.json
const englishFilePath = path.join(path.dirname(filename), '..', 'en-US', jsonFile ?? '')
englishJson = JSON.parse(fs.readFileSync(englishFilePath, 'utf8'))
}
catch (error) {
context.report({
node,
message: `Error parsing JSON: ${error instanceof Error ? error.message : String(error)}`,
})
return
}
const extraKeys = Object.keys(currentJson).filter(
key => !Object.prototype.hasOwnProperty.call(englishJson, key),
)
for (const key of extraKeys) {
context.report({
node,
message: `Key "${key}" is present in ${lang}/${jsonFile} but not in en-US/${jsonFile}`,
fix(fixer) {
const newJson = Object.fromEntries(
Object.entries(currentJson).filter(([k]) => !extraKeys.includes(k)),
)
const newText = `${JSON.stringify(newJson, null, 2)}\n`
return fixer.replaceText(node, newText)
},
})
}
},
}
},
}
+61
View File
@@ -0,0 +1,61 @@
import { cleanJsonText } from '../utils.js'
/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'problem',
docs: {
description: 'Ensure i18n JSON keys are flat and valid as object paths',
},
},
create(context) {
return {
Program(node) {
const { filename, sourceCode } = context
if (!filename.endsWith('.json'))
return
let json
try {
json = JSON.parse(cleanJsonText(sourceCode.text))
}
catch {
context.report({
node,
message: 'Invalid JSON format',
})
return
}
const keys = Object.keys(json)
const keyPrefixes = new Set()
for (const key of keys) {
if (key.includes('.')) {
const parts = key.split('.')
for (let i = 1; i < parts.length; i++) {
const prefix = parts.slice(0, i).join('.')
if (keys.includes(prefix)) {
context.report({
node,
message: `Invalid key structure: '${key}' conflicts with '${prefix}'`,
})
}
keyPrefixes.add(prefix)
}
}
}
for (const key of keys) {
if (keyPrefixes.has(key)) {
context.report({
node,
message: `Invalid key structure: '${key}' is a prefix of another key`,
})
}
}
},
}
},
}
+10
View File
@@ -0,0 +1,10 @@
export const cleanJsonText = (text) => {
const cleaned = text.replaceAll(/,\s*\}/g, '}')
try {
JSON.parse(cleaned)
return cleaned
}
catch {
return text
}
}
+15 -9
View File
@@ -130,15 +130,6 @@ export default antfu(
sonarjs: sonar,
},
},
// allow generated i18n files (like i18n/*/workflow.ts) to exceed max-lines
{
files: ['i18n/**'],
rules: {
'sonarjs/max-lines': 'off',
'max-lines': 'off',
'jsonc/sort-keys': 'error',
},
},
tailwind.configs['flat/recommended'],
{
settings: {
@@ -191,4 +182,19 @@ export default antfu(
'dify-i18n/require-ns-option': 'error',
},
},
// i18n JSON validation rules
{
files: ['i18n/**/*.json'],
plugins: {
'dify-i18n': difyI18n,
},
rules: {
'sonarjs/max-lines': 'off',
'max-lines': 'off',
'jsonc/sort-keys': 'error',
'dify-i18n/valid-i18n-keys': 'error',
'dify-i18n/no-extra-keys': 'error',
},
},
)
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "أوقات الاتصال",
"modelProvider.card.buyQuota": "شراء حصة",
"modelProvider.card.callTimes": "أوقات الاتصال",
"modelProvider.card.modelAPI": "النماذج {{modelName}} تستخدم مفتاح واجهة برمجة التطبيقات.",
"modelProvider.card.modelNotSupported": "النماذج {{modelName}} غير مثبتة.",
"modelProvider.card.modelSupported": "النماذج {{modelName}} تستخدم هذا الحصة.",
"modelProvider.card.onTrial": "في التجربة",
"modelProvider.card.paid": "مدفوع",
"modelProvider.card.priorityUse": "أولوية الاستخدام",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "الرموز المجانية المتاحة المتبقية",
"modelProvider.rerankModel.key": "نموذج إعادة الترتيب",
"modelProvider.rerankModel.tip": "سيعيد نموذج إعادة الترتيب ترتيب قائمة المستندات المرشحة بناءً على المطابقة الدلالية مع استعلام المستخدم، مما يحسن نتائج الترتيب الدلالي",
"modelProvider.resetDate": "إعادة الضبط على {{date}}",
"modelProvider.searchModel": "نموذج البحث",
"modelProvider.selectModel": "اختر نموذجك",
"modelProvider.selector.emptySetting": "يرجى الانتقال إلى الإعدادات للتكوين",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Anrufzeiten",
"modelProvider.card.buyQuota": "Kontingent kaufen",
"modelProvider.card.callTimes": "Anrufzeiten",
"modelProvider.card.modelAPI": "{{modelName}}-Modelle verwenden den API-Schlüssel.",
"modelProvider.card.modelNotSupported": "{{modelName}}-Modelle sind nicht installiert.",
"modelProvider.card.modelSupported": "{{modelName}}-Modelle verwenden dieses Kontingent.",
"modelProvider.card.onTrial": "In Probe",
"modelProvider.card.paid": "Bezahlt",
"modelProvider.card.priorityUse": "Priorisierte Nutzung",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Verbleibende verfügbare kostenlose Token",
"modelProvider.rerankModel.key": "Rerank-Modell",
"modelProvider.rerankModel.tip": "Rerank-Modell wird die Kandidatendokumentenliste basierend auf der semantischen Übereinstimmung mit der Benutzeranfrage neu ordnen und die Ergebnisse der semantischen Rangordnung verbessern",
"modelProvider.resetDate": "Zurücksetzen bei {{date}}",
"modelProvider.searchModel": "Suchmodell",
"modelProvider.selectModel": "Wählen Sie Ihr Modell",
"modelProvider.selector.emptySetting": "Bitte gehen Sie zu den Einstellungen, um zu konfigurieren",
+1
View File
@@ -666,6 +666,7 @@
"nodes.llm.roleDescription.user": "Provide instructions, queries, or any text-based input to the model",
"nodes.llm.singleRun.variable": "Variable",
"nodes.llm.sysQueryInUser": "sys.query in user message is required",
"nodes.llm.tools.title": "Tools",
"nodes.llm.variables": "variables",
"nodes.llm.vision": "vision",
"nodes.loop.ErrorMethod.continueOnError": "Continue on Error",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Tiempos de llamada",
"modelProvider.card.buyQuota": "Comprar Cuota",
"modelProvider.card.callTimes": "Tiempos de llamada",
"modelProvider.card.modelAPI": "Los modelos {{modelName}} están usando la clave de API.",
"modelProvider.card.modelNotSupported": "Los modelos {{modelName}} no están instalados.",
"modelProvider.card.modelSupported": "Los modelos {{modelName}} están utilizando esta cuota.",
"modelProvider.card.onTrial": "En prueba",
"modelProvider.card.paid": "Pagado",
"modelProvider.card.priorityUse": "Uso prioritario",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Tokens gratuitos restantes disponibles",
"modelProvider.rerankModel.key": "Modelo de Reordenar",
"modelProvider.rerankModel.tip": "El modelo de reordenar reordenará la lista de documentos candidatos basada en la coincidencia semántica con la consulta del usuario, mejorando los resultados de clasificación semántica",
"modelProvider.resetDate": "Reiniciar en {{date}}",
"modelProvider.searchModel": "Modelo de búsqueda",
"modelProvider.selectModel": "Selecciona tu modelo",
"modelProvider.selector.emptySetting": "Por favor ve a configuraciones para configurar",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "تعداد فراخوانی",
"modelProvider.card.buyQuota": "خرید سهمیه",
"modelProvider.card.callTimes": "تعداد فراخوانی",
"modelProvider.card.modelAPI": "مدل‌های {{modelName}} در حال استفاده از کلید API هستند.",
"modelProvider.card.modelNotSupported": "مدل‌های {{modelName}} نصب نشده‌اند.",
"modelProvider.card.modelSupported": "مدل‌های {{modelName}} از این سهمیه استفاده می‌کنند.",
"modelProvider.card.onTrial": "در حال آزمایش",
"modelProvider.card.paid": "پرداخت شده",
"modelProvider.card.priorityUse": "استفاده با اولویت",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "توکن‌های رایگان باقی‌مانده در دسترس",
"modelProvider.rerankModel.key": "مدل رتبه‌بندی مجدد",
"modelProvider.rerankModel.tip": "مدل رتبه‌بندی مجدد، لیست اسناد کاندید را بر اساس تطابق معنایی با پرسش کاربر مرتب می‌کند و نتایج رتبه‌بندی معنایی را بهبود می‌بخشد",
"modelProvider.resetDate": "بازنشانی در {{date}}",
"modelProvider.searchModel": "جستجوی مدل",
"modelProvider.selectModel": "مدل خود را انتخاب کنید",
"modelProvider.selector.emptySetting": "لطفاً به تنظیمات بروید تا پیکربندی کنید",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Temps d'appel",
"modelProvider.card.buyQuota": "Acheter Quota",
"modelProvider.card.callTimes": "Temps d'appel",
"modelProvider.card.modelAPI": "Les modèles {{modelName}} utilisent la clé API.",
"modelProvider.card.modelNotSupported": "Les modèles {{modelName}} ne sont pas installés.",
"modelProvider.card.modelSupported": "Les modèles {{modelName}} utilisent ce quota.",
"modelProvider.card.onTrial": "En Essai",
"modelProvider.card.paid": "Payé",
"modelProvider.card.priorityUse": "Utilisation prioritaire",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Tokens gratuits restants disponibles",
"modelProvider.rerankModel.key": "Modèle de Réorganisation",
"modelProvider.rerankModel.tip": "Le modèle de réorganisation réorganisera la liste des documents candidats en fonction de la correspondance sémantique avec la requête de l'utilisateur, améliorant ainsi les résultats du classement sémantique.",
"modelProvider.resetDate": "Réinitialiser sur {{date}}",
"modelProvider.searchModel": "Modèle de recherche",
"modelProvider.selectModel": "Sélectionnez votre modèle",
"modelProvider.selector.emptySetting": "Veuillez aller dans les paramètres pour configurer",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "कॉल समय",
"modelProvider.card.buyQuota": "कोटा खरीदें",
"modelProvider.card.callTimes": "कॉल समय",
"modelProvider.card.modelAPI": "{{modelName}} मॉडल एपीआई कुंजी का उपयोग कर रहे हैं।",
"modelProvider.card.modelNotSupported": "{{modelName}} मॉडल इंस्टॉल नहीं हैं।",
"modelProvider.card.modelSupported": "{{modelName}} मॉडल इस कोटा का उपयोग कर रहे हैं।",
"modelProvider.card.onTrial": "परीक्षण पर",
"modelProvider.card.paid": "भुगतान किया हुआ",
"modelProvider.card.priorityUse": "प्राथमिकता उपयोग",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "बचे हुए उपलब्ध मुफ्त टोकन",
"modelProvider.rerankModel.key": "रीरैंक मॉडल",
"modelProvider.rerankModel.tip": "रीरैंक मॉडल उपयोगकर्ता प्रश्न के साथ सांविधिक मेल के आधार पर उम्मीदवार दस्तावेज़ सूची को पुनः क्रमित करेगा, सांविधिक रैंकिंग के परिणामों में सुधार करेगा।",
"modelProvider.resetDate": "{{date}} पर रीसेट करें",
"modelProvider.searchModel": "खोज मॉडल",
"modelProvider.selectModel": "अपने मॉडल का चयन करें",
"modelProvider.selector.emptySetting": "कॉन्फ़िगर करने के लिए कृपया सेटिंग्स पर जाएं",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Waktu panggilan",
"modelProvider.card.buyQuota": "Beli Kuota",
"modelProvider.card.callTimes": "Waktu panggilan",
"modelProvider.card.modelAPI": "Model {{modelName}} sedang menggunakan API Key.",
"modelProvider.card.modelNotSupported": "Model {{modelName}} tidak terpasang.",
"modelProvider.card.modelSupported": "Model {{modelName}} sedang menggunakan kuota ini.",
"modelProvider.card.onTrial": "Sedang Diadili",
"modelProvider.card.paid": "Dibayar",
"modelProvider.card.priorityUse": "Penggunaan prioritas",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Token gratis yang masih tersedia",
"modelProvider.rerankModel.key": "Peringkat ulang Model",
"modelProvider.rerankModel.tip": "Model rerank akan menyusun ulang daftar dokumen kandidat berdasarkan kecocokan semantik dengan kueri pengguna, meningkatkan hasil peringkat semantik",
"modelProvider.resetDate": "Atur ulang pada {{date}}",
"modelProvider.searchModel": "Model pencarian",
"modelProvider.selectModel": "Pilih model Anda",
"modelProvider.selector.emptySetting": "Silakan buka pengaturan untuk mengonfigurasi",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Numero di chiamate",
"modelProvider.card.buyQuota": "Acquista Quota",
"modelProvider.card.callTimes": "Numero di chiamate",
"modelProvider.card.modelAPI": "I modelli {{modelName}} stanno utilizzando la chiave API.",
"modelProvider.card.modelNotSupported": "I modelli {{modelName}} non sono installati.",
"modelProvider.card.modelSupported": "I modelli {{modelName}} stanno utilizzando questa quota.",
"modelProvider.card.onTrial": "In Prova",
"modelProvider.card.paid": "Pagato",
"modelProvider.card.priorityUse": "Uso prioritario",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Token gratuiti rimanenti disponibili",
"modelProvider.rerankModel.key": "Modello di Rerank",
"modelProvider.rerankModel.tip": "Il modello di rerank riordinerà la lista dei documenti candidati basandosi sulla corrispondenza semantica con la query dell'utente, migliorando i risultati del ranking semantico",
"modelProvider.resetDate": "Reimposta su {{date}}",
"modelProvider.searchModel": "Modello di ricerca",
"modelProvider.selectModel": "Seleziona il tuo modello",
"modelProvider.selector.emptySetting": "Per favore vai alle impostazioni per configurare",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "호출 횟수",
"modelProvider.card.buyQuota": "Buy Quota",
"modelProvider.card.callTimes": "호출 횟수",
"modelProvider.card.modelAPI": "{{modelName}} 모델이 API 키를 사용하고 있습니다.",
"modelProvider.card.modelNotSupported": "{{modelName}} 모델이 설치되지 않았습니다.",
"modelProvider.card.modelSupported": "{{modelName}} 모델이 이 할당량을 사용하고 있습니다.",
"modelProvider.card.onTrial": "트라이얼 중",
"modelProvider.card.paid": "유료",
"modelProvider.card.priorityUse": "우선 사용",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "남은 무료 토큰 사용 가능",
"modelProvider.rerankModel.key": "재랭크 모델",
"modelProvider.rerankModel.tip": "재랭크 모델은 사용자 쿼리와의 의미적 일치를 기반으로 후보 문서 목록을 재배열하여 의미적 순위를 향상시킵니다.",
"modelProvider.resetDate": "{{date}}에서 재설정",
"modelProvider.searchModel": "검색 모델",
"modelProvider.selectModel": "모델 선택",
"modelProvider.selector.emptySetting": "설정으로 이동하여 구성하세요",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Czasy wywołań",
"modelProvider.card.buyQuota": "Kup limit",
"modelProvider.card.callTimes": "Czasy wywołań",
"modelProvider.card.modelAPI": "Modele {{modelName}} używają klucza API.",
"modelProvider.card.modelNotSupported": "Modele {{modelName}} nie są zainstalowane.",
"modelProvider.card.modelSupported": "{{modelName}} modeli korzysta z tej kwoty.",
"modelProvider.card.onTrial": "Na próbę",
"modelProvider.card.paid": "Płatny",
"modelProvider.card.priorityUse": "Używanie z priorytetem",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Pozostałe dostępne darmowe tokeny",
"modelProvider.rerankModel.key": "Model ponownego rankingu",
"modelProvider.rerankModel.tip": "Model ponownego rankingu zmieni kolejność listy dokumentów kandydatów na podstawie semantycznego dopasowania z zapytaniem użytkownika, poprawiając wyniki rankingu semantycznego",
"modelProvider.resetDate": "Reset na {{date}}",
"modelProvider.searchModel": "Model wyszukiwania",
"modelProvider.selectModel": "Wybierz swój model",
"modelProvider.selector.emptySetting": "Przejdź do ustawień, aby skonfigurować",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Chamadas",
"modelProvider.card.buyQuota": "Comprar Quota",
"modelProvider.card.callTimes": "Chamadas",
"modelProvider.card.modelAPI": "Os modelos {{modelName}} estão usando a Chave de API.",
"modelProvider.card.modelNotSupported": "Modelos {{modelName}} não estão instalados.",
"modelProvider.card.modelSupported": "Modelos {{modelName}} estão usando esta cota.",
"modelProvider.card.onTrial": "Em Teste",
"modelProvider.card.paid": "Pago",
"modelProvider.card.priorityUse": "Uso prioritário",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Tokens gratuitos disponíveis restantes",
"modelProvider.rerankModel.key": "Modelo de Reordenação",
"modelProvider.rerankModel.tip": "O modelo de reordenaenação reorganizará a lista de documentos candidatos com base na correspondência semântica com a consulta do usuário, melhorando os resultados da classificação semântica",
"modelProvider.resetDate": "Redefinir em {{date}}",
"modelProvider.searchModel": "Modelo de pesquisa",
"modelProvider.selectModel": "Selecione seu modelo",
"modelProvider.selector.emptySetting": "Por favor, vá para configurações para configurar",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Apeluri",
"modelProvider.card.buyQuota": "Cumpără cotă",
"modelProvider.card.callTimes": "Apeluri",
"modelProvider.card.modelAPI": "Modelele {{modelName}} folosesc cheia API.",
"modelProvider.card.modelNotSupported": "Modelele {{modelName}} nu sunt instalate.",
"modelProvider.card.modelSupported": "{{modelName}} modele utilizează această cotă.",
"modelProvider.card.onTrial": "În probă",
"modelProvider.card.paid": "Plătit",
"modelProvider.card.priorityUse": "Utilizare prioritară",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Jetoane gratuite disponibile rămase",
"modelProvider.rerankModel.key": "Model de reordonare",
"modelProvider.rerankModel.tip": "Modelul de reordonare va reordona lista de documente candidate pe baza potrivirii semantice cu interogarea utilizatorului, îmbunătățind rezultatele clasificării semantice",
"modelProvider.resetDate": "Resetați la {{date}}",
"modelProvider.searchModel": "Model de căutare",
"modelProvider.selectModel": "Selectați modelul dvs.",
"modelProvider.selector.emptySetting": "Vă rugăm să mergeți la setări pentru a configura",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Количество вызовов",
"modelProvider.card.buyQuota": "Купить квоту",
"modelProvider.card.callTimes": "Количество вызовов",
"modelProvider.card.modelAPI": "{{modelName}} модели используют ключ API.",
"modelProvider.card.modelNotSupported": "Модели {{modelName}} не установлены.",
"modelProvider.card.modelSupported": "Эту квоту используют модели {{modelName}}.",
"modelProvider.card.onTrial": "Пробная версия",
"modelProvider.card.paid": "Платный",
"modelProvider.card.priorityUse": "Приоритетное использование",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Оставшиеся доступные бесплатные токены",
"modelProvider.rerankModel.key": "Модель повторного ранжирования",
"modelProvider.rerankModel.tip": "Модель повторного ранжирования изменит порядок списка документов-кандидатов на основе семантического соответствия запросу пользователя, улучшая результаты семантического ранжирования",
"modelProvider.resetDate": "Сброс на {{date}}",
"modelProvider.searchModel": "Поиск модели",
"modelProvider.selectModel": "Выберите свою модель",
"modelProvider.selector.emptySetting": "Пожалуйста, перейдите в настройки для настройки",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Število klicev",
"modelProvider.card.buyQuota": "Kupi kvoto",
"modelProvider.card.callTimes": "Časi klicev",
"modelProvider.card.modelAPI": "{{modelName}} modeli uporabljajo API ključ.",
"modelProvider.card.modelNotSupported": "{{modelName}} modeli niso nameščeni.",
"modelProvider.card.modelSupported": "{{modelName}} modeli uporabljajo to kvoto.",
"modelProvider.card.onTrial": "Na preizkusu",
"modelProvider.card.paid": "Plačano",
"modelProvider.card.priorityUse": "Prednostna uporaba",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Preostali razpoložljivi brezplačni žetoni",
"modelProvider.rerankModel.key": "Model za prerazvrstitev",
"modelProvider.rerankModel.tip": "Model za prerazvrstitev bo prerazporedil seznam kandidatskih dokumentov na podlagi semantične ujemanja z uporabniško poizvedbo, s čimer se izboljšajo rezultati semantičnega razvrščanja.",
"modelProvider.resetDate": "Ponastavi na {{date}}",
"modelProvider.searchModel": "Model iskanja",
"modelProvider.selectModel": "Izberite svoj model",
"modelProvider.selector.emptySetting": "Prosimo, pojdite v nastavitve za konfiguracijo",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "เวลาโทร",
"modelProvider.card.buyQuota": "ซื้อโควต้า",
"modelProvider.card.callTimes": "เวลาโทร",
"modelProvider.card.modelAPI": "{{modelName}} โมเดลกำลังใช้คีย์ API",
"modelProvider.card.modelNotSupported": "โมเดล {{modelName}} ยังไม่ได้ติดตั้ง",
"modelProvider.card.modelSupported": "โมเดล {{modelName}} กำลังใช้โควต้านี้อยู่",
"modelProvider.card.onTrial": "ทดลองใช้",
"modelProvider.card.paid": "จ่าย",
"modelProvider.card.priorityUse": "ลําดับความสําคัญในการใช้งาน",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "โทเค็นฟรีที่เหลืออยู่",
"modelProvider.rerankModel.key": "จัดอันดับโมเดลใหม่",
"modelProvider.rerankModel.tip": "โมเดล Rerank จะจัดลําดับรายการเอกสารผู้สมัครใหม่ตามการจับคู่ความหมายกับการสืบค้นของผู้ใช้ ซึ่งช่วยปรับปรุงผลลัพธ์ของการจัดอันดับความหมาย",
"modelProvider.resetDate": "รีเซ็ตเมื่อ {{date}}",
"modelProvider.searchModel": "ค้นหารุ่น",
"modelProvider.selectModel": "เลือกรุ่นของคุณ",
"modelProvider.selector.emptySetting": "โปรดไปที่การตั้งค่าเพื่อกําหนดค่า",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Çağrı Süreleri",
"modelProvider.card.buyQuota": "Kota Satın Al",
"modelProvider.card.callTimes": "Çağrı Süreleri",
"modelProvider.card.modelAPI": "{{modelName}} modelleri API Anahtarını kullanıyor.",
"modelProvider.card.modelNotSupported": "{{modelName}} modelleri yüklü değil.",
"modelProvider.card.modelSupported": "{{modelName}} modelleri bu kotayı kullanıyor.",
"modelProvider.card.onTrial": "Deneme Sürümünde",
"modelProvider.card.paid": "Ücretli",
"modelProvider.card.priorityUse": "Öncelikli Kullan",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Kalan kullanılabilir ücretsiz tokenler",
"modelProvider.rerankModel.key": "Yeniden Sıralama Modeli",
"modelProvider.rerankModel.tip": "Yeniden sıralama modeli, kullanıcı sorgusuyla anlam eşleştirmesine dayalı olarak aday belge listesini yeniden sıralayacak ve anlam sıralama sonuçlarını iyileştirecektir.",
"modelProvider.resetDate": "{{date}} üzerine sıfırlama",
"modelProvider.searchModel": "Model ara",
"modelProvider.selectModel": "Modelinizi seçin",
"modelProvider.selector.emptySetting": "Lütfen ayarlara gidip yapılandırın",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Кількість викликів",
"modelProvider.card.buyQuota": "Придбати квоту",
"modelProvider.card.callTimes": "Кількість викликів",
"modelProvider.card.modelAPI": "Моделі {{modelName}} використовують API-ключ.",
"modelProvider.card.modelNotSupported": "Моделі {{modelName}} не встановлені.",
"modelProvider.card.modelSupported": "Моделі {{modelName}} використовують цю квоту.",
"modelProvider.card.onTrial": "У пробному періоді",
"modelProvider.card.paid": "Оплачено",
"modelProvider.card.priorityUse": "Пріоритетне використання",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Залишилося доступних безкоштовних токенів",
"modelProvider.rerankModel.key": "Модель повторного ранжування",
"modelProvider.rerankModel.tip": "Модель повторного ранжування змінить порядок списку документів-кандидатів на основі семантичної відповідності запиту користувача, покращуючи результати семантичного ранжування.",
"modelProvider.resetDate": "Скинути на {{date}}",
"modelProvider.searchModel": "Пошукова модель",
"modelProvider.selectModel": "Виберіть свою модель",
"modelProvider.selector.emptySetting": "Перейдіть до налаштувань, щоб налаштувати",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "Số lần gọi",
"modelProvider.card.buyQuota": "Mua Quota",
"modelProvider.card.callTimes": "Số lần gọi",
"modelProvider.card.modelAPI": "Các mô hình {{modelName}} đang sử dụng Khóa API.",
"modelProvider.card.modelNotSupported": "Các mô hình {{modelName}} chưa được cài đặt.",
"modelProvider.card.modelSupported": "{{modelName}} mô hình đang sử dụng hạn mức này.",
"modelProvider.card.onTrial": "Thử nghiệm",
"modelProvider.card.paid": "Đã thanh toán",
"modelProvider.card.priorityUse": "Ưu tiên sử dụng",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Số lượng mã thông báo miễn phí còn lại",
"modelProvider.rerankModel.key": "Mô hình Sắp xếp lại",
"modelProvider.rerankModel.tip": "Mô hình sắp xếp lại sẽ sắp xếp lại danh sách tài liệu ứng cử viên dựa trên sự phù hợp ngữ nghĩa với truy vấn của người dùng, cải thiện kết quả của việc xếp hạng ngữ nghĩa",
"modelProvider.resetDate": "Đặt lại vào {{date}}",
"modelProvider.searchModel": "Mô hình tìm kiếm",
"modelProvider.selectModel": "Chọn mô hình của bạn",
"modelProvider.selector.emptySetting": "Vui lòng vào cài đặt để cấu hình",
-4
View File
@@ -339,9 +339,6 @@
"modelProvider.callTimes": "呼叫次數",
"modelProvider.card.buyQuota": "購買額度",
"modelProvider.card.callTimes": "呼叫次數",
"modelProvider.card.modelAPI": "{{modelName}} 模型正在使用 API 金鑰。",
"modelProvider.card.modelNotSupported": "{{modelName}} 模型未安裝。",
"modelProvider.card.modelSupported": "{{modelName}} 模型正在使用這個配額。",
"modelProvider.card.onTrial": "試用中",
"modelProvider.card.paid": "已購買",
"modelProvider.card.priorityUse": "優先使用",
@@ -397,7 +394,6 @@
"modelProvider.quotaTip": "剩餘免費額度",
"modelProvider.rerankModel.key": "Rerank 模型",
"modelProvider.rerankModel.tip": "重排序模型將根據候選文件列表與使用者問題語義匹配度進行重新排序,從而改進語義排序的結果",
"modelProvider.resetDate": "在 {{date}} 重置",
"modelProvider.searchModel": "搜尋模型",
"modelProvider.selectModel": "選擇您的模型",
"modelProvider.selector.emptySetting": "請前往設定進行配置",
+1 -1
View File
@@ -47,7 +47,7 @@
"knip": "knip"
},
"dependencies": {
"@amplitude/analytics-browser": "^2.31.3",
"@amplitude/analytics-browser": "^2.33.1",
"@amplitude/plugin-session-replay-browser": "^1.23.6",
"@emoji-mart/data": "^1.2.1",
"@floating-ui/react": "^0.26.28",
+45 -36
View File
@@ -58,8 +58,8 @@ importers:
.:
dependencies:
'@amplitude/analytics-browser':
specifier: ^2.31.3
version: 2.31.4
specifier: ^2.33.1
version: 2.33.1
'@amplitude/plugin-session-replay-browser':
specifier: ^1.23.6
version: 1.24.1(@amplitude/rrweb@2.0.0-alpha.33)(rollup@2.79.2)
@@ -578,8 +578,8 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@amplitude/analytics-browser@2.31.4':
resolution: {integrity: sha512-9O8a0SK55tQOgJJ0z9eE+q/C2xWo6a65wN4iSglxYwm1vvGJKG6Z/QV4XKQ6X0syGscRuG1XoMc0mt3xdVPtDg==}
'@amplitude/analytics-browser@2.33.1':
resolution: {integrity: sha512-93wZjuAFJ7QdyptF82i1pezm5jKuBWITHI++XshDgpks1RstJvJ9n11Ak8MnE4L2BGQ93XDN2aVEHfmQkt0/Pw==}
'@amplitude/analytics-client-common@2.4.16':
resolution: {integrity: sha512-qF7NAl6Qr6QXcWKnldGJfO0Kp1TYoy1xsmzEDnOYzOS96qngtvsZ8MuKya1lWdVACoofwQo82V0VhNZJKk/2YA==}
@@ -590,29 +590,32 @@ packages:
'@amplitude/analytics-core@2.33.0':
resolution: {integrity: sha512-56m0R12TjZ41D2YIghb/XNHSdL4CurAVyRT3L2FD+9DCFfbgjfT8xhDBnsZtA+aBkb6Yak1EGUojGBunfAm2/A==}
'@amplitude/analytics-core@2.35.0':
resolution: {integrity: sha512-7RmHYELXCGu8yuO9D6lEXiqkMtiC5sePNhCWmwuP30dneDYHtH06gaYvAFH/YqOFuE6enwEEJfFYtcaPhyiqtA==}
'@amplitude/analytics-types@2.11.0':
resolution: {integrity: sha512-L1niBXYSWmbyHUE/GNuf6YBljbafaxWI3X5jjEIZDFCjQvdWO3DKalY1VPFUbhgYQgWw7+bC6I/AlUaporyfig==}
'@amplitude/experiment-core@0.7.2':
resolution: {integrity: sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==}
'@amplitude/plugin-autocapture-browser@1.18.0':
resolution: {integrity: sha512-hBBZpghTEnl+XF8UZaGxe1xCbSjawdmOkJC0/tQF2k1FwlJS/rdWBGmPd8wH7iU4hd55pnSw28Kd2NL7q0zTcA==}
'@amplitude/plugin-autocapture-browser@1.18.3':
resolution: {integrity: sha512-njYque5t1QCEEe5V8Ls4yVVklTM6V7OXxBk6pqznN/hj/Pc4X8Wjy898pZ2VtbnvpagBKKzGb5B6Syl8OXiicw==}
'@amplitude/plugin-network-capture-browser@1.7.0':
resolution: {integrity: sha512-tlwkBL0tlc1OUTT2XYTjWx4mm6O0DSggKzkkDq+8DhW+ZFl9OfHMFIh/hDLJzxs1LTtX7CvFUfAVSDifJOs+NA==}
'@amplitude/plugin-network-capture-browser@1.7.3':
resolution: {integrity: sha512-zfWgAN7g6AigJAsgrGmlgVwydOHH6XvweBoxhU+qEvRydboiIVCDLSxuXczUsBG7kYVLWRdBK1DYoE5J7lqTGA==}
'@amplitude/plugin-page-url-enrichment-browser@0.5.6':
resolution: {integrity: sha512-H6+tf0zYhvM+8oJsdC/kAbIzuxOY/0p+3HBmX4K+G4doo5nCGAB0DYTr6dqMp1GcPOZ09pKT41+DJ6vwSy4ypQ==}
'@amplitude/plugin-page-url-enrichment-browser@0.5.9':
resolution: {integrity: sha512-TqdELx4WrdRutCjHUFUzum/f/UjhbdTZw0UKkYFAj5gwAKDjaPEjL4waRvINOTaVLsne1A6ck4KEMfC8AKByFw==}
'@amplitude/plugin-page-view-tracking-browser@2.6.3':
resolution: {integrity: sha512-lLU4W2r5jXtfn/14cZKM9c9CQDxT7PVVlgm0susHJ3Kfsua9jJQuMHs4Zlg6rwByAtZi5nF4nYE5z0GF09gx0A==}
'@amplitude/plugin-page-view-tracking-browser@2.6.6':
resolution: {integrity: sha512-dBcJlrdKgPzSgS3exDRRrMLqhIaOjwlIy7o8sEMn1PpMawERlbumSSdtfII6L4L67HYUPo4PY4Kp4acqSzaLvQ==}
'@amplitude/plugin-session-replay-browser@1.24.1':
resolution: {integrity: sha512-NHePIu2Yv9ba+fOt5N33b8FFQPzyKvjs1BnWBgBCM5RECos3w6n/+zUWTnTJ4at2ipO2lz111abKDteUwbuptg==}
'@amplitude/plugin-web-vitals-browser@1.1.0':
resolution: {integrity: sha512-TA0X4Np4Wt5hkQ4+Ouhg6nm2xjDd9l03OV9N8Kbe1cqpr/sxvRwSpd+kp2eREbp6D7tHFFkKJA2iNtxbE5Y0cA==}
'@amplitude/plugin-web-vitals-browser@1.1.4':
resolution: {integrity: sha512-XQXI9OjTNSz2yi0lXw2VYMensDzzSkMCfvXNniTb1LgnHwBcQ1JWPcTqHLPFrvvNckeIdOT78vjs7yA+c1FyzA==}
'@amplitude/rrdom@2.0.0-alpha.33':
resolution: {integrity: sha512-uu+1w1RGEJ7QcGPwCC898YBR47DpNYOZTnQMY9/IgMzTXQ0+Hh1/JLsQfMnBBtAePhvCS0BlHd/qGD5w0taIcg==}
@@ -8734,8 +8737,8 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
web-vitals@5.0.1:
resolution: {integrity: sha512-BsULPWaCKAAtNntUz0aJq1cu1wyuWmDzf4N6vYNMbYA6zzQAf2pzCYbyClf+Ui2MI54bt225AwugXIfL1W+Syg==}
web-vitals@5.1.0:
resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
@@ -9016,14 +9019,14 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@amplitude/analytics-browser@2.31.4':
'@amplitude/analytics-browser@2.33.1':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/plugin-autocapture-browser': 1.18.0
'@amplitude/plugin-network-capture-browser': 1.7.0
'@amplitude/plugin-page-url-enrichment-browser': 0.5.6
'@amplitude/plugin-page-view-tracking-browser': 2.6.3
'@amplitude/plugin-web-vitals-browser': 1.1.0
'@amplitude/analytics-core': 2.35.0
'@amplitude/plugin-autocapture-browser': 1.18.3
'@amplitude/plugin-network-capture-browser': 1.7.3
'@amplitude/plugin-page-url-enrichment-browser': 0.5.9
'@amplitude/plugin-page-view-tracking-browser': 2.6.6
'@amplitude/plugin-web-vitals-browser': 1.1.4
tslib: 2.8.1
'@amplitude/analytics-client-common@2.4.16':
@@ -9041,31 +9044,37 @@ snapshots:
tslib: 2.8.1
zen-observable-ts: 1.1.0
'@amplitude/analytics-core@2.35.0':
dependencies:
'@amplitude/analytics-connector': 1.6.4
tslib: 2.8.1
zen-observable-ts: 1.1.0
'@amplitude/analytics-types@2.11.0': {}
'@amplitude/experiment-core@0.7.2':
dependencies:
js-base64: 3.7.8
'@amplitude/plugin-autocapture-browser@1.18.0':
'@amplitude/plugin-autocapture-browser@1.18.3':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
rxjs: 7.8.2
tslib: 2.8.1
'@amplitude/plugin-network-capture-browser@1.7.0':
'@amplitude/plugin-network-capture-browser@1.7.3':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
tslib: 2.8.1
'@amplitude/plugin-page-url-enrichment-browser@0.5.6':
'@amplitude/plugin-page-url-enrichment-browser@0.5.9':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
tslib: 2.8.1
'@amplitude/plugin-page-view-tracking-browser@2.6.3':
'@amplitude/plugin-page-view-tracking-browser@2.6.6':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
tslib: 2.8.1
'@amplitude/plugin-session-replay-browser@1.24.1(@amplitude/rrweb@2.0.0-alpha.33)(rollup@2.79.2)':
@@ -9080,11 +9089,11 @@ snapshots:
- '@amplitude/rrweb'
- rollup
'@amplitude/plugin-web-vitals-browser@1.1.0':
'@amplitude/plugin-web-vitals-browser@1.1.4':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
tslib: 2.8.1
web-vitals: 5.0.1
web-vitals: 5.1.0
'@amplitude/rrdom@2.0.0-alpha.33':
dependencies:
@@ -9148,7 +9157,7 @@ snapshots:
'@amplitude/targeting@0.2.0':
dependencies:
'@amplitude/analytics-client-common': 2.4.16
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
'@amplitude/analytics-types': 2.11.0
'@amplitude/experiment-core': 0.7.2
idb: 8.0.0
@@ -18336,7 +18345,7 @@ snapshots:
web-namespaces@2.0.1: {}
web-vitals@5.0.1: {}
web-vitals@5.1.0: {}
webidl-conversions@4.0.2: {}