Compare commits

...
Author SHA1 Message Date
Frederick2313072 626e71cb3b feat: implement content-based deduplication for document segments
- Add database index on (dataset_id, index_node_hash) for efficient deduplication queries
- Add deduplication check in SegmentService.create_segment and multi_create_segment methods
- Add deduplication check in DatasetDocumentStore.add_documents method to prevent duplicate embedding processing
- Skip creating segments with identical content hashes across the entire dataset

This prevents duplicate content from being re-processed and re-embedded when uploading documents with repeated content, improving efficiency and reducing unnecessary compute costs.
2025-09-20 06:28:14 +08:00
Frederick2313072 07047487c3 fix 2025-09-20 05:41:25 +08:00
Frederick2313072 c7064d44af feat: local dev 2025-09-20 05:30:39 +08:00
lyzno1andGitHub e93bfe3d41 fix: resolve chat sidebar UI bugs for hover panel and dropdown menu (#25813) 2025-09-19 18:28:49 +08:00
GuanMuandGitHub ab910c736c feat(goto-anything): add RAG pipeline node search (#25948) 2025-09-19 18:28:13 +08:00
YeuolyandGitHub 4047a6bb12 fix: ensure original response are maintained by yielding text messages in ApiTool (#23456) (#25973) 2025-09-19 18:27:33 +08:00
df2478dc26 chore: translate i18n files and update type definitions (#25964)
Co-authored-by: WTW0313 <30284043+WTW0313@users.noreply.github.com>
2025-09-19 18:27:09 +08:00
-LAN-andGitHub 4cc3f6045b Run import-linter within make lint (#25933) 2025-09-19 18:26:43 +08:00
JoelandGitHub 1550316b8d fix: undefined match the wrong output schema (#25971) 2025-09-19 17:03:09 +08:00
Wu TianweiandGitHub 87394d2512 fix: enhance model parameter handling with advanced mode support and localization updates (#25963) 2025-09-19 15:47:52 +08:00
Wu TianweiandGitHub bad59c95bc fix: update details display to conditionally show creator information (#25952) 2025-09-19 15:45:45 +08:00
Xiyuan ChenandGitHub 9f138ef246 Refactor WorkflowService to handle missing default credentials gracef… (#25960) 2025-09-19 00:45:35 -07:00
zxhlyhandGitHub 6453fc4973 fix: refresh datasource list after install datasource (#25949) 2025-09-19 11:03:45 +08:00
f62f926537 style: update GotoAnything component styling (#25929)
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2025-09-19 10:36:43 +08:00
Yongtao HuangandGitHub b3dafd913b Chore: correct inconsistent logging and typo (#25945) 2025-09-19 10:36:16 +08:00
-LAN-andGitHub b2d8a7eaf1 Fix: enforce editor-only access to chat message logs (#25936) 2025-09-18 21:59:51 +08:00
GuanMuandGitHub 3e54414191 chore: update post_create_command.sh to use dynamic workspace root for aliases (#25913) 2025-09-18 21:09:43 +08:00
-LAN-GitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
a173546c8d Fix: replace stdout prints with debug logging (#25931)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-09-18 21:03:20 +08:00
-LAN-andGitHub aa69d90489 fix(makefile): correct uv project path for lint target (#25818) 2025-09-18 20:36:26 +08:00
-LAN-andGitHub 4ba1292455 refactor: replace print statements with proper logging (#25773) 2025-09-18 20:35:47 +08:00
MariesandGitHub bb01c31f30 fix(api): enhance data handling in RagPipelineDslService to filter credentials (#25926) 2025-09-18 18:36:49 +08:00
Wu TianweiandGitHub cd90b2ca9e refactor: replace useInvalid with useInvalidCustomizedTemplateList (#25924) 2025-09-18 18:17:20 +08:00
heysztandGitHub 9a65350cf7 fix: rollback aliyun_trace icon (#25921) 2025-09-18 18:01:08 +08:00
quicksandandGitHub 680eb7a9f6 fix(datasets): retrieval_model null issue when updating dataset info (#25907) 2025-09-18 17:58:06 +08:00
crazywoolaandGitHub 878420463c fix: Message => str (#25876) 2025-09-18 17:57:57 +08:00
zxhlyhandGitHub 4692e20daf fix: workflow header style (#25922) 2025-09-18 17:53:40 +08:00
79 changed files with 1116 additions and 394 deletions
+7 -6
View File
@@ -1,15 +1,16 @@
#!/bin/bash
WORKSPACE_ROOT=$(pwd)
corepack enable
cd web && pnpm install
pipx install uv
echo 'alias start-api="cd /workspaces/dify/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug"' >> ~/.bashrc
echo 'alias start-worker="cd /workspaces/dify/api && uv run python -m celery -A app.celery worker -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion,plugin,workflow_storage"' >> ~/.bashrc
echo 'alias start-web="cd /workspaces/dify/web && pnpm dev"' >> ~/.bashrc
echo 'alias start-web-prod="cd /workspaces/dify/web && pnpm build && pnpm start"' >> ~/.bashrc
echo 'alias start-containers="cd /workspaces/dify/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d"' >> ~/.bashrc
echo 'alias stop-containers="cd /workspaces/dify/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env down"' >> ~/.bashrc
echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion,plugin,workflow_storage\"" >> ~/.bashrc
echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev\"" >> ~/.bashrc
echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc
echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc
echo "alias stop-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env down\"" >> ~/.bashrc
source /home/vscode/.bashrc
+6 -1
View File
@@ -147,6 +147,7 @@ api/.idea
api/.env
api/storage/*
api/Dockerfile.local
docker-legacy/volumes/app/storage/*
docker-legacy/volumes/db/data/*
@@ -230,4 +231,8 @@ api/.env.backup
# Benchmark
scripts/stress-test/setup/config/
scripts/stress-test/reports/
scripts/stress-test/reports/
# mcp
.playwright-mcp/
.serena/
+3 -2
View File
@@ -61,8 +61,9 @@ check:
@echo "✅ Code check complete"
lint:
@echo "🔧 Running ruff format and check with fixes..."
@uv run --directory api --dev sh -c 'ruff format ./api && ruff check --fix ./api'
@echo "🔧 Running ruff format, check with fixes, and import linter..."
@uv run --project api --dev sh -c 'ruff format ./api && ruff check --fix ./api'
@uv run --directory api --dev lint-imports
@echo "✅ Linting complete"
type-check:
+8
View File
@@ -30,6 +30,7 @@ select = [
"RUF022", # unsorted-dunder-all
"S506", # unsafe-yaml-load
"SIM", # flake8-simplify rules
"T201", # print-found
"TRY400", # error-instead-of-exception
"TRY401", # verbose-log-message
"UP", # pyupgrade rules
@@ -91,11 +92,18 @@ ignore = [
"configs/*" = [
"N802", # invalid-function-name
]
"core/model_runtime/callbacks/base_callback.py" = [
"T201",
]
"core/workflow/callbacks/workflow_logging_callback.py" = [
"T201",
]
"libs/gmpy2_pkcs10aep_cipher.py" = [
"N803", # invalid-argument-name
]
"tests/*" = [
"F811", # redefined-while-unused
"T201", # allow print in tests
]
[lint.pyflakes]
+1 -1
View File
@@ -7,7 +7,7 @@ _logger = logging.getLogger(__name__)
def _log(message: str):
print(message, flush=True)
_logger.debug(message)
# grpc gevent
+4 -4
View File
@@ -739,18 +739,18 @@ where sites.id is null limit 1000"""
try:
app = db.session.query(App).where(App.id == app_id).first()
if not app:
print(f"App {app_id} not found")
logger.info("App %s not found", app_id)
continue
tenant = app.tenant
if tenant:
accounts = tenant.get_accounts()
if not accounts:
print(f"Fix failed for app {app.id}")
logger.info("Fix failed for app %s", app.id)
continue
account = accounts[0]
print(f"Fixing missing site for app {app.id}")
logger.info("Fixing missing site for app %s", app.id)
app_was_created.send(app, account=account)
except Exception:
failed_app_ids.append(app_id)
@@ -1544,7 +1544,7 @@ def transform_datasource_credentials():
if jina_plugin_id not in installed_plugins_ids:
if jina_plugin_unique_identifier:
# install jina plugin
print(jina_plugin_unique_identifier)
logger.debug("Installing Jina plugin %s", jina_plugin_unique_identifier)
PluginService.install_from_marketplace_pkg(tenant_id, [jina_plugin_unique_identifier])
auth_count = 0
+3
View File
@@ -62,6 +62,9 @@ class ChatMessageListApi(Resource):
@account_initialization_required
@marshal_with(message_infinite_scroll_pagination_fields)
def get(self, app_model):
if not isinstance(current_user, Account) or not current_user.has_edit_permission:
raise Forbidden()
parser = reqparse.RequestParser()
parser.add_argument("conversation_id", required=True, type=uuid_value, location="args")
parser.add_argument("first_id", type=uuid_value, location="args")
@@ -118,12 +118,14 @@ class RagPipelineExportApi(Resource):
# Add include_secret params
parser = reqparse.RequestParser()
parser.add_argument("include_secret", type=bool, default=False, location="args")
parser.add_argument("include_secret", type=str, default="false", location="args")
args = parser.parse_args()
with Session(db.engine) as session:
export_service = RagPipelineDslService(session)
result = export_service.export_rag_pipeline_dsl(pipeline=pipeline, include_secret=args["include_secret"])
result = export_service.export_rag_pipeline_dsl(
pipeline=pipeline, include_secret=args["include_secret"] == "true"
)
return {"data": result}, 200
+2
View File
@@ -261,6 +261,8 @@ class MessageSuggestedQuestionApi(WebApiResource):
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, user=end_user, message_id=message_id, invoke_from=InvokeFrom.WEB_APP
)
# questions is a list of strings, not a list of Message objects
# so we can directly return it
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
+1 -1
View File
@@ -417,7 +417,7 @@ class WeaveDataTrace(BaseTraceInstance):
if not login_status:
raise ValueError("Weave login failed")
else:
print("Weave login successful")
logger.info("Weave login successful")
return True
except Exception as e:
logger.debug("Weave API check failed: %s", str(e))
@@ -229,7 +229,7 @@ class OceanBaseVector(BaseVector):
try:
metadata = json.loads(metadata_str)
except json.JSONDecodeError:
print(f"Invalid JSON metadata: {metadata_str}")
logger.warning("Invalid JSON metadata: %s", metadata_str)
metadata = {}
metadata["score"] = score
docs.append(Document(page_content=_text, metadata=metadata))
@@ -1,5 +1,6 @@
import array
import json
import logging
import re
import uuid
from typing import Any
@@ -19,6 +20,8 @@ from core.rag.models.document import Document
from extensions.ext_redis import redis_client
from models.dataset import Dataset
logger = logging.getLogger(__name__)
oracledb.defaults.fetch_lobs = False
@@ -180,8 +183,8 @@ class OracleVector(BaseVector):
value,
)
conn.commit()
except Exception as e:
print(e)
except Exception:
logger.exception("Failed to insert record %s into %s", value[0], self.table_name)
conn.close()
return pks
@@ -1,4 +1,5 @@
import json
import logging
import uuid
from typing import Any
@@ -23,6 +24,8 @@ from core.rag.datasource.vdb.vector_base import BaseVector
from core.rag.models.document import Document
from extensions.ext_redis import redis_client
logger = logging.getLogger(__name__)
Base = declarative_base() # type: Any
@@ -187,8 +190,8 @@ class RelytVector(BaseVector):
delete_condition = chunks_table.c.id.in_(ids)
conn.execute(chunks_table.delete().where(delete_condition))
return True
except Exception as e:
print("Delete operation failed:", str(e))
except Exception:
logger.exception("Delete operation failed for collection %s", self._collection_name)
return False
def delete_by_metadata_field(self, key: str, value: str):
@@ -164,8 +164,8 @@ class TiDBVector(BaseVector):
delete_condition = table.c.id.in_(ids)
conn.execute(table.delete().where(delete_condition))
return True
except Exception as e:
print("Delete operation failed:", str(e))
except Exception:
logger.exception("Delete operation failed for collection %s", self._collection_name)
return False
def get_ids_by_metadata_field(self, key: str, value: str):
+11
View File
@@ -93,6 +93,17 @@ class DatasetDocumentStore:
segment_document = self.get_document_segment(doc_id=doc.metadata["doc_id"])
# Check if a segment with the same content hash already exists in the dataset
existing_segment_by_hash = db.session.query(DocumentSegment).filter_by(
dataset_id=self._dataset.id,
index_node_hash=doc.metadata["doc_hash"],
enabled=True
).first()
if existing_segment_by_hash:
# Skip creating duplicate segment with same content hash
continue
# NOTE: doc could already exist in the store, but we overwrite it
if not allow_update and segment_document:
raise ValueError(
@@ -417,12 +417,10 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
if db_model is not None:
offload_data = db_model.offload_data
else:
db_model = self._to_db_model(domain_model)
offload_data = []
offload_data = db_model.offload_data
offload_data = db_model.offload_data
if domain_model.inputs is not None:
result = self._truncate_and_upload(
domain_model.inputs,
+4 -1
View File
@@ -1,4 +1,5 @@
import json
import logging
import threading
from collections.abc import Mapping, MutableMapping
from pathlib import Path
@@ -8,6 +9,8 @@ from typing import Any, ClassVar, Optional
class SchemaRegistry:
"""Schema registry manages JSON schemas with version support"""
logger: ClassVar[logging.Logger] = logging.getLogger(__name__)
_default_instance: ClassVar[Optional["SchemaRegistry"]] = None
_lock: ClassVar[threading.Lock] = threading.Lock()
@@ -83,7 +86,7 @@ class SchemaRegistry:
self.metadata[uri] = metadata
except (OSError, json.JSONDecodeError) as e:
print(f"Warning: failed to load schema {version}/{schema_name}: {e}")
self.logger.warning("Failed to load schema %s/%s: %s", version, schema_name, e)
def get_schema(self, uri: str) -> Any | None:
"""Retrieves a schema by URI with version support"""
+4
View File
@@ -396,6 +396,10 @@ class ApiTool(Tool):
# assemble invoke message based on response type
if parsed_response.is_json and isinstance(parsed_response.content, dict):
yield self.create_json_message(parsed_response.content)
# FIXES: https://github.com/langgenius/dify/pull/23456#issuecomment-3182413088
# We need never break the original flows
yield self.create_text_message(response.text)
else:
# Convert to string if needed and create text message
text_response = (
@@ -147,4 +147,4 @@ class ExecutionLimitsLayer(GraphEngineLayer):
self.logger.debug("Abort command sent to engine")
except Exception:
self.logger.exception("Failed to send abort command: %s")
self.logger.exception("Failed to send abort command")
+1
View File
@@ -689,6 +689,7 @@ class DocumentSegment(Base):
sa.Index("document_segment_tenant_document_idx", "document_id", "tenant_id"),
sa.Index("document_segment_node_dataset_idx", "index_node_id", "dataset_id"),
sa.Index("document_segment_tenant_idx", "tenant_id"),
sa.Index("document_segment_dataset_hash_idx", "dataset_id", "index_node_hash"),
)
# initial fields
+28 -1
View File
@@ -532,7 +532,8 @@ class DatasetService:
filtered_data["updated_by"] = user.id
filtered_data["updated_at"] = naive_utc_now()
# update Retrieval model
filtered_data["retrieval_model"] = data["retrieval_model"]
if data.get("retrieval_model"):
filtered_data["retrieval_model"] = data["retrieval_model"]
# update icon info
if data.get("icon_info"):
filtered_data["icon_info"] = data.get("icon_info")
@@ -2622,6 +2623,17 @@ class SegmentService:
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0]
lock_name = f"add_segment_lock_document_id_{document.id}"
with redis_client.lock(lock_name, timeout=600):
# Check if a segment with the same content hash already exists
existing_segment = db.session.query(DocumentSegment).filter_by(
dataset_id=document.dataset_id,
index_node_hash=segment_hash,
enabled=True
).first()
if existing_segment:
logger.info(f"Segment with same content hash already exists: {segment_hash}")
return existing_segment
max_position = (
db.session.query(func.max(DocumentSegment.position))
.where(DocumentSegment.document_id == document.id)
@@ -2688,6 +2700,15 @@ class SegmentService:
.where(DocumentSegment.document_id == document.id)
.scalar()
)
# Batch query existing hashes before the loop
segment_hashes = [helper.generate_text_hash(seg["content"]) for seg in segments]
existing_segments = db.session.query(DocumentSegment.index_node_hash).filter(
DocumentSegment.dataset_id == document.dataset_id,
DocumentSegment.index_node_hash.in_(segment_hashes),
DocumentSegment.enabled == True
).all()
existing_hashes = {seg.index_node_hash for seg in existing_segments}
pre_segment_data_list = []
segment_data_list = []
keywords_list = []
@@ -2696,6 +2717,12 @@ class SegmentService:
content = segment_item["content"]
doc_id = str(uuid.uuid4())
segment_hash = helper.generate_text_hash(content)
# Skip existing segments
if segment_hash in existing_hashes:
logger.info(f"Skipping duplicate segment with hash: {segment_hash}")
continue
tokens = 0
if dataset.indexing_technique == "high_quality" and embedding_model:
# calc embedding use tokens
+2 -2
View File
@@ -217,7 +217,7 @@ class MessageService:
@classmethod
def get_suggested_questions_after_answer(
cls, app_model: App, user: Union[Account, EndUser] | None, message_id: str, invoke_from: InvokeFrom
) -> list[Message]:
) -> list[str]:
if not user:
raise ValueError("user cannot be None")
@@ -288,7 +288,7 @@ class MessageService:
)
with measure_time() as timer:
questions: list[Message] = LLMGenerator.generate_suggested_questions_after_answer(
questions: list[str] = LLMGenerator.generate_suggested_questions_after_answer(
tenant_id=app_model.tenant_id, histories=histories
)
+5 -1
View File
@@ -46,7 +46,11 @@ limit 1000"""
record_id = str(i.id)
provider_name = str(i.provider_name)
retrieval_model = i.retrieval_model
print(type(retrieval_model))
logger.debug(
"Processing dataset %s with retrieval model of type %s",
record_id,
type(retrieval_model),
)
if record_id in failed_ids:
continue
+7 -7
View File
@@ -1327,14 +1327,14 @@ class RagPipelineService:
"""
Retry error document
"""
document_pipeline_excution_log = (
document_pipeline_execution_log = (
db.session.query(DocumentPipelineExecutionLog)
.where(DocumentPipelineExecutionLog.document_id == document.id)
.first()
)
if not document_pipeline_excution_log:
if not document_pipeline_execution_log:
raise ValueError("Document pipeline execution log not found")
pipeline = db.session.query(Pipeline).where(Pipeline.id == document_pipeline_excution_log.pipeline_id).first()
pipeline = db.session.query(Pipeline).where(Pipeline.id == document_pipeline_execution_log.pipeline_id).first()
if not pipeline:
raise ValueError("Pipeline not found")
# convert to app config
@@ -1346,10 +1346,10 @@ class RagPipelineService:
workflow=workflow,
user=user,
args={
"inputs": document_pipeline_excution_log.input_data,
"start_node_id": document_pipeline_excution_log.datasource_node_id,
"datasource_type": document_pipeline_excution_log.datasource_type,
"datasource_info_list": [json.loads(document_pipeline_excution_log.datasource_info)],
"inputs": document_pipeline_execution_log.input_data,
"start_node_id": document_pipeline_execution_log.datasource_node_id,
"datasource_type": document_pipeline_execution_log.datasource_type,
"datasource_info_list": [json.loads(document_pipeline_execution_log.datasource_info)],
"original_document_id": document.id,
},
invoke_from=InvokeFrom.PUBLISHED,
@@ -685,12 +685,24 @@ class RagPipelineDslService:
workflow_dict = workflow.to_dict(include_secret=include_secret)
for node in workflow_dict.get("graph", {}).get("nodes", []):
if node.get("data", {}).get("type", "") == NodeType.KNOWLEDGE_RETRIEVAL.value:
dataset_ids = node["data"].get("dataset_ids", [])
node_data = node.get("data", {})
if not node_data:
continue
data_type = node_data.get("type", "")
if data_type == NodeType.KNOWLEDGE_RETRIEVAL.value:
dataset_ids = node_data.get("dataset_ids", [])
node["data"]["dataset_ids"] = [
self.encrypt_dataset_id(dataset_id=dataset_id, tenant_id=pipeline.tenant_id)
for dataset_id in dataset_ids
]
# filter credential id from tool node
if not include_secret and data_type == NodeType.TOOL.value:
node_data.pop("credential_id", None)
# filter credential id from agent node
if not include_secret and data_type == NodeType.AGENT.value:
for tool in node_data.get("agent_parameters", {}).get("tools", {}).get("value", []):
tool.pop("credential_id", None)
export_data["workflow"] = workflow_dict
dependencies = self._extract_dependencies_from_workflow(workflow)
export_data["dependencies"] = [
@@ -1,4 +1,5 @@
import json
import logging
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
@@ -17,6 +18,8 @@ from services.entities.knowledge_entities.rag_pipeline_entities import Knowledge
from services.plugin.plugin_migration import PluginMigration
from services.plugin.plugin_service import PluginService
logger = logging.getLogger(__name__)
class RagPipelineTransformService:
def transform_dataset(self, dataset_id: str):
@@ -35,11 +38,11 @@ class RagPipelineTransformService:
indexing_technique = dataset.indexing_technique
if not datasource_type and not indexing_technique:
return self._transfrom_to_empty_pipeline(dataset)
return self._transform_to_empty_pipeline(dataset)
doc_form = dataset.doc_form
if not doc_form:
return self._transfrom_to_empty_pipeline(dataset)
return self._transform_to_empty_pipeline(dataset)
retrieval_model = dataset.retrieval_model
pipeline_yaml = self._get_transform_yaml(doc_form, datasource_type, indexing_technique)
# deal dependencies
@@ -257,10 +260,10 @@ class RagPipelineTransformService:
if plugin_unique_identifier:
need_install_plugin_unique_identifiers.append(plugin_unique_identifier)
if need_install_plugin_unique_identifiers:
print(need_install_plugin_unique_identifiers)
logger.debug("Installing missing pipeline plugins %s", need_install_plugin_unique_identifiers)
PluginService.install_from_marketplace_pkg(tenant_id, need_install_plugin_unique_identifiers)
def _transfrom_to_empty_pipeline(self, dataset: Dataset):
def _transform_to_empty_pipeline(self, dataset: Dataset):
pipeline = Pipeline(
tenant_id=dataset.tenant_id,
name=dataset.name,
+2 -1
View File
@@ -450,7 +450,8 @@ class WorkflowService:
)
if not default_provider:
raise ValueError("No default credential found")
# plugin does not require credentials, skip
return
# Check credential policy compliance using the default credential ID
from core.helper.credential_utils import check_credential_policy_compliance
@@ -1,12 +1,14 @@
"""Integration tests for ChatMessageApi permission verification."""
import uuid
from types import SimpleNamespace
from unittest import mock
import pytest
from flask.testing import FlaskClient
from controllers.console.app import completion as completion_api
from controllers.console.app import message as message_api
from controllers.console.app import wraps
from libs.datetime_utils import naive_utc_now
from models import Account, App, Tenant
@@ -99,3 +101,106 @@ class TestChatMessageApiPermissions:
)
assert response.status_code == status
@pytest.mark.parametrize(
("role", "status"),
[
(TenantAccountRole.OWNER, 200),
(TenantAccountRole.ADMIN, 200),
(TenantAccountRole.EDITOR, 200),
(TenantAccountRole.NORMAL, 403),
(TenantAccountRole.DATASET_OPERATOR, 403),
],
)
def test_get_requires_edit_permission(
self,
test_client: FlaskClient,
auth_header,
monkeypatch,
mock_app_model,
mock_account,
role: TenantAccountRole,
status: int,
):
"""Ensure GET chat-messages endpoint enforces edit permissions."""
mock_load_app_model = mock.Mock(return_value=mock_app_model)
monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model)
conversation_id = uuid.uuid4()
created_at = naive_utc_now()
mock_conversation = SimpleNamespace(id=str(conversation_id), app_id=str(mock_app_model.id))
mock_message = SimpleNamespace(
id=str(uuid.uuid4()),
conversation_id=str(conversation_id),
inputs=[],
query="hello",
message=[{"text": "hello"}],
message_tokens=0,
re_sign_file_url_answer="",
answer_tokens=0,
provider_response_latency=0.0,
from_source="console",
from_end_user_id=None,
from_account_id=mock_account.id,
feedbacks=[],
workflow_run_id=None,
annotation=None,
annotation_hit_history=None,
created_at=created_at,
agent_thoughts=[],
message_files=[],
message_metadata_dict={},
status="success",
error="",
parent_message_id=None,
)
class MockQuery:
def __init__(self, model):
self.model = model
def where(self, *args, **kwargs):
return self
def first(self):
if getattr(self.model, "__name__", "") == "Conversation":
return mock_conversation
return None
def order_by(self, *args, **kwargs):
return self
def limit(self, *_):
return self
def all(self):
if getattr(self.model, "__name__", "") == "Message":
return [mock_message]
return []
mock_session = mock.Mock()
mock_session.query.side_effect = MockQuery
mock_session.scalar.return_value = False
monkeypatch.setattr(message_api, "db", SimpleNamespace(session=mock_session))
monkeypatch.setattr(message_api, "current_user", mock_account)
class DummyPagination:
def __init__(self, data, limit, has_more):
self.data = data
self.limit = limit
self.has_more = has_more
monkeypatch.setattr(message_api, "InfiniteScrollPagination", DummyPagination)
mock_account.role = role
response = test_client.get(
f"/console/api/apps/{mock_app_model.id}/chat-messages",
headers=auth_header,
query_string={"conversation_id": str(conversation_id)},
)
assert response.status_code == status
+218
View File
@@ -0,0 +1,218 @@
# 本地测试环境设置指南
本文档说明如何创建和使用本地的Docker Compose测试环境,该环境不会被提交到版本控制。
## 📁 文件结构
```
docker/
├── .env # 本地环境配置
├── docker-compose.override.yaml # 本地覆盖配置
├── start-local-test.bat # Windows启动脚本
└── README-local-test.md # 本文档
```
## 🚀 快速开始
### 1. 准备环境配置文件
**使用 `.env`**
```bash
cd docker
copy .env.example .env
```
**注意**: 请确保 Docker Desktop 正在运行,然后执行启动脚本。
### 2. 修改配置(可选)
编辑你选择的环境文件,调整适合本地测试的配置:
```bash
# 开发环境
DEPLOY_ENV=DEVELOPMENT
# 启用调试
DEBUG=true
FLASK_DEBUG=true
LOG_LEVEL=DEBUG
# 数据库配置(保持默认即可)
DB_USERNAME=postgres
DB_PASSWORD=difyai123456
# 向量存储(本地测试推荐Weaviate)
VECTOR_STORE=weaviate
```
### 3. 启动测试环境
**Windows用户**
```cmd
cd docker
start-local-test.bat
```
**脚本会自动**
- 检查 Docker Desktop 是否运行
- 验证 `.env` 配置文件存在
- 构建 worker 镜像(使用本地 Dockerfile
- 启动所有服务
或者手动启动:
```bash
# 启动中间件(数据库、Redis、向量存储)
docker compose -f docker-compose.middleware.yaml --profile weaviate up -d
# 启动应用服务
docker compose up -d
```
## 🎯 服务说明
### 中间件服务(docker-compose.middleware.yaml
- **PostgreSQL**: 主数据库
- **Redis**: 缓存和消息队列
- **Weaviate**: 向量数据库(默认)
- **其他**: 可根据需要启用不同的向量存储
### 应用服务(docker-compose.yaml + override
- **API**: 后端服务(开发模式,支持热重载)
- **Web**: 前端服务(开发模式)
- **Nginx**: 反向代理
- **Worker**: 后台任务处理
## 📝 本地开发特性
### 热重载
- API服务会自动检测代码变化并重启
- Web服务支持前端热重载
### 数据持久化
数据存储在 `docker/volumes/` 目录下,会在容器重启后保留。
### 调试支持
- 启用Flask调试模式
- 详细的日志输出
- API文档自动生成
## 🛠️ 常用命令
```bash
# 查看服务状态
docker compose ps
# 查看日志
docker compose logs -f [service_name]
# 重启特定服务
docker compose restart api
# 进入容器调试
docker compose exec api bash
# 停止所有服务
docker compose down
# 停止并清理数据卷
docker compose -f docker-compose.middleware.yaml down -v
```
## 🔧 自定义配置
### 修改端口
在环境文件中修改:
```bash
DIFY_PORT=5002 # API端口
EXPOSE_NGINX_PORT=8080 # Web端口
```
### 切换向量存储
在环境文件中修改:
```bash
VECTOR_STORE=qdrant # 或 milvus, chroma 等
```
然后重新启动中间件:
```bash
docker compose -f docker-compose.middleware.yaml --profile qdrant up -d
```
### 使用本地 Dockerfile
如果需要使用自定义的 Dockerfile(比如使用国内镜像加速):
1. **创建本地 Dockerfile**
```bash
# 复制原文件
cp api/Dockerfile api/Dockerfile.local
# 编辑本地文件(比如取消阿里云镜像注释)
# 第15行取消注释:RUN sed -i 's@deb.debian.org@mirrors.aliyun.com@g' /etc/apt/sources.list.d/debian.sources
```
2. **配置 override 使用本地 Dockerfile**
`docker-compose.override.yaml` 已经配置好了使用 `Dockerfile.local`
3. **构建时会自动使用**
```bash
docker compose --env-file .env build worker
```
### 添加自定义服务
编辑 `docker-compose.override.yaml` 添加新服务。
## 📚 最佳实践
1. **不要修改官方文件**: 不要直接修改 `docker-compose.yaml`,所有本地改动都放在 `docker-compose.override.yaml` 中。
2. **使用有意义的环境文件**: 使用 `.env` 文件进行本地配置。
3. **定期清理**: 测试完成后清理不需要的数据卷。
4. **版本控制**: 这些本地文件(`.env`, `docker-compose.override.yaml`, `Dockerfile.local`)会被 `.gitignore` 忽略,不会提交到仓库。
## 🐛 故障排除
### 服务启动失败
```bash
# 检查端口占用
netstat -tulpn | grep :5001
# 检查Docker资源
docker system df
# 查看详细日志
docker compose logs
```
### 数据库连接问题
```bash
# 检查数据库状态
docker compose exec db pg_isready
# 重置数据库
docker compose down
docker volume rm dify_db_data
docker compose up -d db
```
### 内存不足
减少服务资源使用:
```yaml
# 在 docker-compose.override.yaml 中添加
services:
db:
environment:
POSTGRES_SHARED_BUFFERS: 64MB
redis:
command: redis-server --maxmemory 64mb
```
## 📞 获取帮助
如果遇到问题,请:
1. 检查本文档
2. 查看 [官方文档](https://docs.dify.ai)
3. 在GitHub Issues中搜索类似问题
+63
View File
@@ -0,0 +1,63 @@
@echo off
chcp 65001 >nul
REM Dify Local Test Environment Startup Script (Windows)
REM Used to quickly start local development and testing environment
echo [INFO] Starting Dify local test environment...
REM Ensure in docker directory
cd /d "%~dp0"
REM Check if Docker is running
docker info >nul 2>&1
if errorlevel 1 (
echo [ERROR] Docker is not running. Please start Docker Desktop first.
pause
exit /b 1
)
REM Check if .env file exists
if not exist ".env" (
echo [ERROR] .env configuration file not found
echo Please create first: copy .env.example .env
pause
exit /b 1
)
echo [INFO] Using config file: .env
REM Build worker image
echo [INFO] Building worker image...
docker compose --env-file .env build worker
if errorlevel 1 (
echo [ERROR] Failed to build worker image
pause
exit /b 1
)
REM Start all services
echo [INFO] Starting all services...
docker compose --env-file .env up -d
if errorlevel 1 (
echo [ERROR] Failed to start services
pause
exit /b 1
)
echo [SUCCESS] Local test environment started successfully!
echo.
echo [SERVICES] Service URLs:
echo - Web UI: http://localhost
echo - API Docs: http://localhost/swagger-ui.html
echo - API Service: http://localhost:5001
echo.
echo [COMMANDS] Available commands:
echo - View logs: docker compose logs -f
echo - Stop services: docker compose down
echo - Clean data: docker compose -f docker-compose.middleware.yaml down -v
echo - Restart services: docker compose restart
echo.
echo [TIP] If first run, wait a few minutes for services to fully start
echo Use 'docker compose ps' to check service status
pause
@@ -464,6 +464,7 @@ const Configuration: FC = () => {
provider,
modelId,
completionParams,
isAdvancedMode,
)
if (Object.keys(removedDetails).length)
Toast.notify({ type: 'warning', message: `${t('common.modelProvider.parametersInvalidRemoved')}: ${Object.entries(removedDetails).map(([k, reason]) => `${k} (${reason})`).join(', ')}` })
@@ -122,19 +122,31 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setLocaleFromProps()
}, [appData])
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(false)
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
try {
const localState = localStorage.getItem('webappSidebarCollapse')
return localState === 'collapsed'
}
catch (e) {
// localStorage may be disabled in private browsing mode or by security settings
// fallback to default value
return false
}
}
return false
})
const handleSidebarCollapse = useCallback((state: boolean) => {
if (appId) {
setSidebarCollapseState(state)
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
try {
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
}
catch (e) {
// localStorage may be disabled, continue without persisting state
}
}
}, [appId, setSidebarCollapseState])
useEffect(() => {
if (appId) {
const localState = localStorage.getItem('webappSidebarCollapse')
setSidebarCollapseState(localState === 'collapsed')
}
}, [appId])
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
defaultValue: {},
})
@@ -47,6 +47,11 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
}, [site, customConfig, themeBuilder])
useEffect(() => {
if (!isSidebarCollapsed)
setShowSidePanel(false)
}, [isSidebarCollapsed])
useDocumentTitle(site?.title || 'Chat')
return (
@@ -76,7 +81,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
onMouseEnter={() => setShowSidePanel(true)}
onMouseLeave={() => setShowSidePanel(false)}
>
<Sidebar isPanel />
<Sidebar isPanel panelVisible={showSidePanel} />
</div>
)}
<div className={cn('flex h-full flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg', isMobile ? 'rounded-t-2xl' : 'rounded-2xl')}>
@@ -23,9 +23,10 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
type Props = {
isPanel?: boolean
panelVisible?: boolean
}
const Sidebar = ({ isPanel }: Props) => {
const Sidebar = ({ isPanel, panelVisible }: Props) => {
const { t } = useTranslation()
const {
isInstalledApp,
@@ -138,7 +139,12 @@ const Sidebar = ({ isPanel }: Props) => {
)}
</div>
<div className='flex shrink-0 items-center justify-between p-3'>
<MenuDropdown hideLogout={isInstalledApp} placement='top-start' data={appData?.site} />
<MenuDropdown
hideLogout={isInstalledApp}
placement='top-start'
data={appData?.site}
forceClose={isPanel && !panelVisible}
/>
{/* powered by */}
<div className='shrink-0'>
{!appData?.custom_config?.remove_webapp_brand && (
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -63,7 +63,7 @@ const Details = ({
>
<RiCloseLine className='size-4 text-text-tertiary' />
</button>
<div className='flex items-center gap-x-3 pb-2 pl-4 pr-12 pt-6'>
<div className='flex items-start gap-x-3 pb-2 pl-4 pr-12 pt-6'>
<AppIcon
size='large'
iconType={appIcon.type as AppIconType}
@@ -72,15 +72,23 @@ const Details = ({
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
showEditIcon
/>
<div className='flex grow flex-col gap-y-1 py-px'>
<div className='system-md-semibold text-text-secondary'>
<div className='flex grow flex-col gap-y-1 overflow-hidden py-px'>
<div
className='system-md-semibold truncate text-text-secondary'
title={pipelineTemplateInfo.name}
>
{pipelineTemplateInfo.name}
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>
{t('datasetPipeline.details.createdBy', {
author: pipelineTemplateInfo.created_by,
})}
</div>
{pipelineTemplateInfo.created_by && (
<div
className='system-2xs-medium-uppercase truncate text-text-tertiary'
title={pipelineTemplateInfo.created_by}
>
{t('datasetPipeline.details.createdBy', {
author: pipelineTemplateInfo.created_by,
})}
</div>
)}
</div>
</div>
<p className='system-sm-regular px-4 pb-2 pt-1 text-text-secondary'>
@@ -9,8 +9,7 @@ import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import type { PipelineTemplate } from '@/models/pipeline'
import { PipelineTemplateListQueryKeyPrefix, useUpdateTemplateInfo } from '@/service/use-pipeline'
import { useInvalid } from '@/service/use-base'
import { useInvalidCustomizedTemplateList, useUpdateTemplateInfo } from '@/service/use-pipeline'
type EditPipelineInfoProps = {
onClose: () => void
@@ -63,7 +62,7 @@ const EditPipelineInfo = ({
}, [])
const { mutateAsync: updatePipeline } = useUpdateTemplateInfo()
const invalidCustomizedTemplateList = useInvalid([...PipelineTemplateListQueryKeyPrefix, 'customized'])
const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList()
const handleSave = useCallback(async () => {
if (!name) {
@@ -5,9 +5,9 @@ import EditPipelineInfo from './edit-pipeline-info'
import type { PipelineTemplate } from '@/models/pipeline'
import Confirm from '@/app/components/base/confirm'
import {
PipelineTemplateListQueryKeyPrefix,
useDeleteTemplate,
useExportTemplateDSL,
useInvalidCustomizedTemplateList,
usePipelineTemplateById,
} from '@/service/use-pipeline'
import { downloadFile } from '@/utils/format'
@@ -18,7 +18,6 @@ import Details from './details'
import Content from './content'
import Actions from './actions'
import { useCreatePipelineDatasetFromCustomized } from '@/service/knowledge/use-create-dataset'
import { useInvalid } from '@/service/use-base'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
type TemplateCardProps = {
@@ -128,7 +127,7 @@ const TemplateCard = ({
}, [])
const { mutateAsync: deletePipeline } = useDeleteTemplate()
const invalidCustomizedTemplateList = useInvalid([...PipelineTemplateListQueryKeyPrefix, 'customized'])
const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList()
const onConfirmDelete = useCallback(async () => {
await deletePipeline(pipeline.id, {
@@ -167,10 +167,39 @@ import { appAction } from './app'
import { knowledgeAction } from './knowledge'
import { pluginAction } from './plugin'
import { workflowNodesAction } from './workflow-nodes'
import { ragPipelineNodesAction } from './rag-pipeline-nodes'
import type { ActionItem, SearchResult } from './types'
import { slashAction } from './commands'
import { slashCommandRegistry } from './commands/registry'
// Create dynamic Actions based on context
export const createActions = (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
const baseActions = {
slash: slashAction,
app: appAction,
knowledge: knowledgeAction,
plugin: pluginAction,
}
// Add appropriate node search based on context
if (isRagPipelinePage) {
return {
...baseActions,
node: ragPipelineNodesAction,
}
}
else if (isWorkflowPage) {
return {
...baseActions,
node: workflowNodesAction,
}
}
// Default actions without node search
return baseActions
}
// Legacy export for backward compatibility
export const Actions = {
slash: slashAction,
app: appAction,
@@ -183,6 +212,7 @@ export const searchAnything = async (
locale: string,
query: string,
actionItem?: ActionItem,
dynamicActions?: Record<string, ActionItem>,
): Promise<SearchResult[]> => {
if (actionItem) {
const searchTerm = query.replace(actionItem.key, '').replace(actionItem.shortcut, '').trim()
@@ -198,7 +228,7 @@ export const searchAnything = async (
if (query.startsWith('@') || query.startsWith('/'))
return []
const globalSearchActions = Object.values(Actions)
const globalSearchActions = Object.values(dynamicActions || Actions)
// Use Promise.allSettled to handle partial failures gracefully
const searchPromises = globalSearchActions.map(async (action) => {
@@ -0,0 +1,24 @@
import type { ActionItem } from './types'
// Create the RAG pipeline nodes action
export const ragPipelineNodesAction: ActionItem = {
key: '@node',
shortcut: '@node',
title: 'Search RAG Pipeline Nodes',
description: 'Find and jump to nodes in the current RAG pipeline by name or type',
searchFn: undefined, // Will be set by useRagPipelineSearch hook
search: async (_, searchTerm = '', _locale) => {
try {
// Use the searchFn if available (set by useRagPipelineSearch hook)
if (ragPipelineNodesAction.searchFn)
return ragPipelineNodesAction.searchFn(searchTerm)
// If not in RAG pipeline context, return empty array
return []
}
catch (error) {
console.warn('RAG pipeline nodes search failed:', error)
return []
}
},
}
@@ -7,7 +7,7 @@ export const workflowNodesAction: ActionItem = {
title: 'Search Workflow Nodes',
description: 'Find and jump to nodes in the current workflow by name or type',
searchFn: undefined, // Will be set by useWorkflowSearch hook
search: async (_, searchTerm = '', locale) => {
search: async (_, searchTerm = '', _locale) => {
try {
// Use the searchFn if available (set by useWorkflowSearch hook)
if (workflowNodesAction.searchFn)
+20 -4
View File
@@ -12,11 +12,16 @@ type GotoAnythingContextType = {
* Whether the current page is a workflow page
*/
isWorkflowPage: boolean
/**
* Whether the current page is a RAG pipeline page
*/
isRagPipelinePage: boolean
}
// Create context with default values
const GotoAnythingContext = createContext<GotoAnythingContextType>({
isWorkflowPage: false,
isRagPipelinePage: false,
})
/**
@@ -33,17 +38,28 @@ type GotoAnythingProviderProps = {
*/
export const GotoAnythingProvider: React.FC<GotoAnythingProviderProps> = ({ children }) => {
const [isWorkflowPage, setIsWorkflowPage] = useState(false)
const [isRagPipelinePage, setIsRagPipelinePage] = useState(false)
const pathname = usePathname()
// Update context based on current pathname
// Update context based on current pathname using more robust route matching
useEffect(() => {
// Check if current path contains workflow
const isWorkflow = pathname?.includes('/workflow') || false
if (!pathname) {
setIsWorkflowPage(false)
setIsRagPipelinePage(false)
return
}
// Workflow pages: /app/[appId]/workflow or /workflow/[token] (shared)
const isWorkflow = /^\/app\/[^/]+\/workflow$/.test(pathname) || /^\/workflow\/[^/]+$/.test(pathname)
// RAG Pipeline pages: /datasets/[datasetId]/pipeline
const isRagPipeline = /^\/datasets\/[^/]+\/pipeline$/.test(pathname)
setIsWorkflowPage(isWorkflow)
setIsRagPipelinePage(isRagPipeline)
}, [pathname])
return (
<GotoAnythingContext.Provider value={{ isWorkflowPage }}>
<GotoAnythingContext.Provider value={{ isWorkflowPage, isRagPipelinePage }}>
{children}
</GotoAnythingContext.Provider>
)
+23 -25
View File
@@ -9,7 +9,7 @@ import { useDebounce, useKeyPress } from 'ahooks'
import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app/components/workflow/utils/common'
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
import { RiSearchLine } from '@remixicon/react'
import { Actions as AllActions, type SearchResult, matchAction, searchAnything } from './actions'
import { type SearchResult, createActions, matchAction, searchAnything } from './actions'
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
import { slashCommandRegistry } from './actions/commands/registry'
import { useQuery } from '@tanstack/react-query'
@@ -29,7 +29,7 @@ const GotoAnything: FC<Props> = ({
}) => {
const router = useRouter()
const defaultLocale = useGetLanguage()
const { isWorkflowPage } = useGotoAnythingContext()
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
const { t } = useTranslation()
const [show, setShow] = useState<boolean>(false)
const [searchQuery, setSearchQuery] = useState<string>('')
@@ -38,16 +38,9 @@ const GotoAnything: FC<Props> = ({
// Filter actions based on context
const Actions = useMemo(() => {
// Create a filtered copy of actions based on current page context
if (isWorkflowPage) {
// Include all actions on workflow pages
return AllActions
}
else {
const { app, knowledge, plugin, slash } = AllActions
return { app, knowledge, plugin, slash }
}
}, [isWorkflowPage])
// Create actions based on current page context
return createActions(isWorkflowPage, isRagPipelinePage)
}, [isWorkflowPage, isRagPipelinePage])
const [activePlugin, setActivePlugin] = useState<Plugin>()
@@ -99,9 +92,11 @@ const GotoAnything: FC<Props> = ({
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
return action
? (action.key === '/' ? '@command' : action.key)
: 'general'
if (!action)
return 'general'
return action.key === '/' ? '@command' : action.key
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
const { data: searchResults = [], isLoading, isError, error } = useQuery(
@@ -112,13 +107,14 @@ const GotoAnything: FC<Props> = ({
searchQueryDebouncedValue,
searchMode,
isWorkflowPage,
isRagPipelinePage,
defaultLocale,
Object.keys(Actions).sort().join(','),
],
queryFn: async () => {
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
return await searchAnything(defaultLocale, query, action)
return await searchAnything(defaultLocale, query, action, Actions)
},
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
staleTime: 30000,
@@ -321,7 +317,7 @@ const GotoAnything: FC<Props> = ({
autoFocus
/>
{searchMode !== 'general' && (
<div className='flex items-center gap-1 rounded bg-blue-50 px-2 py-[2px] text-xs font-medium text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'>
<div className='flex items-center gap-1 rounded bg-gray-100 px-2 py-[2px] text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300'>
<span>{(() => {
if (searchMode === 'scopes')
return 'SCOPES'
@@ -446,18 +442,20 @@ const GotoAnything: FC<Props> = ({
) : (
<>
<span className='opacity-60'>
{isCommandsMode
? t('app.gotoAnything.selectToNavigate')
: searchQuery.trim()
? t('app.gotoAnything.searching')
: t('app.gotoAnything.startTyping')
}
{(() => {
if (isCommandsMode)
return t('app.gotoAnything.selectToNavigate')
if (searchQuery.trim())
return t('app.gotoAnything.searching')
return t('app.gotoAnything.startTyping')
})()}
</span>
<span className='opacity-60'>
{searchQuery.trim() || isCommandsMode
? t('app.gotoAnything.tips')
: t('app.gotoAnything.pressEscToClose')
}
: t('app.gotoAnything.pressEscToClose')}
</span>
</>
)}
@@ -29,7 +29,7 @@ import {
import { fetchModelParameterRules } from '@/service/common'
import Loading from '@/app/components/base/loading'
import { useProviderContext } from '@/context/provider-context'
import { TONE_LIST } from '@/config'
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
export type ModelParameterModalProps = {
@@ -50,26 +50,7 @@ export type ModelParameterModalProps = {
isInWorkflow?: boolean
scope?: string
}
const stopParameterRule: ModelParameterRule = {
default: [],
help: {
en_US: 'Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.',
zh_Hans: '最多四个序列,API 将停止生成更多的 token。返回的文本将不包含停止序列。',
},
label: {
en_US: 'Stop sequences',
zh_Hans: '停止序列',
},
name: 'stop',
required: false,
type: 'tag',
tagPlaceholder: {
en_US: 'Enter sequence and press Tab',
zh_Hans: '输入序列并按 Tab 键',
},
}
const PROVIDER_WITH_PRESET_TONE = ['langgenius/openai/openai', 'langgenius/azure_openai/azure_openai']
const ModelParameterModal: FC<ModelParameterModalProps> = ({
popupClassName,
portalToFollowElemContentClassName,
@@ -230,7 +211,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
!isLoading && !!parameterRules.length && (
[
...parameterRules,
...(isAdvancedMode ? [stopParameterRule] : []),
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
].map(parameter => (
<ParameterItem
key={`${modelId}-${parameter.name}`}
@@ -6,6 +6,7 @@ import { useInvalidateAllBuiltInTools, useInvalidateAllToolProviders } from '@/s
import { useInvalidateStrategyProviders } from '@/service/use-strategy'
import type { Plugin, PluginDeclaration, PluginManifestInMarket } from '../../types'
import { PluginType } from '../../types'
import { useInvalidDataSourceList } from '@/service/use-pipeline'
const useRefreshPluginList = () => {
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
@@ -16,6 +17,7 @@ const useRefreshPluginList = () => {
const invalidateAllToolProviders = useInvalidateAllToolProviders()
const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools()
const invalidateAllDataSources = useInvalidDataSourceList()
const invalidateStrategyProviders = useInvalidateStrategyProviders()
return {
@@ -30,6 +32,9 @@ const useRefreshPluginList = () => {
// TODO: update suggested tools. It's a function in hook useMarketplacePlugins,handleUpdatePlugins
}
if ((manifest && PluginType.datasource.includes(manifest.category)) || refreshAllType)
invalidateAllDataSources()
// model select
if ((manifest && PluginType.model.includes(manifest.category)) || refreshAllType) {
refreshModelProviders()
@@ -136,6 +136,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
provider,
model,
value?.completion_params,
isAdvancedMode,
)
nextCompletionParams = filtered
@@ -10,29 +10,9 @@ import type {
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ParameterValue } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item'
import { fetchModelParameterRules } from '@/service/common'
import { TONE_LIST } from '@/config'
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
import cn from '@/utils/classnames'
const PROVIDER_WITH_PRESET_TONE = ['langgenius/openai/openai', 'langgenius/azure_openai/azure_openai']
const stopParameterRule: ModelParameterRule = {
default: [],
help: {
en_US: 'Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.',
zh_Hans: '最多四个序列,API 将停止生成更多的 token。返回的文本将不包含停止序列。',
},
label: {
en_US: 'Stop sequences',
zh_Hans: '停止序列',
},
name: 'stop',
required: false,
type: 'tag',
tagPlaceholder: {
en_US: 'Enter sequence and press Tab',
zh_Hans: '输入序列并按 Tab 键',
},
}
type Props = {
isAdvancedMode: boolean
provider: string
@@ -108,7 +88,7 @@ const LLMParamsPanel = ({
{!!parameterRules.length && (
[
...parameterRules,
...(isAdvancedMode ? [stopParameterRule] : []),
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
].map(parameter => (
<ParameterItem
key={`${modelId}-${parameter.name}`}
@@ -16,6 +16,7 @@ import {
} from '@/app/components/workflow/hooks'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import PublishToast from './publish-toast'
import { useRagPipelineSearch } from '../hooks/use-rag-pipeline-search'
const RagPipelineChildren = () => {
const { eventEmitter } = useEventEmitterContextContext()
@@ -30,6 +31,9 @@ const RagPipelineChildren = () => {
handleExportDSL,
} = useDSL()
// Initialize RAG pipeline search functionality
useRagPipelineSearch()
eventEmitter?.useSubscription((v: any) => {
if (v.type === DSL_EXPORT_CHECK)
setSecretEnvList(v.payload.data as EnvironmentVariable[])
@@ -33,6 +33,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useInvalid } from '@/service/use-base'
import {
publishedPipelineInfoQueryKeyPrefix,
useInvalidCustomizedTemplateList,
usePublishAsCustomizedPipeline,
} from '@/service/use-pipeline'
import Confirm from '@/app/components/base/confirm'
@@ -158,6 +159,8 @@ const Popup = () => {
push(`/datasets/${datasetId}/documents/create-from-pipeline`)
}, [datasetId, push])
const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList()
const handlePublishAsKnowledgePipeline = useCallback(async (
name: string,
icon: IconInfo,
@@ -189,6 +192,7 @@ const Popup = () => {
</div>
),
})
invalidCustomizedTemplateList()
}
catch {
notify({ type: 'error', message: t('datasetPipeline.publishTemplate.error.message') })
@@ -0,0 +1,168 @@
'use client'
import { useCallback, useEffect, useMemo } from 'react'
import { useNodes } from 'reactflow'
import { useNodesInteractions } from '@/app/components/workflow/hooks/use-nodes-interactions'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { ragPipelineNodesAction } from '@/app/components/goto-anything/actions/rag-pipeline-nodes'
import BlockIcon from '@/app/components/workflow/block-icon'
import { setupNodeSelectionListener } from '@/app/components/workflow/utils/node-navigation'
import { BlockEnum } from '@/app/components/workflow/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types'
import type { KnowledgeRetrievalNodeType } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon'
/**
* Hook to register RAG pipeline nodes search functionality
*/
export const useRagPipelineSearch = () => {
const nodes = useNodes()
const { handleNodeSelect } = useNodesInteractions()
const getToolIcon = useGetToolIcon()
// Process nodes to create searchable data structure
const searchableNodes = useMemo(() => {
return nodes.map((node) => {
const nodeData = node.data as CommonNodeType
const title = nodeData.title || nodeData.type || 'Untitled Node'
let desc = nodeData.desc || ''
// Keep the original node title for consistency with workflow display
// Only enhance description for better search context
if (nodeData.type === BlockEnum.Tool) {
const toolData = nodeData as ToolNodeType
desc = toolData.tool_description || toolData.tool_label || desc
}
if (nodeData.type === BlockEnum.LLM) {
const llmData = nodeData as LLMNodeType
if (llmData.model?.provider && llmData.model?.name)
desc = `${llmData.model.name} (${llmData.model.provider}) - ${llmData.model.mode || desc}`
}
if (nodeData.type === BlockEnum.KnowledgeRetrieval) {
const knowledgeData = nodeData as KnowledgeRetrievalNodeType
if (knowledgeData.dataset_ids?.length)
desc = `Knowledge Retrieval with ${knowledgeData.dataset_ids.length} datasets - ${desc}`
}
return {
id: node.id,
title,
desc,
type: nodeData.type,
blockType: nodeData.type,
nodeData,
toolIcon: getToolIcon(nodeData),
modelInfo: nodeData.type === BlockEnum.LLM ? {
provider: (nodeData as LLMNodeType).model?.provider,
name: (nodeData as LLMNodeType).model?.name,
mode: (nodeData as LLMNodeType).model?.mode,
} : {
provider: undefined,
name: undefined,
mode: undefined,
},
}
})
}, [nodes, getToolIcon])
// Calculate relevance score for search results
const calculateScore = useCallback((node: {
title: string;
type: string;
desc: string;
modelInfo: { provider?: string; name?: string; mode?: string }
}, searchTerm: string): number => {
if (!searchTerm) return 1
let score = 0
const term = searchTerm.toLowerCase()
// Title match (highest priority)
if (node.title.toLowerCase().includes(term))
score += 10
// Type match
if (node.type.toLowerCase().includes(term))
score += 8
// Description match
if (node.desc.toLowerCase().includes(term))
score += 5
// Model info matches (for LLM nodes)
if (node.modelInfo.provider?.toLowerCase().includes(term))
score += 6
if (node.modelInfo.name?.toLowerCase().includes(term))
score += 6
if (node.modelInfo.mode?.toLowerCase().includes(term))
score += 4
return score
}, [])
// Create search function for RAG pipeline nodes
const searchRagPipelineNodes = useCallback((query: string) => {
if (!searchableNodes.length) return []
const searchTerm = query.toLowerCase().trim()
const results = searchableNodes
.map((node) => {
const score = calculateScore(node, searchTerm)
return score > 0 ? {
id: node.id,
title: node.title,
description: node.desc || node.type,
type: 'workflow-node' as const,
path: `#${node.id}`,
icon: (
<BlockIcon
type={node.blockType}
className="shrink-0"
size="sm"
toolIcon={node.toolIcon}
/>
),
metadata: {
nodeId: node.id,
nodeData: node.nodeData,
},
data: node.nodeData,
score,
} : null
})
.filter((node): node is NonNullable<typeof node> => node !== null)
.sort((a, b) => {
// If no search term, sort alphabetically
if (!searchTerm) return a.title.localeCompare(b.title)
// Sort by relevance score (higher score first)
return (b.score || 0) - (a.score || 0)
})
return results
}, [searchableNodes, calculateScore])
// Directly set the search function on the action object
useEffect(() => {
if (searchableNodes.length > 0) {
// Set the search function directly on the action
ragPipelineNodesAction.searchFn = searchRagPipelineNodes
}
return () => {
// Clean up when component unmounts
ragPipelineNodesAction.searchFn = undefined
}
}, [searchableNodes, searchRagPipelineNodes])
// Set up node selection event listener using the utility function
useEffect(() => {
return setupNodeSelectionListener(handleNodeSelect)
}, [handleNodeSelect])
return null
}
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Placement } from '@floating-ui/react'
import {
@@ -25,12 +25,14 @@ type Props = {
data?: SiteInfo
placement?: Placement
hideLogout?: boolean
forceClose?: boolean
}
const MenuDropdown: FC<Props> = ({
data,
placement,
hideLogout,
forceClose,
}) => {
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
const router = useRouter()
@@ -55,6 +57,11 @@ const MenuDropdown: FC<Props> = ({
const [show, setShow] = useState(false)
useEffect(() => {
if (forceClose)
setOpen(false)
}, [forceClose, setOpen])
return (
<>
<PortalToFollowElem
+1 -1
View File
@@ -38,7 +38,7 @@ const Header = ({
return (
<div
className='absolute left-0 top-0 z-10 flex h-14 w-full items-center justify-between bg-mask-top2bottom-gray-50-to-transparent px-3'
className='absolute left-0 top-7 z-10 flex h-0 w-full items-center justify-between bg-mask-top2bottom-gray-50-to-transparent px-3'
>
{(inWorkflowCanvas || isPipelineCanvas) && maximizeCanvas && <div className='h-14 w-[52px]' />}
{
@@ -4,7 +4,7 @@ import type { AnyObj } from './match-schema-type'
import matchTheSchemaType from './match-schema-type'
export const getMatchedSchemaType = (obj: AnyObj, schemaTypeDefinitions?: SchemaTypeDefinition[]): string => {
if(!schemaTypeDefinitions) return ''
if(!schemaTypeDefinitions || obj === undefined || obj === null) return ''
const matched = schemaTypeDefinitions.find(def => matchTheSchemaType(obj, def.schema))
return matched ? matched.name : ''
}
@@ -78,6 +78,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
model.provider,
model.modelId,
inputs.model.completion_params,
true,
)
const keys = Object.keys(removedDetails)
if (keys.length)
+22
View File
@@ -4,6 +4,7 @@ import { PromptRole } from '@/models/debug'
import { PipelineInputVarType } from '@/models/pipeline'
import { DatasetAttr } from '@/types/feature'
import pkg from '../package.json'
import type { ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations'
const getBooleanConfig = (
envVar: string | undefined,
@@ -403,3 +404,24 @@ export const ZENDESK_FIELD_IDS = {
export const APP_VERSION = pkg.version
export const RAG_PIPELINE_PREVIEW_CHUNK_NUM = 20
export const PROVIDER_WITH_PRESET_TONE = ['langgenius/openai/openai', 'langgenius/azure_openai/azure_openai']
export const STOP_PARAMETER_RULE: ModelParameterRule = {
default: [],
help: {
en_US: 'Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.',
zh_Hans: '最多四个序列,API 将停止生成更多的 token。返回的文本将不包含停止序列。',
},
label: {
en_US: 'Stop sequences',
zh_Hans: '停止序列',
},
name: 'stop',
required: false,
type: 'tag',
tagPlaceholder: {
en_US: 'Enter sequence and press Tab',
zh_Hans: '输入序列并按 Tab 键',
},
}
+1
View File
@@ -500,6 +500,7 @@ const translation = {
editModelCredential: 'Bearbeiten von Modellanmeldeinformationen',
customModelCredentialsDeleteTip: 'Anmeldeinformationen werden verwendet und können nicht gelöscht werden',
},
parametersInvalidRemoved: 'Einige Parameter sind ungültig und wurden entfernt.',
},
dataSource: {
add: 'Eine Datenquelle hinzufügen',
+1
View File
@@ -527,6 +527,7 @@ const translation = {
selectModelCredential: 'Select a model credential',
customModelCredentialsDeleteTip: 'Credential is in use and cannot be deleted',
},
parametersInvalidRemoved: 'Some parameters are invalid and have been removed',
},
dataSource: {
add: 'Add a data source',
+1
View File
@@ -504,6 +504,7 @@ const translation = {
customModelCredentialsDeleteTip: 'La credencial está en uso y no se puede eliminar',
editModelCredential: 'Editar credencial de modelo',
},
parametersInvalidRemoved: 'Algunos parámetros son inválidos y han sido eliminados',
},
dataSource: {
add: 'Agregar una fuente de datos',
+1
View File
@@ -504,6 +504,7 @@ const translation = {
customModelCredentials: 'اعتبار مدل سفارشی',
customModelCredentialsDeleteTip: 'اعتبار در حال استفاده است و قابل حذف نیست',
},
parametersInvalidRemoved: 'برخی پارامترها نامعتبر هستند و حذف شده‌اند',
},
dataSource: {
add: 'افزودن منبع داده',
+1
View File
@@ -501,6 +501,7 @@ const translation = {
removeModel: 'Supprimer le modèle',
editModelCredential: 'Modifier les informations didentification du modèle',
},
parametersInvalidRemoved: 'Certains paramètres sont invalides et ont été supprimés.',
},
dataSource: {
add: 'Ajouter une source de données',
+1
View File
@@ -520,6 +520,7 @@ const translation = {
customModelCredentials: 'कस्टम मॉडल क्रेडेंशियल्स',
editModelCredential: 'मॉडल की क्रेडेंशियल संपादित करें',
},
parametersInvalidRemoved: 'कुछ पैरामीटर अमान्य हैं और हटा दिए गए हैं',
},
dataSource: {
add: 'डेटा स्रोत जोड़ें',
+1
View File
@@ -500,6 +500,7 @@ const translation = {
installProvider: 'Menginstal penyedia model',
callTimes: 'Waktu panggilan',
getFreeTokens: 'Dapatkan Token gratis',
parametersInvalidRemoved: 'Beberapa parameter tidak valid dan telah dihapus',
},
dataSource: {
notion: {
+1
View File
@@ -526,6 +526,7 @@ const translation = {
removeModel: 'Rimuovi modello',
editModelCredential: 'Modificare le credenziali del modello',
},
parametersInvalidRemoved: 'Alcuni parametri non sono validi e sono stati rimossi.',
},
dataSource: {
add: 'Aggiungi una fonte di dati',
+1
View File
@@ -518,6 +518,7 @@ const translation = {
customModelCredentials: 'カスタムモデルの認証情報',
selectModelCredential: 'モデルの資格情報を選択する',
},
parametersInvalidRemoved: 'いくつかのパラメータが無効であり、削除されました。',
},
dataSource: {
add: 'データソースの追加',
+1
View File
@@ -496,6 +496,7 @@ const translation = {
customModelCredentials: '사용자 지정 모델 자격 증명',
customModelCredentialsDeleteTip: '자격 증명이 사용 중이며 삭제할 수 없습니다.',
},
parametersInvalidRemoved: '일부 매개변수가 유효하지 않아 제거되었습니다.',
},
dataSource: {
add: '데이터 소스 추가하기',
+1
View File
@@ -513,6 +513,7 @@ const translation = {
selectModelCredential: 'Wybieranie poświadczeń modelu',
editModelCredential: 'Edytowanie poświadczeń modelu',
},
parametersInvalidRemoved: 'Niektóre parametry są nieprawidłowe i zostały usunięte.',
},
dataSource: {
add: 'Dodaj źródło danych',
+1
View File
@@ -500,6 +500,7 @@ const translation = {
customModelCredentialsDeleteTip: 'A credencial está em uso e não pode ser excluída',
addNewModelCredential: 'Adicionar nova credencial de modelo',
},
parametersInvalidRemoved: 'Alguns parâmetros são inválidos e foram removidos',
},
dataSource: {
add: 'Adicionar uma fonte de dados',
+1
View File
@@ -500,6 +500,7 @@ const translation = {
manageCredentials: 'Gestionați acreditările',
customModelCredentialsDeleteTip: 'Acreditarea este în uz și nu poate fi ștearsă',
},
parametersInvalidRemoved: 'Unele parametrii sunt invalizi și au fost eliminați.',
},
dataSource: {
add: 'Adăugați o sursă de date',
+1
View File
@@ -504,6 +504,7 @@ const translation = {
manageCredentials: 'Управление учетными данными',
customModelCredentialsDeleteTip: 'Учетные данные используются и не могут быть удалены',
},
parametersInvalidRemoved: 'Некоторые параметры недействительны и были удалены',
},
dataSource: {
add: 'Добавить источник данных',
+1
View File
@@ -585,6 +585,7 @@ const translation = {
customModelCredentialsDeleteTip: 'Poverilnice so v uporabi in jih ni mogoče izbrisati',
customModelCredentials: 'Poverilnice modela po meri',
},
parametersInvalidRemoved: 'Nekateri parametri so neveljavni in so bili odstranjeni.',
},
dataSource: {
notion: {
+1
View File
@@ -499,6 +499,7 @@ const translation = {
customModelCredentials: 'ข้อมูลประจําตัวของโมเดลแบบกําหนดเอง',
addNewModelCredential: 'เพิ่มข้อมูลประจําตัวของโมเดลใหม่',
},
parametersInvalidRemoved: 'บางพารามิเตอร์ไม่ถูกต้องและถูกนำออก',
},
dataSource: {
add: 'เพิ่มแหล่งข้อมูล',
+1
View File
@@ -504,6 +504,7 @@ const translation = {
addNewModelCredential: 'Yeni model kimlik bilgisi ekleme',
customModelCredentialsDeleteTip: 'Kimlik bilgisi kullanımda ve silinemiyor',
},
parametersInvalidRemoved: 'Bazı parametreler geçersizdir ve kaldırılmıştır.',
},
dataSource: {
add: 'Bir veri kaynağı ekle',
+1
View File
@@ -501,6 +501,7 @@ const translation = {
editModelCredential: 'Редагувати облікові дані моделі',
customModelCredentialsDeleteTip: 'Облікові дані використовуються і не можуть бути видалені',
},
parametersInvalidRemoved: 'Деякі параметри є недійсними і були видалені',
},
dataSource: {
add: 'Додати джерело даних',
+1
View File
@@ -500,6 +500,7 @@ const translation = {
addNewModelCredential: 'Thêm thông tin xác thực mô hình mới',
selectModelCredential: 'Chọn thông tin xác thực mô hình',
},
parametersInvalidRemoved: 'Một số tham số không hợp lệ và đã được loại bỏ',
},
dataSource: {
add: 'Thêm nguồn dữ liệu',
+1
View File
@@ -521,6 +521,7 @@ const translation = {
selectModelCredential: '选择模型凭据',
customModelCredentialsDeleteTip: '模型凭据正在使用中,无法删除',
},
parametersInvalidRemoved: '部分参数无效,已移除',
},
dataSource: {
add: '添加数据源',
+1
View File
@@ -500,6 +500,7 @@ const translation = {
addNewModelCredential: '新增模型認證',
selectModelCredential: '選取模型認證',
},
parametersInvalidRemoved: '一些參數無效,已被移除',
},
dataSource: {
add: '新增資料來源',
+4
View File
@@ -48,6 +48,10 @@ export const usePipelineTemplateList = (params: PipelineTemplateListParams) => {
})
}
export const useInvalidCustomizedTemplateList = () => {
return useInvalid([...PipelineTemplateListQueryKeyPrefix, 'customized'])
}
export const usePipelineTemplateById = (params: PipelineTemplateByIdRequest, enabled: boolean) => {
const { template_id, type } = params
return useQuery<PipelineTemplateByIdResponse>({
+8 -1
View File
@@ -3,6 +3,7 @@ import type { FormValue, ModelParameterRule } from '@/app/components/header/acco
export const mergeValidCompletionParams = (
oldParams: FormValue | undefined,
rules: ModelParameterRule[],
isAdvancedMode: boolean = false,
): { params: FormValue; removedDetails: Record<string, string> } => {
if (!oldParams || Object.keys(oldParams).length === 0)
return { params: {}, removedDetails: {} }
@@ -16,6 +17,11 @@ export const mergeValidCompletionParams = (
const removedDetails: Record<string, string> = {}
Object.entries(oldParams).forEach(([key, value]) => {
if (key === 'stop' && isAdvancedMode) {
// keep stop in advanced mode
nextParams[key] = value
return
}
const rule = ruleMap[key]
if (!rule) {
removedDetails[key] = 'unsupported'
@@ -74,9 +80,10 @@ export const fetchAndMergeValidCompletionParams = async (
provider: string,
modelId: string,
oldParams: FormValue | undefined,
isAdvancedMode: boolean = false,
): Promise<{ params: FormValue; removedDetails: Record<string, string> }> => {
const { fetchModelParameterRules } = await import('@/service/common')
const url = `/workspaces/current/model-providers/${provider}/models/parameter-rules?model=${modelId}`
const { data: parameterRules } = await fetchModelParameterRules(url)
return mergeValidCompletionParams(oldParams, parameterRules ?? [])
return mergeValidCompletionParams(oldParams, parameterRules ?? [], isAdvancedMode)
}