Merge pull request #833 from SuanmoSuanyangTechnology/release/v0.2.10
Release/v0.2.10
This commit is contained in:
@@ -14,6 +14,7 @@ from typing import Any, AsyncGenerator, Dict, List, Optional, Sequence
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
|
||||
from langchain_core.tools import BaseTool
|
||||
from langgraph.errors import GraphRecursionError
|
||||
|
||||
from app.core.logging_config import get_business_logger
|
||||
from app.core.models import RedBearLLM, RedBearModelConfig
|
||||
@@ -377,7 +378,7 @@ class LangChainAgent:
|
||||
{"messages": messages},
|
||||
config={"recursion_limit": self.max_iterations}
|
||||
)
|
||||
except RecursionError as e:
|
||||
except (RecursionError, GraphRecursionError) as e:
|
||||
logger.warning(
|
||||
f"Agent 达到最大迭代次数限制 ({self.max_iterations}),可能存在工具调用循环",
|
||||
extra={"error": str(e)}
|
||||
@@ -612,6 +613,12 @@ class LangChainAgent:
|
||||
yield stream_total_tokens
|
||||
break
|
||||
|
||||
except GraphRecursionError:
|
||||
logger.warning(
|
||||
f"Agent 达到最大迭代次数限制 ({self.max_iterations}),模型可能不支持正确的工具调用停止判断"
|
||||
)
|
||||
if not full_content:
|
||||
yield "抱歉,我在处理您的请求时遇到了问题(已达最大处理步骤限制)。请尝试简化问题或更换模型后重试。"
|
||||
except Exception as e:
|
||||
logger.error(f"Agent astream_events 失败: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
@@ -19,6 +19,7 @@ class BizCode(IntEnum):
|
||||
TENANT_NOT_FOUND = 3002
|
||||
WORKSPACE_NO_ACCESS = 3003
|
||||
WORKSPACE_INVITE_NOT_FOUND = 3004
|
||||
WORKSPACE_ACCESS_DENIED = 3005
|
||||
# API Key 管理(3xxx)
|
||||
API_KEY_NOT_FOUND = 3007
|
||||
API_KEY_DUPLICATE_NAME = 3008
|
||||
@@ -113,6 +114,8 @@ HTTP_MAPPING = {
|
||||
BizCode.FORBIDDEN: 403,
|
||||
BizCode.TENANT_NOT_FOUND: 400,
|
||||
BizCode.WORKSPACE_NO_ACCESS: 403,
|
||||
BizCode.WORKSPACE_INVITE_NOT_FOUND: 400,
|
||||
BizCode.WORKSPACE_ACCESS_DENIED: 403,
|
||||
BizCode.NOT_FOUND: 400,
|
||||
BizCode.USER_NOT_FOUND: 200,
|
||||
BizCode.WORKSPACE_NOT_FOUND: 400,
|
||||
|
||||
@@ -79,8 +79,10 @@ class RedBearModelFactory:
|
||||
model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
|
||||
if is_streaming:
|
||||
model_kwargs["enable_thinking"] = config.deep_thinking
|
||||
if config.deep_thinking and config.thinking_budget_tokens:
|
||||
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
|
||||
if config.deep_thinking:
|
||||
model_kwargs["incremental_output"] = True
|
||||
if config.thinking_budget_tokens:
|
||||
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
|
||||
else:
|
||||
model_kwargs["enable_thinking"] = False
|
||||
params["model_kwargs"] = model_kwargs
|
||||
@@ -110,7 +112,7 @@ class RedBearModelFactory:
|
||||
params["stream_usage"] = True
|
||||
# 深度思考模式
|
||||
is_streaming = bool(config.extra_params.get("streaming"))
|
||||
if is_streaming:
|
||||
if is_streaming and not config.is_omni:
|
||||
if provider == ModelProvider.VOLCANO:
|
||||
# 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数
|
||||
thinking_config: Dict[str, Any] = {
|
||||
@@ -140,8 +142,10 @@ class RedBearModelFactory:
|
||||
model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
|
||||
if is_streaming:
|
||||
model_kwargs["enable_thinking"] = config.deep_thinking
|
||||
if config.deep_thinking and config.thinking_budget_tokens:
|
||||
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
|
||||
if config.deep_thinking:
|
||||
model_kwargs["incremental_output"] = True
|
||||
if config.thinking_budget_tokens:
|
||||
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
|
||||
else:
|
||||
model_kwargs["enable_thinking"] = False
|
||||
params["model_kwargs"] = model_kwargs
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Union
|
||||
from langchain_core.embeddings import Embeddings
|
||||
|
||||
from app.core.models.base import RedBearModelConfig, get_provider_embedding_class, RedBearModelFactory
|
||||
@@ -22,7 +22,8 @@ class RedBearEmbeddings(Embeddings):
|
||||
self._model = self._create_model(config)
|
||||
self._client = None
|
||||
|
||||
def _create_model(self, config: RedBearModelConfig) -> Embeddings:
|
||||
@staticmethod
|
||||
def _create_model(config: RedBearModelConfig) -> Embeddings:
|
||||
"""根据配置创建 LangChain 模型"""
|
||||
embedding_class = get_provider_embedding_class(config.provider)
|
||||
provider = config.provider.lower()
|
||||
@@ -36,6 +37,8 @@ class RedBearEmbeddings(Embeddings):
|
||||
"api_key": config.api_key,
|
||||
"timeout": httpx.Timeout(timeout=config.timeout, connect=60.0),
|
||||
"max_retries": config.max_retries,
|
||||
"check_embedding_ctx_length": False,
|
||||
"encoding_format": "float"
|
||||
}
|
||||
elif provider == ModelProvider.DASHSCOPE:
|
||||
params = {
|
||||
|
||||
@@ -803,7 +803,6 @@ models:
|
||||
- vision
|
||||
- video
|
||||
- audio
|
||||
- thinking
|
||||
is_omni: true
|
||||
tags:
|
||||
- 大语言模型
|
||||
|
||||
@@ -131,7 +131,7 @@ class DifyConverter(BaseConverter):
|
||||
selector = var_selector.split('.')
|
||||
if len(selector) not in [2, 3] and var_selector != "context":
|
||||
raise Exception(f"invalid variable selector: {var_selector}")
|
||||
if len(selector) == 3:
|
||||
if len(selector) == 3 and selector[0] in ("conversation", "sys"):
|
||||
selector = selector[1:]
|
||||
if selector[0] == "conversation":
|
||||
selector[0] = "conv"
|
||||
@@ -483,11 +483,11 @@ class DifyConverter(BaseConverter):
|
||||
node_data = node["data"]
|
||||
result = IterationNodeConfig.model_construct(
|
||||
input=self._process_list_variable_literal(node_data["iterator_selector"]),
|
||||
parallel=node_data["is_parallel"],
|
||||
parallel_count=node_data["parallel_nums"],
|
||||
parallel=node_data.get("is_parallel", False),
|
||||
parallel_count=node_data.get("parallel_nums", 4),
|
||||
output=self._process_list_variable_literal(node_data["output_selector"]),
|
||||
output_type=self.variable_type_map(node_data.get("output_type")),
|
||||
flatten=node_data["flatten_output"],
|
||||
flatten=node_data.get("flatten_output", False),
|
||||
).model_dump()
|
||||
|
||||
self.config_validate(node["id"], node["data"]["title"], IterationNodeConfig, result)
|
||||
@@ -496,7 +496,23 @@ class DifyConverter(BaseConverter):
|
||||
def convert_assigner_node_config(self, node: dict) -> dict:
|
||||
node_data = node["data"]
|
||||
assignments = []
|
||||
for assignment in node_data["items"]:
|
||||
|
||||
# Support both formats:
|
||||
# 1. New format: node_data["items"] list
|
||||
# 2. Flat format: assigned_variable_selector + input_variable_selector + write_mode
|
||||
if "items" in node_data:
|
||||
raw_items = node_data["items"]
|
||||
elif "assigned_variable_selector" in node_data and "input_variable_selector" in node_data:
|
||||
raw_items = [{
|
||||
"variable_selector": node_data["assigned_variable_selector"],
|
||||
"value": node_data["input_variable_selector"],
|
||||
"input_type": ValueInputType.VARIABLE,
|
||||
"operation": node_data.get("write_mode", "over-write"),
|
||||
}]
|
||||
else:
|
||||
raw_items = []
|
||||
|
||||
for assignment in raw_items:
|
||||
if assignment.get("operation") is None or assignment.get("value") is None:
|
||||
continue
|
||||
assignments.append(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import Any
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.core.workflow.nodes.base_config import BaseNodeConfig
|
||||
from app.core.workflow.nodes.enums import ComparisonOperator
|
||||
@@ -31,6 +31,11 @@ class ExtractConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
serial: str = "1" # 1-based index string, e.g. "1" = first
|
||||
|
||||
@field_validator("serial", mode="before")
|
||||
@classmethod
|
||||
def coerce_serial(cls, v):
|
||||
return str(v)
|
||||
|
||||
|
||||
class ListOperatorNodeConfig(BaseNodeConfig):
|
||||
"""
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.core.workflow.variable.base_variable import VariableType
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# File object fields that hold string values
|
||||
_FILE_STRING_KEYS = {"name", "extension", "mime_type", "url", "transfer_method", "origin_file_type", "file_id"}
|
||||
_FILE_STRING_KEYS = {"type", "name", "url", "extension", "mime_type", "transfer_method", "origin_file_type", "file_id"}
|
||||
_FILE_NUMBER_KEYS = {"size"}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ class ListOperatorNode(BaseNode):
|
||||
result = [result[idx]]
|
||||
|
||||
# 3. Order
|
||||
if cfg.order_by.enabled and cfg.order_by.key:
|
||||
if cfg.order_by.enabled:
|
||||
reverse = cfg.order_by.value == "desc"
|
||||
key_fn = self._make_sort_key(cfg.order_by.key)
|
||||
result = sorted(result, key=key_fn, reverse=reverse)
|
||||
@@ -100,10 +100,17 @@ class ListOperatorNode(BaseNode):
|
||||
else:
|
||||
left = item # primitive array: compare element directly
|
||||
|
||||
# Determine if this field should be compared as a string
|
||||
is_string_field = isinstance(item, dict) and cond.key in _FILE_STRING_KEYS
|
||||
|
||||
# Numeric operators
|
||||
if op == ComparisonOperator.EQ:
|
||||
if is_string_field:
|
||||
return str(left) == str(value)
|
||||
return self._safe_num(left) == self._safe_num(value)
|
||||
if op == ComparisonOperator.NE:
|
||||
if is_string_field:
|
||||
return str(left) != str(value)
|
||||
return self._safe_num(left) != self._safe_num(value)
|
||||
if op == ComparisonOperator.LT:
|
||||
return self._safe_num(left) < self._safe_num(value)
|
||||
|
||||
@@ -246,7 +246,10 @@ class LLMNode(BaseNode):
|
||||
logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(content)}")
|
||||
|
||||
# 返回 AIMessage(包含响应元数据)
|
||||
return AIMessage(content=content, response_metadata=response.response_metadata)
|
||||
return AIMessage(content=content, response_metadata={
|
||||
**response.response_metadata,
|
||||
"token_usage": getattr(response, 'usage_metadata', None) or response.response_metadata.get('token_usage')
|
||||
})
|
||||
|
||||
def _extract_input(self, state: WorkflowState, variable_pool: VariablePool) -> dict[str, Any]:
|
||||
"""提取输入数据(用于记录)"""
|
||||
@@ -305,15 +308,16 @@ class LLMNode(BaseNode):
|
||||
|
||||
# 调用 LLM(流式,支持字符串或消息列表)
|
||||
last_meta_data = {}
|
||||
last_usage_metadata = {}
|
||||
async for chunk in llm.astream(self.messages):
|
||||
# 提取内容
|
||||
if hasattr(chunk, 'content'):
|
||||
content = self.process_model_output(chunk.content)
|
||||
else:
|
||||
content = str(chunk)
|
||||
if hasattr(chunk, 'response_metadata'):
|
||||
if chunk.response_metadata:
|
||||
last_meta_data = chunk.response_metadata
|
||||
if hasattr(chunk, 'response_metadata') and chunk.response_metadata:
|
||||
last_meta_data = chunk.response_metadata
|
||||
if hasattr(chunk, 'usage_metadata') and chunk.usage_metadata:
|
||||
last_usage_metadata = chunk.usage_metadata
|
||||
|
||||
# 只有当内容不为空时才处理
|
||||
if content:
|
||||
@@ -336,7 +340,10 @@ class LLMNode(BaseNode):
|
||||
# 构建完整的 AIMessage(包含元数据)
|
||||
final_message = AIMessage(
|
||||
content=full_response,
|
||||
response_metadata=last_meta_data
|
||||
response_metadata={
|
||||
**last_meta_data,
|
||||
"token_usage": last_usage_metadata or last_meta_data.get('token_usage')
|
||||
}
|
||||
)
|
||||
|
||||
# yield 完成标记
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.core.workflow.engine.state_manager import WorkflowState
|
||||
from app.core.workflow.engine.variable_pool import VariablePool
|
||||
from app.core.workflow.nodes.base_node import BaseNode
|
||||
from app.core.workflow.nodes.parameter_extractor.config import ParameterExtractorNodeConfig
|
||||
from app.core.workflow.variable.base_variable import VariableType
|
||||
from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE
|
||||
from app.db import get_db_read
|
||||
from app.models import ModelType
|
||||
from app.services.model_service import ModelConfigService
|
||||
@@ -45,6 +45,12 @@ class ParameterExtractorNode(BaseNode):
|
||||
"model_id": str(self.typed_config.model_id),
|
||||
}
|
||||
|
||||
def _extract_output(self, business_result: Any) -> Any:
|
||||
final_output = {}
|
||||
for param in self.typed_config.params:
|
||||
final_output[param.name] = business_result.get(param.name) or DEFAULT_VALUE(self.output_types[param.name])
|
||||
return final_output
|
||||
|
||||
def _output_types(self) -> dict[str, VariableType]:
|
||||
outputs = {}
|
||||
for param in self.typed_config.params:
|
||||
@@ -202,7 +208,10 @@ class ParameterExtractorNode(BaseNode):
|
||||
])
|
||||
|
||||
model_resp = await llm.ainvoke(messages)
|
||||
self.response_metadata = model_resp.response_metadata
|
||||
self.response_metadata = {
|
||||
**model_resp.response_metadata,
|
||||
"token_usage": getattr(model_resp, 'usage_metadata', None) or model_resp.response_metadata.get('token_usage')
|
||||
}
|
||||
model_message = self.process_model_output(model_resp.content)
|
||||
result = json_repair.repair_json(model_message, return_objects=True)
|
||||
logger.info(f"node: {self.node_id} get params:{result}")
|
||||
|
||||
@@ -136,7 +136,10 @@ class QuestionClassifierNode(BaseNode):
|
||||
|
||||
response = await llm.ainvoke(messages)
|
||||
result = self.process_model_output(response.content)
|
||||
self.response_metadata = response.response_metadata
|
||||
self.response_metadata = {
|
||||
**response.response_metadata,
|
||||
"token_usage": getattr(response, 'usage_metadata', None) or response.response_metadata.get('token_usage')
|
||||
}
|
||||
|
||||
if result in category_names:
|
||||
category = result
|
||||
|
||||
@@ -91,7 +91,7 @@ async def fetch_remote_file_meta(
|
||||
"""
|
||||
import httpx
|
||||
|
||||
name = size = mime_type = extension = None
|
||||
name = extension = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.head(url, follow_redirects=True)
|
||||
|
||||
@@ -4,6 +4,10 @@ from typing import Optional, Any, List, Dict, Union
|
||||
from enum import Enum, StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_serializer, field_validator
|
||||
|
||||
from app.schemas.workflow_schema import WorkflowConfigCreate
|
||||
|
||||
|
||||
# ---------- Multimodal File Support ----------
|
||||
|
||||
class FileType(StrEnum):
|
||||
@@ -313,7 +317,7 @@ class AppCreate(BaseModel):
|
||||
# only for type=multi_agent
|
||||
multi_agent_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
workflow_config: Optional[Dict[str, Any]] = None
|
||||
workflow_config: Optional[WorkflowConfigCreate] = None
|
||||
|
||||
|
||||
class AppUpdate(BaseModel):
|
||||
|
||||
@@ -32,7 +32,7 @@ class ChatRequest(BaseModel):
|
||||
web_search: bool = Field(default=False, description="是否启用网络搜索")
|
||||
memory: bool = Field(default=True, description="是否启用记忆功能")
|
||||
thinking: bool = Field(default=False, description="是否启用深度思考(需Agent配置支持)")
|
||||
files: Optional[List[FileInput]] = Field(default=None, description="附件列表(支持多文件)")
|
||||
files: List[FileInput] = Field(default_factory=list, description="附件列表(支持多文件)")
|
||||
|
||||
|
||||
# ---------- Output Schemas ----------
|
||||
|
||||
@@ -231,8 +231,13 @@ class AppChatService:
|
||||
if memory_flag:
|
||||
connected_config = get_end_user_connected_config(user_id, self.db)
|
||||
memory_config_id: str = connected_config.get("memory_config_id")
|
||||
file_list = []
|
||||
for file in files:
|
||||
file_dict = file.model_dump()
|
||||
file_dict["upload_file_id"] = str(file_dict["upload_file_id"]) if file_dict["upload_file_id"] else None
|
||||
file_list.append(file_dict)
|
||||
messages = [
|
||||
{"role": "user", "content": message, "files": [file.model_dump() for file in files]},
|
||||
{"role": "user", "content": message, "files": file_list},
|
||||
{"role": "assistant", "content": result["content"]}
|
||||
]
|
||||
if memory_config_id:
|
||||
@@ -506,8 +511,13 @@ class AppChatService:
|
||||
if memory_flag:
|
||||
connected_config = get_end_user_connected_config(user_id, self.db)
|
||||
memory_config_id: str = connected_config.get("memory_config_id")
|
||||
file_list = []
|
||||
for file in files:
|
||||
file_dict = file.model_dump()
|
||||
file_dict["upload_file_id"] = str(file_dict["upload_file_id"]) if file_dict["upload_file_id"] else None
|
||||
file_list.append(file_dict)
|
||||
messages = [
|
||||
{"role": "user", "content": message, "files": [file.model_dump() for file in files]},
|
||||
{"role": "user", "content": message, "files": file_list},
|
||||
{"role": "assistant", "content": full_content}
|
||||
]
|
||||
if memory_config_id:
|
||||
|
||||
@@ -1360,6 +1360,7 @@ class AppService:
|
||||
variables=cfg.get("variables", []),
|
||||
execution_config=cfg.get("execution_config", {}),
|
||||
triggers=cfg.get("triggers", []),
|
||||
features=cfg.get("features", {}),
|
||||
is_active=True,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
|
||||
@@ -513,34 +513,40 @@ def get_dashboard_yesterday_changes(
|
||||
today_data: dict
|
||||
) -> dict:
|
||||
"""
|
||||
计算各指标相比昨天的变化量。
|
||||
|
||||
计算各指标相比昨天的变化百分比。
|
||||
|
||||
- total_app_change / total_knowledge_change:只看活跃记录,
|
||||
百分比 = (截止今日活跃总量 - 截止昨日活跃总量) / 截止昨日活跃总量
|
||||
- total_memory_change / total_api_call_change:
|
||||
百分比 = (今日总量 - 昨日总量) / 昨日总量
|
||||
|
||||
昨日总量为 0 时返回 None。返回值为浮点数,例如 0.5 表示增长 50%。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
workspace_id: 工作空间ID
|
||||
storage_type: 存储类型 'neo4j' | 'rag'
|
||||
today_data: 当前数据,包含 total_memory, total_app, total_knowledge, total_api_call
|
||||
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total_memory_change": int | None,
|
||||
"total_app_change": int | None,
|
||||
"total_knowledge_change": int | None,
|
||||
"total_api_call_change": int | None
|
||||
"total_memory_change": float | None,
|
||||
"total_app_change": float | None,
|
||||
"total_knowledge_change": float | None,
|
||||
"total_api_call_change": float | None
|
||||
}
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
from app.models.api_key_model import ApiKey, ApiKeyLog
|
||||
from app.models.knowledge_model import Knowledge
|
||||
from app.models.app_model import App
|
||||
from app.models.appshare_model import AppShare
|
||||
|
||||
business_logger.info(f"计算昨日对比: workspace_id={workspace_id}, storage_type={storage_type}")
|
||||
business_logger.info(f"计算昨日对比百分比: workspace_id={workspace_id}, storage_type={storage_type}")
|
||||
|
||||
now_local = datetime.now()
|
||||
today_start = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
yesterday_start = today_start - timedelta(days=1)
|
||||
|
||||
changes = {
|
||||
"total_memory_change": None,
|
||||
@@ -549,134 +555,102 @@ def get_dashboard_yesterday_changes(
|
||||
"total_api_call_change": None,
|
||||
}
|
||||
|
||||
# --- total_api_call_change ---
|
||||
def _calc_percentage(today_val, yesterday_val):
|
||||
"""计算百分比,昨日为0时返回None"""
|
||||
if yesterday_val is None or yesterday_val == 0:
|
||||
return None
|
||||
return round((today_val - yesterday_val) / yesterday_val, 4)
|
||||
|
||||
# --- total_api_call_change: (截止今日累计总数 - 截止昨日累计总数) / 截止昨日累计总数 ---
|
||||
try:
|
||||
# 获取该workspace下所有api_key的id
|
||||
api_key_ids = [
|
||||
row[0] for row in db.query(ApiKey.id).filter(
|
||||
ApiKey.workspace_id == workspace_id
|
||||
).all()
|
||||
]
|
||||
if api_key_ids:
|
||||
# 今日累计
|
||||
today_api_count = db.query(func.count(ApiKeyLog.id)).filter(
|
||||
# 截止今日的累计调用总数
|
||||
total_api_until_now = db.query(func.count(ApiKeyLog.id)).filter(
|
||||
ApiKeyLog.api_key_id.in_(api_key_ids),
|
||||
ApiKeyLog.created_at >= today_start,
|
||||
ApiKeyLog.created_at < now_local
|
||||
).scalar() or 0
|
||||
# 昨日全天
|
||||
yesterday_api_count = db.query(func.count(ApiKeyLog.id)).filter(
|
||||
# 截止昨日的累计调用总数(today_start 即昨日结束)
|
||||
total_api_until_yesterday = db.query(func.count(ApiKeyLog.id)).filter(
|
||||
ApiKeyLog.api_key_id.in_(api_key_ids),
|
||||
ApiKeyLog.created_at >= yesterday_start,
|
||||
ApiKeyLog.created_at < today_start
|
||||
).scalar() or 0
|
||||
changes["total_api_call_change"] = today_api_count - yesterday_api_count
|
||||
changes["total_api_call_change"] = _calc_percentage(total_api_until_now, total_api_until_yesterday)
|
||||
else:
|
||||
# 没有api_key,如果今日也是0则无对比意义
|
||||
changes["total_api_call_change"] = None
|
||||
except Exception as e:
|
||||
business_logger.warning(f"计算API调用昨日对比失败: {str(e)}")
|
||||
|
||||
# --- total_knowledge_change ---
|
||||
# --- total_knowledge_change: 只看活跃(status=1)且为顶层知识库(parent_id=workspace_id),百分比 = (今日活跃总量 - 昨日活跃总量) / 昨日活跃总量 ---
|
||||
try:
|
||||
# 今天有效总量:当前status=1的知识库总数,排除用户知识库(permission_id='Memory')
|
||||
# 截止今日的活跃知识库总量(当前 status=1,parent_id=workspace_id)
|
||||
today_knowledge = db.query(func.count(Knowledge.id)).filter(
|
||||
Knowledge.workspace_id == workspace_id,
|
||||
Knowledge.status == 1,
|
||||
Knowledge.permission_id != "Memory"
|
||||
Knowledge.parent_id == Knowledge.workspace_id
|
||||
).scalar() or 0
|
||||
# 昨日有效总量:昨天之前创建的、当前仍有效的知识库,排除用户知识库
|
||||
# 截止昨日的活跃知识库总量(昨日之前创建的、当前仍 status=1,parent_id=workspace_id)
|
||||
yesterday_knowledge = db.query(func.count(Knowledge.id)).filter(
|
||||
Knowledge.workspace_id == workspace_id,
|
||||
Knowledge.status == 1,
|
||||
Knowledge.permission_id != "Memory",
|
||||
Knowledge.parent_id == Knowledge.workspace_id,
|
||||
Knowledge.created_at < today_start
|
||||
).scalar() or 0
|
||||
# 今日软删:今天被软删的知识库(status=2 且 updated_at >= today_start),排除用户知识库
|
||||
today_deleted_knowledge = db.query(func.count(Knowledge.id)).filter(
|
||||
Knowledge.workspace_id == workspace_id,
|
||||
Knowledge.status == 2,
|
||||
Knowledge.permission_id != "Memory",
|
||||
Knowledge.updated_at >= today_start
|
||||
).scalar() or 0
|
||||
|
||||
if yesterday_knowledge == 0 and today_knowledge == 0 and today_deleted_knowledge == 0:
|
||||
changes["total_knowledge_change"] = None
|
||||
else:
|
||||
# change = 今天有效总量 - 今日软删 - 昨日有效总量
|
||||
changes["total_knowledge_change"] = today_knowledge - today_deleted_knowledge - yesterday_knowledge
|
||||
changes["total_knowledge_change"] = _calc_percentage(today_knowledge, yesterday_knowledge)
|
||||
except Exception as e:
|
||||
business_logger.warning(f"计算知识库昨日对比失败: {str(e)}")
|
||||
|
||||
# --- total_app_change ---
|
||||
# --- total_app_change: 只看活跃(is_active=True),百分比 = (今日活跃总量 - 昨日活跃总量) / 昨日活跃总量 ---
|
||||
try:
|
||||
# === 自有app ===
|
||||
# 今天有效总量
|
||||
today_own_apps = db.query(func.count(App.id)).filter(
|
||||
App.workspace_id == workspace_id,
|
||||
App.is_active == True
|
||||
).scalar() or 0
|
||||
# 昨日有效总量
|
||||
yesterday_own_apps = db.query(func.count(App.id)).filter(
|
||||
App.workspace_id == workspace_id,
|
||||
App.is_active == True,
|
||||
App.created_at < today_start
|
||||
).scalar() or 0
|
||||
# 今日软删
|
||||
today_deleted_own_apps = db.query(func.count(App.id)).filter(
|
||||
App.workspace_id == workspace_id,
|
||||
App.is_active == False,
|
||||
App.updated_at >= today_start
|
||||
).scalar() or 0
|
||||
|
||||
# === 被分享app ===
|
||||
# 今天有效总量
|
||||
today_shared_apps = db.query(func.count(AppShare.id)).filter(
|
||||
AppShare.target_workspace_id == workspace_id,
|
||||
AppShare.is_active == True
|
||||
).scalar() or 0
|
||||
# 昨日有效总量
|
||||
yesterday_shared_apps = db.query(func.count(AppShare.id)).filter(
|
||||
AppShare.target_workspace_id == workspace_id,
|
||||
AppShare.is_active == True,
|
||||
AppShare.created_at < today_start
|
||||
).scalar() or 0
|
||||
# 今日软删
|
||||
today_deleted_shared_apps = db.query(func.count(AppShare.id)).filter(
|
||||
AppShare.target_workspace_id == workspace_id,
|
||||
AppShare.is_active == False,
|
||||
AppShare.updated_at >= today_start
|
||||
).scalar() or 0
|
||||
|
||||
today_total_app = today_own_apps + today_shared_apps
|
||||
yesterday_total_app = yesterday_own_apps + yesterday_shared_apps
|
||||
total_deleted = today_deleted_own_apps + today_deleted_shared_apps
|
||||
|
||||
if yesterday_total_app == 0 and today_total_app == 0 and total_deleted == 0:
|
||||
changes["total_app_change"] = None
|
||||
else:
|
||||
# change = 今天有效总量 - 今日软删 - 昨日有效总量
|
||||
changes["total_app_change"] = today_total_app - total_deleted - yesterday_total_app
|
||||
changes["total_app_change"] = _calc_percentage(today_total_app, yesterday_total_app)
|
||||
except Exception as e:
|
||||
business_logger.warning(f"计算应用数量昨日对比失败: {str(e)}")
|
||||
|
||||
# --- total_memory_change ---
|
||||
# --- total_memory_change: (今日总量 - 昨日总量) / 昨日总量 ---
|
||||
try:
|
||||
today_memory = today_data.get("total_memory")
|
||||
if today_memory is None:
|
||||
changes["total_memory_change"] = None
|
||||
elif storage_type == "neo4j":
|
||||
# 从 memory_increments 取最近一条 created_at < today_start 的记录
|
||||
last_record = db.query(MemoryIncrement).filter(
|
||||
MemoryIncrement.workspace_id == workspace_id,
|
||||
MemoryIncrement.created_at < today_start
|
||||
).order_by(desc(MemoryIncrement.created_at)).first()
|
||||
if last_record is None:
|
||||
if last_record is None or last_record.total_num == 0:
|
||||
changes["total_memory_change"] = None
|
||||
else:
|
||||
changes["total_memory_change"] = today_memory - last_record.total_num
|
||||
changes["total_memory_change"] = _calc_percentage(today_memory, last_record.total_num)
|
||||
elif storage_type == "rag":
|
||||
# RAG: 查 documents 表中 created_at < today_start 的 chunk_num 之和
|
||||
from app.models.document_model import Document
|
||||
from app.models.end_user_model import EndUser as _EndUser
|
||||
from app.models.app_model import App as _App
|
||||
@@ -691,18 +665,18 @@ def get_dashboard_yesterday_changes(
|
||||
changes["total_memory_change"] = None
|
||||
else:
|
||||
file_names = [f"{uid}.txt" for uid in end_user_ids]
|
||||
yesterday_chunk = db.query(func.sum(Document.chunk_num)).filter(
|
||||
yesterday_chunk = int(db.query(func.sum(Document.chunk_num)).filter(
|
||||
Document.file_name.in_(file_names),
|
||||
Document.created_at < today_start
|
||||
).scalar()
|
||||
if yesterday_chunk is None:
|
||||
).scalar() or 0)
|
||||
if yesterday_chunk == 0:
|
||||
changes["total_memory_change"] = None
|
||||
else:
|
||||
changes["total_memory_change"] = today_memory - int(yesterday_chunk)
|
||||
changes["total_memory_change"] = _calc_percentage(today_memory, yesterday_chunk)
|
||||
except Exception as e:
|
||||
business_logger.warning(f"计算记忆总量昨日对比失败: {str(e)}")
|
||||
|
||||
business_logger.info(f"昨日对比计算完成: {changes}")
|
||||
business_logger.info(f"昨日对比百分比计算完成: {changes}")
|
||||
return changes
|
||||
|
||||
|
||||
@@ -1104,15 +1078,11 @@ def get_dashboard_common_stats(db: Session, workspace_id) -> dict:
|
||||
except Exception as e:
|
||||
business_logger.warning(f"获取知识库数量失败: {e}")
|
||||
|
||||
# total_api_call: 仅统计当天 api_key_log 调用次数
|
||||
# total_api_call: 截止当前的历史累计调用总数
|
||||
try:
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func as _api_func
|
||||
from app.models.api_key_model import ApiKey as _ApiKey, ApiKeyLog as _ApiKeyLog
|
||||
|
||||
_now = datetime.now()
|
||||
_today_start = _now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
_api_key_ids = [
|
||||
row[0] for row in db.query(_ApiKey.id).filter(
|
||||
_ApiKey.workspace_id == workspace_id
|
||||
@@ -1120,9 +1090,7 @@ def get_dashboard_common_stats(db: Session, workspace_id) -> dict:
|
||||
]
|
||||
if _api_key_ids:
|
||||
total_api_calls = db.query(_api_func.count(_ApiKeyLog.id)).filter(
|
||||
_ApiKeyLog.api_key_id.in_(_api_key_ids),
|
||||
_ApiKeyLog.created_at >= _today_start,
|
||||
_ApiKeyLog.created_at < _now
|
||||
_ApiKeyLog.api_key_id.in_(_api_key_ids)
|
||||
).scalar() or 0
|
||||
else:
|
||||
total_api_calls = 0
|
||||
|
||||
@@ -25,7 +25,7 @@ from app.repositories.workflow_repository import (
|
||||
WorkflowExecutionRepository,
|
||||
WorkflowNodeExecutionRepository
|
||||
)
|
||||
from app.schemas import DraftRunRequest, FileInput
|
||||
from app.schemas import DraftRunRequest, FileInput, FileType
|
||||
from app.services.conversation_service import ConversationService
|
||||
from app.services.multi_agent_service import convert_uuids_to_str
|
||||
from app.services.multimodal_service import MultimodalService
|
||||
@@ -466,7 +466,7 @@ class WorkflowService:
|
||||
if not isinstance(item, dict) or item.get("is_file"):
|
||||
return item
|
||||
transfer_method = item.get("transfer_method", "remote_url")
|
||||
file_type = item.get("type", "document")
|
||||
file_type = FileType.trans(item.get("type", "document"))
|
||||
origin_file_type = item.get("file_type") or file_type
|
||||
if transfer_method == "remote_url":
|
||||
url = item.get("url", "")
|
||||
|
||||
Reference in New Issue
Block a user