Merge branch 'refs/heads/release/v0.3.1' into fix/Timebomb_031

This commit is contained in:
Timebomb2018
2026-04-22 14:13:04 +08:00
28 changed files with 403 additions and 240 deletions

View File

@@ -2,6 +2,8 @@
Celery Worker 入口点
用于启动 Celery Worker: celery -A app.celery_worker worker --loglevel=info
"""
from celery.signals import worker_process_init
from app.celery_app import celery_app
from app.core.logging_config import LoggingConfig, get_logger
@@ -13,4 +15,39 @@ logger.info("Celery worker logging initialized")
# 导入任务模块以注册任务
import app.tasks
@worker_process_init.connect
def _reinit_db_pool(**kwargs):
"""
prefork 子进程启动时重建被 fork 污染的资源。
fork() 后子进程继承了父进程的:
1. SQLAlchemy 连接池 — 多进程共享 TCP socket 导致 DB 连接损坏
2. ThreadPoolExecutor — fork 后线程状态不确定,第二个任务会死锁
"""
# 重建 DB 连接池
from app.db import engine
engine.dispose()
logger.info("DB connection pool disposed for forked worker process")
# 重建模块级 ThreadPoolExecutorfork 后线程池不可用)
try:
from app.core.rag.deepdoc.parser import figure_parser
from concurrent.futures import ThreadPoolExecutor
figure_parser.shared_executor = ThreadPoolExecutor(max_workers=10)
logger.info("figure_parser.shared_executor recreated")
except Exception as e:
logger.warning(f"Failed to recreate figure_parser.shared_executor: {e}")
try:
from app.core.rag.utils import libre_office
from concurrent.futures import ThreadPoolExecutor
import os
max_workers = os.cpu_count() * 2 if os.cpu_count() else 4
libre_office.executor = ThreadPoolExecutor(max_workers=max_workers)
logger.info("libre_office.executor recreated")
except Exception as e:
logger.warning(f"Failed to recreate libre_office.executor: {e}")
__all__ = ['celery_app']

View File

@@ -167,6 +167,8 @@ def update_api_key(
return success(data=api_key_schema.ApiKey.model_validate(api_key), msg="API Key 更新成功")
except BusinessException:
raise
except Exception as e:
logger.error(f"未知错误: {str(e)}", extra={
"api_key_id": str(api_key_id),

View File

@@ -219,6 +219,7 @@ def delete_app(
@router.post("/{app_id}/copy", summary="复制应用")
@cur_workspace_access_guard()
@check_app_quota
def copy_app(
app_id: uuid.UUID,
new_name: Optional[str] = None,
@@ -1144,6 +1145,7 @@ async def import_workflow_config(
@router.post("/workflow/import/save")
@cur_workspace_access_guard()
@check_app_quota
async def save_workflow_import(
data: WorkflowImportSave,
db: Session = Depends(get_db),
@@ -1281,6 +1283,10 @@ async def import_app(
return fail(msg="YAML 格式无效,缺少 app 字段", code=BizCode.BAD_REQUEST)
target_app_id = uuid.UUID(app_id) if app_id else None
# 仅新建应用时检查配额,覆盖已有应用时跳过
if target_app_id is None:
from app.core.quota_manager import _check_quota
_check_quota(db, current_user.tenant_id, "app_quota", "app")
result_app, warnings = AppDslService(db).import_dsl(
dsl=dsl,
workspace_id=current_user.current_workspace_id,

View File

@@ -457,7 +457,7 @@ async def retrieve_chunks(
if doc.metadata["doc_id"] not in seen_ids:
seen_ids.add(doc.metadata["doc_id"])
unique_rs.append(doc)
rs = vector_service.rerank(query=retrieve_data.query, docs=unique_rs, top_k=retrieve_data.top_k)
rs = vector_service.rerank(query=retrieve_data.query, docs=unique_rs, top_k=retrieve_data.top_k) if unique_rs else []
if retrieve_data.retrieve_type == chunk_schema.RetrieveType.Graph:
kb_ids = [str(kb_id) for kb_id in private_kb_ids]
workspace_ids = [str(workspace_id) for workspace_id in private_workspace_ids]

View File

@@ -219,9 +219,20 @@ def list_conversations(
end_user_repo = EndUserRepository(db)
app_service = AppService(db)
app = app_service._get_app_or_404(share.app_id)
workspace_id = app.workspace_id
# 仅在新建终端用户时检查配额
existing_end_user = end_user_repo.get_end_user_by_other_id(workspace_id=workspace_id, other_id=other_id)
if existing_end_user is None:
from app.core.quota_manager import _check_quota
from app.models.workspace_model import Workspace
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if ws:
_check_quota(db, ws.tenant_id, "end_user_quota", "end_user")
new_end_user = end_user_repo.get_or_create_end_user(
app_id=share.app_id,
workspace_id=app.workspace_id,
workspace_id=workspace_id,
other_id=other_id
)
logger.debug(new_end_user.id)
@@ -309,7 +320,6 @@ def get_conversation(
"/chat",
summary="发送消息(支持流式和非流式)"
)
@check_end_user_quota
async def chat(
payload: conversation_schema.ChatRequest,
share_data: ShareTokenData = Depends(get_share_user_id),
@@ -350,6 +360,18 @@ async def chat(
app_service = AppService(db)
app = app_service._get_app_or_404(share.app_id)
workspace_id = app.workspace_id
# 仅在新建终端用户时检查配额,已有用户复用不受限制
existing_end_user = end_user_repo.get_end_user_by_other_id(workspace_id=workspace_id, other_id=other_id)
logger.info(f"终端用户配额检查: workspace_id={workspace_id}, other_id={other_id}, existing={existing_end_user is not None}")
if existing_end_user is None:
from app.core.quota_manager import _check_quota
from app.models.workspace_model import Workspace
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if ws:
logger.info(f"新终端用户,执行配额检查: tenant_id={ws.tenant_id}")
_check_quota(db, ws.tenant_id, "end_user_quota", "end_user")
new_end_user = end_user_repo.get_or_create_end_user(
app_id=share.app_id,
workspace_id=workspace_id,

View File

@@ -106,6 +106,16 @@ async def chat(
other_id = payload.user_id
workspace_id = api_key_auth.workspace_id
end_user_repo = EndUserRepository(db)
# 仅在新建终端用户时检查配额,已有用户复用不受限制
existing_end_user = end_user_repo.get_end_user_by_other_id(workspace_id=workspace_id, other_id=other_id)
if existing_end_user is None:
from app.core.quota_manager import _check_quota
from app.models.workspace_model import Workspace
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if ws:
_check_quota(db, ws.tenant_id, "end_user_quota", "end_user")
new_end_user = end_user_repo.get_or_create_end_user(
app_id=app.id,
workspace_id=workspace_id,

View File

@@ -32,6 +32,8 @@ class BizCode(IntEnum):
API_KEY_DAILY_LIMIT_EXCEEDED = 3015
API_KEY_QUOTA_EXCEEDED = 3016
API_KEY_RATE_LIMIT_EXCEEDED = 3017
QUOTA_EXCEEDED = 3018
RATE_LIMIT_EXCEEDED = 3019
# 资源4xxx
NOT_FOUND = 4000
USER_NOT_FOUND = 4001
@@ -156,7 +158,8 @@ HTTP_MAPPING = {
BizCode.API_KEY_QPS_LIMIT_EXCEEDED: 429,
BizCode.API_KEY_DAILY_LIMIT_EXCEEDED: 429,
BizCode.API_KEY_QUOTA_EXCEEDED: 429,
BizCode.QUOTA_EXCEEDED: 402,
BizCode.MODEL_CONFIG_INVALID: 400,
BizCode.API_KEY_MISSING: 400,
BizCode.PROVIDER_NOT_SUPPORTED: 400,
@@ -185,4 +188,21 @@ HTTP_MAPPING = {
BizCode.DB_ERROR: 500,
BizCode.SERVICE_UNAVAILABLE: 503,
BizCode.RATE_LIMITED: 429,
BizCode.RATE_LIMIT_EXCEEDED: 429,
}
ERROR_CODE_TO_BIZ_CODE = {
"QUOTA_EXCEEDED": BizCode.QUOTA_EXCEEDED,
"RATE_LIMIT_EXCEEDED": BizCode.RATE_LIMIT_EXCEEDED,
"API_KEY_NOT_FOUND": BizCode.API_KEY_NOT_FOUND,
"API_KEY_INVALID": BizCode.API_KEY_INVALID,
"API_KEY_EXPIRED": BizCode.API_KEY_EXPIRED,
"WORKSPACE_NOT_FOUND": BizCode.WORKSPACE_NOT_FOUND,
"WORKSPACE_NO_ACCESS": BizCode.WORKSPACE_NO_ACCESS,
"PERMISSION_DENIED": BizCode.PERMISSION_DENIED,
"TOKEN_EXPIRED": BizCode.TOKEN_EXPIRED,
"TOKEN_INVALID": BizCode.TOKEN_INVALID,
"VALIDATION_FAILED": BizCode.VALIDATION_FAILED,
"INVALID_PARAMETER": BizCode.INVALID_PARAMETER,
"MISSING_PARAMETER": BizCode.MISSING_PARAMETER,
}

View File

@@ -33,18 +33,16 @@ def timeout(seconds: float | int | str = None, attempts: int = 2, *, exception:
thread.daemon = True
thread.start()
effective_timeout = seconds if seconds else 120 # 默认 120 秒超时
for a in range(attempts):
try:
if os.environ.get("ENABLE_TIMEOUT_ASSERTION"):
result = result_queue.get(timeout=seconds)
else:
result = result_queue.get()
result = result_queue.get(timeout=effective_timeout)
if isinstance(result, Exception):
raise result
return result
except queue.Empty:
pass
raise TimeoutError(f"Function '{func.__name__}' timed out after {seconds} seconds and {attempts} attempts.")
raise TimeoutError(f"Function '{func.__name__}' timed out after {effective_timeout} seconds and {attempts} attempts.")
@wraps(func)
async def async_wrapper(*args, **kwargs) -> Any:

View File

@@ -113,7 +113,7 @@ def knowledge_retrieval(
continue
# Use the specified reranker for re-ranking
if reranker_id:
if reranker_id and all_results:
try:
all_results = rerank(db=db, reranker_id=reranker_id, query=query, docs=all_results, top_k=reranker_top_k)
except Exception as rerank_error:

View File

@@ -68,9 +68,9 @@ class ESConnection(DocStoreConnection):
client_config = {
"hosts": [hosts],
"basic_auth": (os.getenv("ELASTICSEARCH_USERNAME", "elastic"), os.getenv("ELASTICSEARCH_PASSWORD", "elastic")),
"request_timeout": int(os.getenv("ELASTICSEARCH_REQUEST_TIMEOUT", 100000)),
"request_timeout": int(os.getenv("ELASTICSEARCH_REQUEST_TIMEOUT", 30)),
"retry_on_timeout": os.getenv("ELASTICSEARCH_RETRY_ON_TIMEOUT", True) == "true",
"max_retries": int(os.getenv("ELASTICSEARCH_MAX_RETRIES", 10000)),
"max_retries": int(os.getenv("ELASTICSEARCH_MAX_RETRIES", 3)),
}
# Only add SSL settings if using HTTPS

View File

@@ -1,25 +1,22 @@
import os
import logging
from typing import Any, cast
import threading
from typing import Any
from urllib.parse import urlparse
import uuid
import requests
from elasticsearch import Elasticsearch, helpers
from elasticsearch.helpers import BulkIndexError
from packaging.version import parse as parse_version
from pydantic import BaseModel, model_validator
from abc import ABC
# langchain-community
# langchain-xinference
# from langchain_community.embeddings import XinferenceEmbeddings
# from langchain_xinference import XinferenceRerank
from langchain_core.documents import Document
from app.core.models.base import RedBearModelConfig
from app.core.models import RedBearLLM, RedBearRerank
from app.core.models import RedBearRerank
from app.core.models.embedding import RedBearEmbeddings
from app.models.models_model import ModelConfig, ModelApiKey
from app.services.model_service import ModelConfigService
from app.models.models_model import ModelApiKey
from app.models.knowledge_model import Knowledge
from app.core.rag.vdb.field import Field
@@ -29,37 +26,9 @@ from app.core.rag.models.chunk import DocumentChunk
logger = logging.getLogger(__name__)
class ElasticSearchConfig(BaseModel):
# Regular Elasticsearch config
host: str | None = None
port: int | None = None
username: str | None = None
password: str | None = None
# Common config
ca_certs: str | None = None
verify_certs: bool = False
request_timeout: int = 100000
retry_on_timeout: bool = True
max_retries: int = 10000
@model_validator(mode="before")
@classmethod
def validate_config(cls, values: dict):
# Regular Elasticsearch validation
if not values.get("host"):
raise ValueError("config HOST is required for regular Elasticsearch")
if not values.get("port"):
raise ValueError("config PORT is required for regular Elasticsearch")
if not values.get("username"):
raise ValueError("config USERNAME is required for regular Elasticsearch")
if not values.get("password"):
raise ValueError("config PASSWORD is required for regular Elasticsearch")
return values
class ElasticSearchVector(BaseVector):
def __init__(self, index_name: str, config: ElasticSearchConfig, embedding_config: ModelApiKey, reranker_config: ModelApiKey):
def __init__(self, index_name: str, client: Elasticsearch,
embedding_config: ModelApiKey, reranker_config: ModelApiKey):
super().__init__(index_name.lower())
# 初始化 Embedding 模型(自动支持火山引擎多模态)
@@ -77,58 +46,8 @@ class ElasticSearchVector(BaseVector):
api_key=reranker_config.api_key,
base_url=reranker_config.api_base
))
self._client = self._init_client(config)
self._version = self._get_version()
self._check_version()
def _init_client(self, config: ElasticSearchConfig) -> Elasticsearch:
"""
Initialize Elasticsearch client for regular Elasticsearch.
"""
try:
# Regular Elasticsearch configuration
parsed_url = urlparse(config.host or "")
if parsed_url.scheme in {"http", "https"}:
hosts = f"{config.host}:{config.port}"
use_https = parsed_url.scheme == "https"
else:
hosts = f"https://{config.host}:{config.port}"
use_https = False
client_config = {
"hosts": [hosts],
"basic_auth": (config.username, config.password),
"request_timeout": config.request_timeout,
"retry_on_timeout": config.retry_on_timeout,
"max_retries": config.max_retries,
}
# Only add SSL settings if using HTTPS
if use_https:
client_config["verify_certs"] = config.verify_certs
if config.ca_certs:
client_config["ca_certs"] = config.ca_certs
client = Elasticsearch(**client_config)
# Test connection
if not client.ping():
raise ConnectionError("Failed to connect to Elasticsearch")
except requests.ConnectionError as e:
raise ConnectionError(f"Vector database connection error: {str(e)}")
except Exception as e:
raise ConnectionError(f"Elasticsearch client initialization failed: {str(e)}")
return client
def _get_version(self) -> str:
info = self._client.info()
return cast(str, info["version"]["number"])
def _check_version(self):
if parse_version(self._version) < parse_version("8.0.0"):
raise ValueError("Elasticsearch vector database version must be greater than 8.0.0")
# 使用外部传入的共享客户端
self._client = client
def get_type(self) -> str:
return "elasticsearch"
@@ -745,29 +664,79 @@ class ElasticSearchVector(BaseVector):
class ElasticSearchVectorFactory:
@staticmethod
def init_vector(knowledge: Knowledge) -> ElasticSearchVector:
"""ES 向量服务工厂 - 单例共享连接"""
_client: Elasticsearch | None = None
_lock = threading.Lock()
_version_checked = False
@classmethod
def _get_shared_client(cls) -> Elasticsearch:
"""获取共享的 ES 客户端(线程安全的懒加载单例)"""
if cls._client is not None:
return cls._client
with cls._lock:
# 双重检查,防止并发时重复创建
if cls._client is not None:
return cls._client
try:
parsed_url = urlparse(os.getenv("ELASTICSEARCH_HOST", "127.0.0.1") or "")
if parsed_url.scheme in {"http", "https"}:
hosts = f'{os.getenv("ELASTICSEARCH_HOST")}:{os.getenv("ELASTICSEARCH_PORT", 9200)}'
use_https = parsed_url.scheme == "https"
else:
hosts = f'https://{os.getenv("ELASTICSEARCH_HOST", "127.0.0.1")}:{os.getenv("ELASTICSEARCH_PORT", 9200)}'
use_https = False
client_config = {
"hosts": [hosts],
"basic_auth": (
os.getenv("ELASTICSEARCH_USERNAME", "elastic"),
os.getenv("ELASTICSEARCH_PASSWORD", "elastic"),
),
"request_timeout": int(os.getenv("ELASTICSEARCH_REQUEST_TIMEOUT", 30)),
"retry_on_timeout": True,
"max_retries": int(os.getenv("ELASTICSEARCH_MAX_RETRIES", 3)),
"connections_per_node": int(os.getenv("ELASTICSEARCH_CONNECTIONS_PER_NODE", 10)),
}
if use_https:
client_config["verify_certs"] = os.getenv("ELASTICSEARCH_VERIFY_CERTS", "false") == "true"
ca_certs = os.getenv("ELASTICSEARCH_CA_CERTS")
if ca_certs:
client_config["ca_certs"] = str(ca_certs)
client = Elasticsearch(**client_config)
if not client.ping():
raise ConnectionError("Failed to connect to Elasticsearch")
# 版本检查只做一次
if not cls._version_checked:
info = client.info()
version = info["version"]["number"]
if parse_version(version) < parse_version("8.0.0"):
raise ValueError(f"Elasticsearch version must be >= 8.0.0, got {version}")
cls._version_checked = True
logger.info(f"Elasticsearch shared client initialized, version: {version}")
cls._client = client
except requests.ConnectionError as e:
raise ConnectionError(f"Vector database connection error: {str(e)}")
except Exception as e:
raise ConnectionError(f"Elasticsearch client initialization failed: {str(e)}")
return cls._client
@classmethod
def init_vector(cls, knowledge: Knowledge) -> ElasticSearchVector:
"""创建向量服务实例(共享 ES 连接)"""
client = cls._get_shared_client()
collection_name = f"Vector_index_{knowledge.id}_Node"
# Use regular Elasticsearch with config values
config_dict = {
"host": os.getenv("ELASTICSEARCH_HOST", "127.0.0.1"),
"port": os.getenv("ELASTICSEARCH_PORT", 9200),
"username": os.getenv("ELASTICSEARCH_USERNAME", "elastic"),
"password": os.getenv("ELASTICSEARCH_PASSWORD", "elastic"),
}
# Common configuration
config_dict.update(
{
"ca_certs": str(os.getenv("ELASTICSEARCH_CA_CERTS")) if os.getenv("ELASTICSEARCH_CA_CERTS") else None,
"verify_certs": os.getenv("ELASTICSEARCH_VERIFY_CERTS", False) == "true",
"request_timeout": int(os.getenv("ELASTICSEARCH_REQUEST_TIMEOUT", 100000)),
"retry_on_timeout": os.getenv("ELASTICSEARCH_RETRY_ON_TIMEOUT", True) == "true",
"max_retries": int(os.getenv("ELASTICSEARCH_MAX_RETRIES", 10000)),
}
)
if knowledge.embedding is None:
raise ValueError(f"embedding_id config error: {str(knowledge.embedding_id)}")
if knowledge.reranker is None:
@@ -775,9 +744,9 @@ class ElasticSearchVectorFactory:
return ElasticSearchVector(
index_name=collection_name,
config=ElasticSearchConfig(**config_dict),
client=client,
embedding_config=knowledge.embedding.api_keys[0],
reranker_config=knowledge.reranker.api_keys[0]
reranker_config=knowledge.reranker.api_keys[0],
)

View File

@@ -6,12 +6,14 @@ error messages based on the current request's language.
"""
import logging
import time
from contextvars import ContextVar
from typing import Any, Dict, Optional
from fastapi import HTTPException, Request
from app.i18n.service import get_translation_service
from app.core.error_codes import ERROR_CODE_TO_BIZ_CODE, BizCode
logger = logging.getLogger(__name__)
@@ -118,15 +120,24 @@ class I18nException(HTTPException):
**params
)
# Build error detail
detail = {
"error_code": self.error_code,
"message": message,
}
# Convert error_code string to BizCode value
biz_code = ERROR_CODE_TO_BIZ_CODE.get(
self.error_code,
BizCode.BAD_REQUEST
)
# Add parameters to detail if provided
if params:
detail["params"] = params
# Build error detail in standard format for compatibility
# main.py handler expects "message" and "error_code" fields for filtering
# but we also include standard format fields
detail = {
"code": biz_code.value,
"msg": message,
"message": message,
"error_code": self.error_code,
"data": params if params else {},
"error": message,
"time": int(time.time() * 1000),
}
# Initialize HTTPException
super().__init__(

View File

@@ -66,6 +66,17 @@ class EndUserRepository:
db_logger.error(f"查询宿主 {end_user_id} 时出错: {str(e)}")
raise
def get_end_user_by_other_id(self, workspace_id: uuid.UUID, other_id: str) -> Optional["EndUser"]:
"""按 workspace_id + other_id 查找终端用户,不存在返回 None"""
return (
self.db.query(EndUser)
.filter(
EndUser.workspace_id == workspace_id,
EndUser.other_id == other_id
)
.first()
)
def get_or_create_end_user(
self,
app_id: uuid.UUID,

View File

@@ -51,7 +51,7 @@ class ApiKeyService:
if existing:
raise BusinessException(f"API Key 名称 {data.name} 已存在", BizCode.API_KEY_DUPLICATE_NAME)
# 若 rate_limit 超过租户套餐的 api_ops_rate_limit自动截断到套餐上限
# 若 rate_limit 超过租户套餐的 api_ops_rate_limit直接报错
from app.models.workspace_model import Workspace
from app.core.quota_manager import get_api_ops_rate_limit
@@ -59,7 +59,10 @@ class ApiKeyService:
if workspace:
tenant_api_ops_limit = get_api_ops_rate_limit(db, workspace.tenant_id)
if tenant_api_ops_limit and data.rate_limit > tenant_api_ops_limit:
data.rate_limit = tenant_api_ops_limit
raise BusinessException(
f"API Key QPS 不能超过套餐上限 {tenant_api_ops_limit}",
BizCode.BAD_REQUEST
)
# 生成 API Key
api_key = generate_api_key(data.type)
@@ -162,7 +165,7 @@ class ApiKeyService:
if existing:
raise BusinessException(f"API Key 名称 {data.name} 已存在", BizCode.API_KEY_DUPLICATE_NAME)
# 若 rate_limit 超过租户套餐的 api_ops_rate_limit自动截断到套餐上限
# 若 rate_limit 超过租户套餐的 api_ops_rate_limit直接报错
if data.rate_limit is not None:
from app.models.workspace_model import Workspace
from app.core.quota_manager import get_api_ops_rate_limit
@@ -171,7 +174,10 @@ class ApiKeyService:
if workspace:
tenant_api_ops_limit = get_api_ops_rate_limit(db, workspace.tenant_id)
if tenant_api_ops_limit and data.rate_limit > tenant_api_ops_limit:
data.rate_limit = tenant_api_ops_limit
raise BusinessException(
f"API Key QPS 不能超过套餐上限 {tenant_api_ops_limit}",
BizCode.BAD_REQUEST
)
update_data = data.model_dump(exclude_unset=True)
ApiKeyRepository.update(db, api_key_id, update_data)

View File

@@ -434,19 +434,37 @@ class AppDslService:
def _resolve_model(self, ref: Optional[dict], tenant_id: uuid.UUID, warnings: list) -> Optional[uuid.UUID]:
if not ref:
return None
q = self.db.query(ModelConfig).filter(
ModelConfig.tenant_id == tenant_id,
ModelConfig.name == ref.get("name"),
ModelConfig.is_active.is_(True)
)
if ref.get("provider"):
q = q.filter(ModelConfig.provider == ref["provider"])
if ref.get("type"):
q = q.filter(ModelConfig.type == ref["type"])
m = q.first()
if not m:
warnings.append(f"模型 '{ref.get('name')}' 未匹配,已置空,请导入后手动配置")
return m.id if m else None
model_id = ref.get("id")
if model_id:
try:
model_uuid = uuid.UUID(str(model_id))
m = self.db.query(ModelConfig).filter(
ModelConfig.id == model_uuid,
ModelConfig.tenant_id == tenant_id,
ModelConfig.is_active.is_(True)
).first()
if m:
return str(m.id)
except (ValueError, AttributeError):
pass
model_name = ref.get("name")
if model_name:
q = self.db.query(ModelConfig).filter(
ModelConfig.tenant_id == tenant_id,
ModelConfig.name == model_name,
ModelConfig.is_active.is_(True)
)
if ref.get("provider"):
q = q.filter(ModelConfig.provider == ref["provider"])
if ref.get("type"):
q = q.filter(ModelConfig.type == ref["type"])
m = q.first()
if m:
return str(m.id)
warnings.append(f"模型 '{model_name}' 未匹配,已置空,请导入后手动配置")
else:
warnings.append(f"模型 ID '{model_id}' 未匹配,已置空,请导入后手动配置")
return None
def _resolve_kb(self, ref: Optional[dict], workspace_id: uuid.UUID, warnings: list) -> Optional[str]:
if not ref:
@@ -587,7 +605,7 @@ class AppDslService:
if not kb_id:
continue
kb_ref = {}
if isinstance(kb_id, str) and len(kb_id) >= 36:
if isinstance(kb_id, str):
try:
uuid.UUID(kb_id)
kb_ref["id"] = kb_id
@@ -601,6 +619,33 @@ class AppDslService:
else:
warnings.append(f"[{node_label}] 知识库 '{kb_id}' 未匹配,已移除,请导入后手动配置")
config["knowledge_bases"] = resolved_kbs
elif node_type in (NodeType.LLM.value, NodeType.QUESTION_CLASSIFIER.value, NodeType.PARAMETER_EXTRACTOR.value):
model_ref = config.get("model_id")
if model_ref:
ref_dict = None
if isinstance(model_ref, dict):
ref_id = model_ref.get("id")
ref_name = model_ref.get("name")
if ref_id:
ref_dict = {"id": ref_id}
elif ref_name is not None:
ref_dict = {"name": ref_name, "provider": model_ref.get("provider"), "type": model_ref.get("type")}
elif isinstance(model_ref, str):
try:
uuid.UUID(model_ref)
ref_dict = {"id": model_ref}
except ValueError:
ref_dict = {"name": model_ref}
if ref_dict:
resolved_model_id = self._resolve_model(ref_dict, tenant_id, warnings)
if resolved_model_id:
config["model_id"] = resolved_model_id
else:
warnings.append(f"[{node_label}] 模型未匹配,已置空,请导入后手动配置")
config["model_id"] = None
else:
warnings.append(f"[{node_label}] 模型未匹配,已置空,请导入后手动配置")
config["model_id"] = None
resolved_nodes.append({**node, "config": config})
return resolved_nodes

View File

@@ -7,7 +7,6 @@ from app.models.models_model import ModelConfig
from app.schemas.knowledge_schema import KnowledgeCreate, KnowledgeUpdate
from app.repositories import knowledge_repository
from app.core.logging_config import get_business_logger
from app.repositories.model_repository import ModelConfigRepository
from app.models.models_model import ModelType
business_logger = get_business_logger()
@@ -77,53 +76,32 @@ def create_knowledge(
tenant_id = workspace.tenant_id
def _get_model_by_name_or_fallback(model_name: str | None, model_types: list, label: str):
"""优先按 workspace 指定的 model name 查,找不到再 fallback 到 tenant 下第一个"""
if model_name:
model = db.query(ModelConfig).filter(
ModelConfig.tenant_id == tenant_id,
ModelConfig.name == model_name,
ModelConfig.type.in_([t.value for t in model_types]),
ModelConfig.is_active == True,
ModelConfig.is_composite == False
).first()
if model:
business_logger.debug(f"Auto-bind {label} model from workspace default: {model.id} ({model_name})")
return model
business_logger.debug(f"Workspace default {label} model '{model_name}' not found, falling back to tenant")
models = ModelConfigRepository.get_by_type(db=db, model_types=model_types, tenant_id=tenant_id, is_active=True)
if models:
business_logger.debug(f"Auto-bind {label} model from tenant fallback: {models[0].id}")
return models[0]
return None
if not knowledge.embedding_id:
model = _get_model_by_name_or_fallback(workspace.embedding, [ModelType.EMBEDDING], "embedding")
if model:
knowledge.embedding_id = model.id
if not workspace.embedding:
raise Exception("工作空间未配置 Embedding 模型,请先完善工作空间配置后重试")
knowledge.embedding_id = workspace.embedding
if not knowledge.reranker_id:
model = _get_model_by_name_or_fallback(workspace.rerank, [ModelType.RERANK], "rerank")
if model:
knowledge.reranker_id = model.id
if not workspace.rerank:
raise Exception("工作空间未配置 Rerank 模型,请先完善工作空间配置后重试")
knowledge.reranker_id = workspace.rerank
if not knowledge.llm_id:
model = _get_model_by_name_or_fallback(workspace.llm, [ModelType.LLM, ModelType.CHAT], "llm")
if model:
knowledge.llm_id = model.id
if not workspace.llm:
raise Exception("工作空间未配置 LLM 模型,请先完善工作空间配置后重试")
knowledge.llm_id = workspace.llm
if not knowledge.image2text_id:
image2text_models = db.query(ModelConfig).filter(
model = db.query(ModelConfig).filter(
ModelConfig.tenant_id == tenant_id,
ModelConfig.type.in_([ModelType.CHAT.value]),
ModelConfig.type.in_([ModelType.CHAT.value, ModelType.LLM.value]),
ModelConfig.capability.contains(["vision"]),
ModelConfig.is_active == True,
ModelConfig.is_composite == False
).order_by(ModelConfig.created_at.desc()).all()
if not image2text_models:
).order_by(ModelConfig.created_at.desc()).first()
if not model:
raise Exception("租户下没有可用的视觉模型,创建知识库失败")
knowledge.image2text_id = image2text_models[0].id
business_logger.debug(f"Auto-bind image2text model: {image2text_models[0].id}")
knowledge.image2text_id = model.id
business_logger.debug(f"Auto-bind image2text model: {model.id}")
business_logger.debug(f"Start creating the knowledge base: {knowledge.name}")
db_knowledge = knowledge_repository.create_knowledge(

View File

@@ -251,8 +251,40 @@ def parse_document(file_path: str, document_id: uuid.UUID):
# Prepare vision_model for parsing
vision_model = _build_vision_model(file_path, db_knowledge)
# 先将文件读入内存,避免解析过程中依赖 NFS 文件持续可访问
# python-docx 等库在 binary=None 时会用路径直接打开文件,
# 在 NFS/共享存储上可能因缓存失效导致 "Package not found"
max_wait_seconds = 30
wait_interval = 2
waited = 0
file_binary = None
while waited <= max_wait_seconds:
# os.listdir 强制 NFS 客户端刷新目录缓存
parent_dir = os.path.dirname(file_path)
try:
os.listdir(parent_dir)
except OSError:
pass
try:
with open(file_path, "rb") as f:
file_binary = f.read()
if not file_binary:
# NFS 上文件存在但内容为空(可能还在同步中)
raise IOError(f"File is empty (0 bytes), NFS may still be syncing: {file_path}")
break
except (FileNotFoundError, IOError) as e:
if waited >= max_wait_seconds:
raise type(e)(
f"File not accessible at '{file_path}' after waiting {max_wait_seconds}s: {e}"
)
logger.warning(f"File not ready on this node, retrying in {wait_interval}s: {file_path} ({e})")
time.sleep(wait_interval)
waited += wait_interval
from app.core.rag.app.naive import chunk
logger.info(f"[ParseDoc] file_binary size={len(file_binary)} bytes, type={type(file_binary).__name__}, bool={bool(file_binary)}")
res = chunk(filename=file_path,
binary=file_binary,
from_page=0,
to_page=DEFAULT_PARSE_TO_PAGE,
callback=progress_callback,

View File

@@ -2,11 +2,11 @@
* @Author: ZhaoYing
* @Date: 2026-04-14 12:28:23
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-16 17:34:02
* @Last Modified time: 2026-04-21 15:46:35
*/
import { useState, forwardRef, useImperativeHandle } from 'react';
import { Flex, Tooltip, Divider } from 'antd';
import { Flex, Divider } from 'antd';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
@@ -82,8 +82,7 @@ const SubscriptionDetailModal = forwardRef<SubscriptionDetailModalRef>((_props,
{/* Features */}
<Flex gap={12} vertical className="rb:space-y-3 rb:mb-4 rb:h-[calc(100vh-341px)]! rb:overflow-y-auto">
{billingUnits.map(({ key, unit, icon }) => {
const value = detail?.quotas[key as keyof Subscription['quotas']];
if (value === undefined || value === null) return null;
const value = detail?.quotas?.[key as keyof Subscription['quotas']];
return (
<UnitWrapper
key={key}
@@ -95,7 +94,7 @@ const SubscriptionDetailModal = forwardRef<SubscriptionDetailModalRef>((_props,
/>
)
})}
{detail?.package_plan?.tech_support && (
{detail?.package_plan?.tech_support && detail?.package_plan?.[getKeyWithLanguage('tech_support')] && (
<UnitWrapper
titleKey="tech_support"
value={String(detail?.package_plan?.[getKeyWithLanguage('tech_support')] ?? '')}
@@ -103,7 +102,7 @@ const SubscriptionDetailModal = forwardRef<SubscriptionDetailModalRef>((_props,
theme_color={detail?.package_plan?.theme_color}
/>
)}
{detail?.package_plan?.sla_compliance && (
{detail?.package_plan?.sla_compliance && detail?.package_plan?.[getKeyWithLanguage('sla_compliance')] && (
<UnitWrapper
titleKey="sla"
value={String(detail?.package_plan?.[getKeyWithLanguage('sla_compliance')] ?? '')}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:25:31
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-20 10:15:20
* @Last Modified time: 2026-04-21 15:46:03
*/
/**
* SiderMenu Component
@@ -417,7 +417,7 @@ const Menu: FC<{
<div className="rb:grid rb:grid-cols-4 rb:mt-4">
{['workspace_quota', 'skill_quota', 'app_quota', 'model_quota'].map(key => (
<div key={key} className="rb:text-center">
<div className="rb:text-[13px] rb:font-[MiSans-Semibold] rb:font-semibold">{subscription.quotas?.[key as keyof typeof subscription.quotas]}</div>
<div className="rb:text-[13px] rb:font-[MiSans-Semibold] rb:font-semibold">{subscription.quotas?.[key as keyof typeof subscription.quotas] ?? t('package.noLimit')}</div>
<div className="rb:mt-1 rb:text-[#5B6167] rb:text-[10px] rb:leading-3.5">{t(`index.${key}`)}</div>
</div>
))}

View File

@@ -451,6 +451,9 @@ export const en = {
logoutApiCannotRefreshToken: 'Logout API cannot refresh token',
publicApiCannotRefreshToken: 'Public API cannot refresh token',
refreshTokenNotExist: 'Refresh token does not exist',
SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: 'This is a system preset scene and cannot be deleted',
SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: 'This scene is a system preset scene and cannot be deleted',
SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE: 'This scene is a system preset scene and cannot be modified',
reset: 'Reset',
refresh: 'Refresh',
return: 'Return',
@@ -3093,6 +3096,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
editPackage: 'Edit Package',
viewDetail: 'View full package details',
noLimit: 'Unlimited',
},
},
};

View File

@@ -1130,6 +1130,9 @@ export const zh = {
logoutApiCannotRefreshToken: '退出登录接口不能刷新token',
publicApiCannotRefreshToken: '公共接口不能刷新token',
refreshTokenNotExist: '刷新token不存在',
SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: '该场景为系统预设场景,不允许删除',
SYSTEM_DEFAULT_CLASS_CANNOT_DELETE: '该场景为系统预设场景,不允许删除',
SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE: '该场景为系统预设场景,不允许修改',
reset: '重置',
refresh: '刷新',
return: '返回',
@@ -3057,6 +3060,7 @@ export const zh = {
editPackage: '编辑套餐',
viewDetail: '查看完整套餐详情',
noLimit: '不限制',
},
},
}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 16:35:43
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-18 14:32:40
* @Last Modified time: 2026-04-22 10:16:43
*/
/**
* Server-Sent Events (SSE) Stream Utility Module
@@ -176,12 +176,12 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
case 500:
case 502:
const errorData = await response.json();
const errorInfo = errorData.error || i18n.t('common.serviceUpgrading');
const errorInfo = errorData.error || errorData.msg || i18n.t('common.serviceUpgrading');
message.warning(errorInfo);
throw new Error(errorData);
case 400:
const error = await response.json();
const error400 = error.error || 'Bad Request';
const error400 = error.error || error.msg || 'Bad Request';
message.warning(error400);
throw new Error(error);
case 403:
@@ -190,7 +190,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
throw new Error(errors);
case 504:
const errorJson = await response.json();
const errorMsg = errorJson.error || i18n.t('common.serverError');
const errorMsg = errorJson.error || errorJson.msg || i18n.t('common.serverError');
message.warning(errorMsg);
throw new Error(errorJson);
case 401:
@@ -204,6 +204,13 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
return;
}
break;
default:
if (!response.ok) {
const defaultData = await response.json().catch(() => ({}));
const defaultMsg = defaultData.error || defaultData.msg;
if (defaultMsg) message.warning(defaultMsg);
throw new Error(defaultMsg || `HTTP ${response.status}`);
}
}
if (!response.body) throw new Error('No response body');

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:25:32
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-04 10:34:43
* @Last Modified time: 2026-04-21 13:34:52
*/
/**
* Knowledge Base Component
@@ -54,7 +54,7 @@ const Knowledge: FC<{value?: KnowledgeConfig; onChange?: (config: KnowledgeConfi
const basesWithoutName = knowledge_bases.filter(base => !base.name)
if (basesWithoutName.length > 0) {
// Call API to get complete knowledge base information
getKnowledgeBaseList().then(res => {
getKnowledgeBaseList(undefined, { kb_ids: basesWithoutName.map(vo => vo.kb_id).join(',') }).then(res => {
const fullBases = knowledge_bases.map(base => {
if (!base.name) {
const fullBase = res.items.find((item: any) => item.id === base.kb_id)

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:49:28
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-16 18:03:53
* @Last Modified time: 2026-04-21 15:02:53
*/
/**
* Custom Model Modal
@@ -230,21 +230,23 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
<Input.TextArea placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item
name={["api_keys", 0, "api_key"]}
label={t('modelNew.api_key')}
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('modelNew.api_key') }) }]}
>
<Input.Password placeholder={t('common.pleaseEnter')} />
</Form.Item>
{!isEdit && <>
<Form.Item
name={["api_keys", 0, "api_key"]}
label={t('modelNew.api_key')}
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('modelNew.api_key') }) }]}
>
<Input.Password placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item
name={["api_keys", 0, "api_base"]}
label={t('modelNew.api_base')}
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('modelNew.api_base') }) }]}
>
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item
name={["api_keys", 0, "api_base"]}
label={t('modelNew.api_base')}
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('modelNew.api_base') }) }]}
>
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
</>}
{['llm', 'chat'].includes(modelType as string) &&
<Row gutter={16}>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-04-14 11:43:57
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-14 11:44:40
* @Last Modified time: 2026-04-21 15:44:13
*/
export const billingUnits = [
{
@@ -42,7 +42,7 @@ export const billingUnits = [
},
{
key: 'model_quota',
unit: 'ops', placeholder: 'numberPlaceholder',
unit: 'pcs', placeholder: 'numberPlaceholder',
icon: 'model',
},
{

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-04-14 11:34:42
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-16 17:23:49
* @Last Modified time: 2026-04-21 15:45:30
*/
/**
* Package Component
@@ -60,7 +60,7 @@ const btnClassNames = {
default: 'rb:h-10! rb:rounded-[8px]! rb:bg-[#212332]! rb:text-white! rb:border-0! rb:hover:border-0! rb:hover:opacity-[0.8]',
}
export const UnitWrapper = ({ titleKey, value, icon, unit, theme_color = '#171719' }: { titleKey: string; value: number | string; icon: string; unit?: string; theme_color?: string; }) => {
export const UnitWrapper = ({ titleKey, value, icon, unit, theme_color = '#171719' }: { titleKey: string; value?: number | string | null; icon: string; unit?: string; theme_color?: string; }) => {
const { t } = useTranslation();
const renderFeatureIcon = (iconKey: string, color: string) => {
@@ -78,7 +78,7 @@ export const UnitWrapper = ({ titleKey, value, icon, unit, theme_color = '#17171
>{renderFeatureIcon(icon, theme_color)}</Flex>
<div className="rb:text-[13px] rb:leading-4.5">
<div className="rb:text-[#5F6266]">{t(`package.${titleKey}`)}</div>
<div>{value} {unit ? t(`package.${unit}`) : ''}</div>
{value ? <div>{value} {unit ? t(`package.${unit}`) : ''}</div> : <div>{t('package.noLimit')}</div>}
</div>
</Flex>
)
@@ -252,7 +252,6 @@ const Package: FC = () => {
>
{billingUnits.map(({ key, unit, icon }) => {
const value = pkg?.quotas?.[key as keyof Package['quotas']];
if (value === undefined || value === null) return null;
return (
<UnitWrapper
key={key}
@@ -264,7 +263,7 @@ const Package: FC = () => {
/>
)
})}
{pkg.tech_support && (
{pkg.tech_support && pkg[getKeyWithLanguage('tech_support')] && (
<UnitWrapper
titleKey="tech_support"
value={String(pkg[getKeyWithLanguage('tech_support')] ?? '')}
@@ -272,7 +271,7 @@ const Package: FC = () => {
theme_color={pkg.theme_color}
/>
)}
{pkg.sla_compliance && (
{pkg.sla_compliance && pkg[getKeyWithLanguage('sla_compliance')] && (
<UnitWrapper
titleKey="sla"
value={String(pkg[getKeyWithLanguage('sla_compliance')] ?? '')}

View File

@@ -29,12 +29,13 @@ const Knowledge: FC<{value?: KnowledgeConfig; onChange?: (config: KnowledgeConfi
if (value && JSON.stringify(value) !== JSON.stringify(editConfig)) {
setEditConfig({ ...(value || {}) })
const knowledge_bases = [...(value.knowledge_bases || [])]
setKnowledgeList(knowledge_bases)
// 检查是否有knowledge_bases缺少name字段
const basesWithoutName = knowledge_bases.filter(base => !base.name)
if (basesWithoutName.length > 0) {
// 调用接口获取完整的知识库信息
getKnowledgeBaseList().then(res => {
getKnowledgeBaseList(undefined, { kb_ids: basesWithoutName.map(vo => vo.kb_id).join(',') }).then(res => {
const fullBases = knowledge_bases.map(base => {
if (!base.name) {
const fullBase = res.items.find((item: any) => item.id === base.kb_id)

View File

@@ -1,4 +1,4 @@
import { type FC, useEffect, useState, useMemo } from "react";
import { type FC, useEffect, useState } from "react";
import { useTranslation } from 'react-i18next'
import { Form, Select, Switch, Cascader, type CascaderProps, Tooltip } from 'antd'
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
@@ -45,15 +45,15 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
getToolDetail(values.tool_id)
.then(res => {
const detail = res as { tool_type: ToolType; }
getTools({ tool_type: detail.tool_type })
.then(toolsRes => {
const tools = toolsRes as ToolItem[]
getToolMethods(values.tool_id)
.then(methodsRes => {
const response = methodsRes as Array<{ method_id: string; name: string; parameters: Parameter[] }>
setOptionList(prevList => {
return prevList.map(item => {
if (item.value === detail.tool_type) {
@@ -76,7 +76,7 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
return item
})
})
if (response.length > 1) {
const filterTarget = response.find(vo => vo.name === values.tool_parameters?.operation)
if (filterTarget) {
@@ -98,7 +98,7 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
useEffect(() => {
if (values.tools && values.tools.length === 3) {
const [toolType, toolId, operation] = values.tools
// 从 optionList 中查找对应的参数
const typeOption = optionList.find(opt => opt.value === toolType)
if (typeOption?.children) {
@@ -155,18 +155,18 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
const targetOption = selectedOptions[selectedOptions.length - 1];
const curParameters = [...(targetOption.parameters ?? [])]
setParameters([...curParameters])
const inititalValue: any = { tool_id: selectedOptions[1].value, tool_parameters: {} }
const initialValue: any = { tool_id: selectedOptions[1].value, tool_parameters: { operation: undefined } }
if (value[0] === 'mcp' || (value[0] === 'builtin' && selectedOptions[1]?.children && selectedOptions[1].children.length > 1)) {
inititalValue.tool_parameters.operation = value?.[2]
initialValue.tool_parameters.operation = value?.[2]
} else if (value[0] === 'custom') {
inititalValue.tool_parameters.operation = selectedOptions?.[2].method_id
initialValue.tool_parameters.operation = selectedOptions?.[2].method_id
}
curParameters.forEach(vo => {
inititalValue.tool_parameters[vo.name] = vo.default
initialValue.tool_parameters[vo.name] = vo.default
})
form.setFieldsValue(inititalValue)
form.setFieldsValue(initialValue)
}
// string -> string
@@ -214,9 +214,9 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
name="tools"
label={t('workflow.config.tool.tool_id')}
>
<Cascader
<Cascader
placeholder={t('common.pleaseSelect')}
options={optionList}
options={optionList}
loadData={loadData}
onChange={handleChange}
changeOnSelect={false}
@@ -244,8 +244,8 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
{parameter.type === 'string' && parameter.enum && parameter.enum.length > 0
? <Select size="small" options={parameter.enum.map(vo => ({ value: vo, label: vo }))} placeholder={t('common.pleaseSelect')} />
: parameter.type === 'boolean'
? <Switch size="small" />
: <Editor
? <Switch size="small" />
: <Editor
variant="outlined"
type="input"
size="small"