Compare commits

..
73 changed files with 385 additions and 1097 deletions
+6 -7
View File
@@ -1,16 +1,15 @@
#!/bin/bash
WORKSPACE_ROOT=$(pwd)
corepack enable
cd web && pnpm install
pipx install uv
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
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
source /home/vscode/.bashrc
+1 -6
View File
@@ -147,7 +147,6 @@ api/.idea
api/.env
api/storage/*
api/Dockerfile.local
docker-legacy/volumes/app/storage/*
docker-legacy/volumes/db/data/*
@@ -231,8 +230,4 @@ api/.env.backup
# Benchmark
scripts/stress-test/setup/config/
scripts/stress-test/reports/
# mcp
.playwright-mcp/
.serena/
scripts/stress-test/reports/
+2 -3
View File
@@ -61,9 +61,8 @@ check:
@echo "✅ Code check complete"
lint:
@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 "🔧 Running ruff format and check with fixes..."
@uv run --directory api --dev sh -c 'ruff format ./api && ruff check --fix ./api'
@echo "✅ Linting complete"
type-check:
-8
View File
@@ -30,7 +30,6 @@ 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
@@ -92,18 +91,11 @@ 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):
_logger.debug(message)
print(message, flush=True)
# 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:
logger.info("App %s not found", app_id)
print(f"App {app_id} not found")
continue
tenant = app.tenant
if tenant:
accounts = tenant.get_accounts()
if not accounts:
logger.info("Fix failed for app %s", app.id)
print(f"Fix failed for app {app.id}")
continue
account = accounts[0]
logger.info("Fixing missing site for app %s", app.id)
print(f"Fixing missing site for app {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
logger.debug("Installing Jina plugin %s", jina_plugin_unique_identifier)
print(jina_plugin_unique_identifier)
PluginService.install_from_marketplace_pkg(tenant_id, [jina_plugin_unique_identifier])
auth_count = 0
-3
View File
@@ -62,9 +62,6 @@ 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,14 +118,12 @@ class RagPipelineExportApi(Resource):
# Add include_secret params
parser = reqparse.RequestParser()
parser.add_argument("include_secret", type=str, default="false", location="args")
parser.add_argument("include_secret", type=bool, 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"] == "true"
)
result = export_service.export_rag_pipeline_dsl(pipeline=pipeline, include_secret=args["include_secret"])
return {"data": result}, 200
+1 -1
View File
@@ -417,7 +417,7 @@ class WeaveDataTrace(BaseTraceInstance):
if not login_status:
raise ValueError("Weave login failed")
else:
logger.info("Weave login successful")
print("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:
logger.warning("Invalid JSON metadata: %s", metadata_str)
print(f"Invalid JSON metadata: {metadata_str}")
metadata = {}
metadata["score"] = score
docs.append(Document(page_content=_text, metadata=metadata))
@@ -1,6 +1,5 @@
import array
import json
import logging
import re
import uuid
from typing import Any
@@ -20,8 +19,6 @@ 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
@@ -183,8 +180,8 @@ class OracleVector(BaseVector):
value,
)
conn.commit()
except Exception:
logger.exception("Failed to insert record %s into %s", value[0], self.table_name)
except Exception as e:
print(e)
conn.close()
return pks
@@ -1,5 +1,4 @@
import json
import logging
import uuid
from typing import Any
@@ -24,8 +23,6 @@ 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
@@ -190,8 +187,8 @@ class RelytVector(BaseVector):
delete_condition = chunks_table.c.id.in_(ids)
conn.execute(chunks_table.delete().where(delete_condition))
return True
except Exception:
logger.exception("Delete operation failed for collection %s", self._collection_name)
except Exception as e:
print("Delete operation failed:", str(e))
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:
logger.exception("Delete operation failed for collection %s", self._collection_name)
except Exception as e:
print("Delete operation failed:", str(e))
return False
def get_ids_by_metadata_field(self, key: str, value: str):
-11
View File
@@ -93,17 +93,6 @@ 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,10 +417,12 @@ 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 = db_model.offload_data
offload_data = []
offload_data = db_model.offload_data
if domain_model.inputs is not None:
result = self._truncate_and_upload(
domain_model.inputs,
+1 -4
View File
@@ -1,5 +1,4 @@
import json
import logging
import threading
from collections.abc import Mapping, MutableMapping
from pathlib import Path
@@ -9,8 +8,6 @@ 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()
@@ -86,7 +83,7 @@ class SchemaRegistry:
self.metadata[uri] = metadata
except (OSError, json.JSONDecodeError) as e:
self.logger.warning("Failed to load schema %s/%s: %s", version, schema_name, e)
print(f"Warning: failed to load schema {version}/{schema_name}: {e}")
def get_schema(self, uri: str) -> Any | None:
"""Retrieves a schema by URI with version support"""
-4
View File
@@ -396,10 +396,6 @@ 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")
self.logger.exception("Failed to send abort command: %s")
-1
View File
@@ -689,7 +689,6 @@ 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
-26
View File
@@ -2623,17 +2623,6 @@ 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)
@@ -2700,15 +2689,6 @@ 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 = []
@@ -2717,12 +2697,6 @@ 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
+1 -5
View File
@@ -46,11 +46,7 @@ limit 1000"""
record_id = str(i.id)
provider_name = str(i.provider_name)
retrieval_model = i.retrieval_model
logger.debug(
"Processing dataset %s with retrieval model of type %s",
record_id,
type(retrieval_model),
)
print(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_execution_log = (
document_pipeline_excution_log = (
db.session.query(DocumentPipelineExecutionLog)
.where(DocumentPipelineExecutionLog.document_id == document.id)
.first()
)
if not document_pipeline_execution_log:
if not document_pipeline_excution_log:
raise ValueError("Document pipeline execution log not found")
pipeline = db.session.query(Pipeline).where(Pipeline.id == document_pipeline_execution_log.pipeline_id).first()
pipeline = db.session.query(Pipeline).where(Pipeline.id == document_pipeline_excution_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_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)],
"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)],
"original_document_id": document.id,
},
invoke_from=InvokeFrom.PUBLISHED,
@@ -685,24 +685,12 @@ class RagPipelineDslService:
workflow_dict = workflow.to_dict(include_secret=include_secret)
for node in workflow_dict.get("graph", {}).get("nodes", []):
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", [])
if node.get("data", {}).get("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,5 +1,4 @@
import json
import logging
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
@@ -18,8 +17,6 @@ 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):
@@ -38,11 +35,11 @@ class RagPipelineTransformService:
indexing_technique = dataset.indexing_technique
if not datasource_type and not indexing_technique:
return self._transform_to_empty_pipeline(dataset)
return self._transfrom_to_empty_pipeline(dataset)
doc_form = dataset.doc_form
if not doc_form:
return self._transform_to_empty_pipeline(dataset)
return self._transfrom_to_empty_pipeline(dataset)
retrieval_model = dataset.retrieval_model
pipeline_yaml = self._get_transform_yaml(doc_form, datasource_type, indexing_technique)
# deal dependencies
@@ -260,10 +257,10 @@ class RagPipelineTransformService:
if plugin_unique_identifier:
need_install_plugin_unique_identifiers.append(plugin_unique_identifier)
if need_install_plugin_unique_identifiers:
logger.debug("Installing missing pipeline plugins %s", need_install_plugin_unique_identifiers)
print(need_install_plugin_unique_identifiers)
PluginService.install_from_marketplace_pkg(tenant_id, need_install_plugin_unique_identifiers)
def _transform_to_empty_pipeline(self, dataset: Dataset):
def _transfrom_to_empty_pipeline(self, dataset: Dataset):
pipeline = Pipeline(
tenant_id=dataset.tenant_id,
name=dataset.name,
+1 -2
View File
@@ -450,8 +450,7 @@ class WorkflowService:
)
if not default_provider:
# plugin does not require credentials, skip
return
raise ValueError("No default credential found")
# Check credential policy compliance using the default credential ID
from core.helper.credential_utils import check_credential_policy_compliance
@@ -1,14 +1,12 @@
"""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
@@ -101,106 +99,3 @@ 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
@@ -1,218 +0,0 @@
# 本地测试环境设置指南
本文档说明如何创建和使用本地的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
@@ -1,63 +0,0 @@
@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,7 +464,6 @@ 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,31 +122,19 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setLocaleFromProps()
}, [appData])
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 [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(false)
const handleSidebarCollapse = useCallback((state: boolean) => {
if (appId) {
setSidebarCollapseState(state)
try {
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
}
catch (e) {
// localStorage may be disabled, continue without persisting state
}
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
}
}, [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,11 +47,6 @@ 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 (
@@ -81,7 +76,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
onMouseEnter={() => setShowSidePanel(true)}
onMouseLeave={() => setShowSidePanel(false)}
>
<Sidebar isPanel panelVisible={showSidePanel} />
<Sidebar isPanel />
</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,10 +23,9 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
type Props = {
isPanel?: boolean
panelVisible?: boolean
}
const Sidebar = ({ isPanel, panelVisible }: Props) => {
const Sidebar = ({ isPanel }: Props) => {
const { t } = useTranslation()
const {
isInstalledApp,
@@ -139,12 +138,7 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => {
)}
</div>
<div className='flex shrink-0 items-center justify-between p-3'>
<MenuDropdown
hideLogout={isInstalledApp}
placement='top-start'
data={appData?.site}
forceClose={isPanel && !panelVisible}
/>
<MenuDropdown hideLogout={isInstalledApp} placement='top-start' data={appData?.site} />
{/* 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-start gap-x-3 pb-2 pl-4 pr-12 pt-6'>
<div className='flex items-center gap-x-3 pb-2 pl-4 pr-12 pt-6'>
<AppIcon
size='large'
iconType={appIcon.type as AppIconType}
@@ -72,23 +72,15 @@ const Details = ({
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
showEditIcon
/>
<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}
>
<div className='flex grow flex-col gap-y-1 py-px'>
<div className='system-md-semibold text-text-secondary'>
{pipelineTemplateInfo.name}
</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 className='system-2xs-medium-uppercase text-text-tertiary'>
{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'>
@@ -167,39 +167,10 @@ 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,
@@ -212,7 +183,6 @@ 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()
@@ -228,7 +198,7 @@ export const searchAnything = async (
if (query.startsWith('@') || query.startsWith('/'))
return []
const globalSearchActions = Object.values(dynamicActions || Actions)
const globalSearchActions = Object.values(Actions)
// Use Promise.allSettled to handle partial failures gracefully
const searchPromises = globalSearchActions.map(async (action) => {
@@ -1,24 +0,0 @@
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)
+4 -20
View File
@@ -12,16 +12,11 @@ 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,
})
/**
@@ -38,28 +33,17 @@ 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 using more robust route matching
// Update context based on current pathname
useEffect(() => {
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)
// Check if current path contains workflow
const isWorkflow = pathname?.includes('/workflow') || false
setIsWorkflowPage(isWorkflow)
setIsRagPipelinePage(isRagPipeline)
}, [pathname])
return (
<GotoAnythingContext.Provider value={{ isWorkflowPage, isRagPipelinePage }}>
<GotoAnythingContext.Provider value={{ isWorkflowPage }}>
{children}
</GotoAnythingContext.Provider>
)
+25 -23
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 { type SearchResult, createActions, matchAction, searchAnything } from './actions'
import { Actions as AllActions, type SearchResult, 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, isRagPipelinePage } = useGotoAnythingContext()
const { isWorkflowPage } = useGotoAnythingContext()
const { t } = useTranslation()
const [show, setShow] = useState<boolean>(false)
const [searchQuery, setSearchQuery] = useState<string>('')
@@ -38,9 +38,16 @@ const GotoAnything: FC<Props> = ({
// Filter actions based on context
const Actions = useMemo(() => {
// Create actions based on current page context
return createActions(isWorkflowPage, isRagPipelinePage)
}, [isWorkflowPage, isRagPipelinePage])
// 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])
const [activePlugin, setActivePlugin] = useState<Plugin>()
@@ -92,11 +99,9 @@ const GotoAnything: FC<Props> = ({
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
if (!action)
return 'general'
return action.key === '/' ? '@command' : action.key
return action
? (action.key === '/' ? '@command' : action.key)
: 'general'
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
const { data: searchResults = [], isLoading, isError, error } = useQuery(
@@ -107,14 +112,13 @@ 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, Actions)
return await searchAnything(defaultLocale, query, action)
},
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
staleTime: 30000,
@@ -317,7 +321,7 @@ const GotoAnything: FC<Props> = ({
autoFocus
/>
{searchMode !== 'general' && (
<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'>
<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'>
<span>{(() => {
if (searchMode === 'scopes')
return 'SCOPES'
@@ -442,20 +446,18 @@ const GotoAnything: FC<Props> = ({
) : (
<>
<span className='opacity-60'>
{(() => {
if (isCommandsMode)
return t('app.gotoAnything.selectToNavigate')
if (searchQuery.trim())
return t('app.gotoAnything.searching')
return t('app.gotoAnything.startTyping')
})()}
{isCommandsMode
? t('app.gotoAnything.selectToNavigate')
: searchQuery.trim()
? t('app.gotoAnything.searching')
: 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 { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
import { TONE_LIST } from '@/config'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
export type ModelParameterModalProps = {
@@ -50,7 +50,26 @@ 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,
@@ -211,7 +230,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
!isLoading && !!parameterRules.length && (
[
...parameterRules,
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
...(isAdvancedMode ? [stopParameterRule] : []),
].map(parameter => (
<ParameterItem
key={`${modelId}-${parameter.name}`}
@@ -6,7 +6,6 @@ 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()
@@ -17,7 +16,6 @@ const useRefreshPluginList = () => {
const invalidateAllToolProviders = useInvalidateAllToolProviders()
const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools()
const invalidateAllDataSources = useInvalidDataSourceList()
const invalidateStrategyProviders = useInvalidateStrategyProviders()
return {
@@ -32,9 +30,6 @@ 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,7 +136,6 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
provider,
model,
value?.completion_params,
isAdvancedMode,
)
nextCompletionParams = filtered
@@ -10,9 +10,29 @@ 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 { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
import { 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
@@ -88,7 +108,7 @@ const LLMParamsPanel = ({
{!!parameterRules.length && (
[
...parameterRules,
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
...(isAdvancedMode ? [stopParameterRule] : []),
].map(parameter => (
<ParameterItem
key={`${modelId}-${parameter.name}`}
@@ -16,7 +16,6 @@ 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()
@@ -31,9 +30,6 @@ 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[])
@@ -209,6 +209,7 @@ const Popup = () => {
hidePublishAsKnowledgePipelineModal,
notify,
t,
invalidCustomizedTemplateList,
])
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
@@ -1,168 +0,0 @@
'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, useEffect, useRef, useState } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Placement } from '@floating-ui/react'
import {
@@ -25,14 +25,12 @@ 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()
@@ -57,11 +55,6 @@ const MenuDropdown: FC<Props> = ({
const [show, setShow] = useState(false)
useEffect(() => {
if (forceClose)
setOpen(false)
}, [forceClose, setOpen])
return (
<>
<PortalToFollowElem
@@ -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 || obj === undefined || obj === null) return ''
if(!schemaTypeDefinitions) return ''
const matched = schemaTypeDefinitions.find(def => matchTheSchemaType(obj, def.schema))
return matched ? matched.name : ''
}
@@ -78,7 +78,6 @@ 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,7 +4,6 @@ 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,
@@ -404,24 +403,3 @@ 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,7 +500,6 @@ 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,7 +527,6 @@ 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,7 +504,6 @@ 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,7 +504,6 @@ const translation = {
customModelCredentials: 'اعتبار مدل سفارشی',
customModelCredentialsDeleteTip: 'اعتبار در حال استفاده است و قابل حذف نیست',
},
parametersInvalidRemoved: 'برخی پارامترها نامعتبر هستند و حذف شده‌اند',
},
dataSource: {
add: 'افزودن منبع داده',
-1
View File
@@ -501,7 +501,6 @@ 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,7 +520,6 @@ const translation = {
customModelCredentials: 'कस्टम मॉडल क्रेडेंशियल्स',
editModelCredential: 'मॉडल की क्रेडेंशियल संपादित करें',
},
parametersInvalidRemoved: 'कुछ पैरामीटर अमान्य हैं और हटा दिए गए हैं',
},
dataSource: {
add: 'डेटा स्रोत जोड़ें',
-1
View File
@@ -500,7 +500,6 @@ 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,7 +526,6 @@ 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,7 +518,6 @@ const translation = {
customModelCredentials: 'カスタムモデルの認証情報',
selectModelCredential: 'モデルの資格情報を選択する',
},
parametersInvalidRemoved: 'いくつかのパラメータが無効であり、削除されました。',
},
dataSource: {
add: 'データソースの追加',
-1
View File
@@ -496,7 +496,6 @@ const translation = {
customModelCredentials: '사용자 지정 모델 자격 증명',
customModelCredentialsDeleteTip: '자격 증명이 사용 중이며 삭제할 수 없습니다.',
},
parametersInvalidRemoved: '일부 매개변수가 유효하지 않아 제거되었습니다.',
},
dataSource: {
add: '데이터 소스 추가하기',
-1
View File
@@ -513,7 +513,6 @@ 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,7 +500,6 @@ 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,7 +500,6 @@ 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,7 +504,6 @@ const translation = {
manageCredentials: 'Управление учетными данными',
customModelCredentialsDeleteTip: 'Учетные данные используются и не могут быть удалены',
},
parametersInvalidRemoved: 'Некоторые параметры недействительны и были удалены',
},
dataSource: {
add: 'Добавить источник данных',
-1
View File
@@ -585,7 +585,6 @@ 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,7 +499,6 @@ const translation = {
customModelCredentials: 'ข้อมูลประจําตัวของโมเดลแบบกําหนดเอง',
addNewModelCredential: 'เพิ่มข้อมูลประจําตัวของโมเดลใหม่',
},
parametersInvalidRemoved: 'บางพารามิเตอร์ไม่ถูกต้องและถูกนำออก',
},
dataSource: {
add: 'เพิ่มแหล่งข้อมูล',
-1
View File
@@ -504,7 +504,6 @@ 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,7 +501,6 @@ const translation = {
editModelCredential: 'Редагувати облікові дані моделі',
customModelCredentialsDeleteTip: 'Облікові дані використовуються і не можуть бути видалені',
},
parametersInvalidRemoved: 'Деякі параметри є недійсними і були видалені',
},
dataSource: {
add: 'Додати джерело даних',
-1
View File
@@ -500,7 +500,6 @@ 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,7 +521,6 @@ const translation = {
selectModelCredential: '选择模型凭据',
customModelCredentialsDeleteTip: '模型凭据正在使用中,无法删除',
},
parametersInvalidRemoved: '部分参数无效,已移除',
},
dataSource: {
add: '添加数据源',
-1
View File
@@ -500,7 +500,6 @@ const translation = {
addNewModelCredential: '新增模型認證',
selectModelCredential: '選取模型認證',
},
parametersInvalidRemoved: '一些參數無效,已被移除',
},
dataSource: {
add: '新增資料來源',
+1 -8
View File
@@ -3,7 +3,6 @@ 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: {} }
@@ -17,11 +16,6 @@ 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'
@@ -80,10 +74,9 @@ 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 ?? [], isAdvancedMode)
return mergeValidCompletionParams(oldParams, parameterRules ?? [])
}