Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/apps/agent_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ async def agent_run_api(
)
except ForbiddenError as e:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)) from e
except AppException:
raise
except ValidationError as e:
raise HTTPException(
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
Expand Down
8 changes: 7 additions & 1 deletion backend/apps/conversation_management_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
OpinionRequest,
RenameRequest,
)
from consts.exceptions import ConversationNotFoundError, ValidationError
from consts.exceptions import (
AppException,
ConversationNotFoundError,
ValidationError,
)
from services.conversation_management_service import (
create_new_conversation,
delete_conversation_service,
Expand Down Expand Up @@ -51,6 +55,8 @@ async def create_new_conversation_endpoint(request: ConversationRequest, authori
user_id, tenant_id = get_current_user_id(authorization)
conversation_data = create_new_conversation(request.title, user_id)
return ConversationResponse(code=0, message="success", data=conversation_data)
except AppException:
raise
except Exception as e:
logging.error(f"Failed to create conversation: {str(e)}")
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
Expand Down
2 changes: 2 additions & 0 deletions backend/consts/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ class VectorDatabaseType(str, Enum):
MAX_GROUPS_PER_TENANT = 1_000
MAX_SUPER_ADMIN_COUNT = 1
MAX_ADMINS_PER_TENANT = 1_000
MAX_CONVERSATION_TURNS = 100
MAX_CONVERSATIONS_PER_USER = 1_000

# Invitation code type for asset administrator registration
ASSET_OWNER_INVITE_CODE_TYPE = "ASSET_OWNER_INVITE"
Expand Down
2 changes: 2 additions & 0 deletions backend/consts/error_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ class ErrorCode(Enum):
ErrorCode.COMMON_MISSING_REQUIRED_FIELD: 400,
# Common - Rate Limit
ErrorCode.COMMON_RATE_LIMIT_EXCEEDED: 429,
# Tenant resources
ErrorCode.TENANT_RESOURCE_EXCEEDED: 429,
# Common - Resource
ErrorCode.COMMON_RESOURCE_NOT_FOUND: 404,
ErrorCode.COMMON_RESOURCE_ALREADY_EXISTS: 409,
Expand Down
83 changes: 81 additions & 2 deletions backend/database/conversation_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, TypedDict

from sqlalchemy import asc, desc, func, insert, select, update
from sqlalchemy import asc, desc, func, insert, select, text, update

from consts.const import (
MAX_CONVERSATION_TURNS,
MAX_CONVERSATIONS_PER_USER,
MESSAGE_ROLE,
)
from consts.error_code import ErrorCode
from consts.exceptions import AppException
from .client import as_dict, db_client, get_db_session
from .db_models import (
ConversationMessage,
Expand Down Expand Up @@ -102,6 +109,69 @@ def _get_effective_tenant_id(user_tenant: Dict[str, Any]) -> str:
return DEFAULT_TENANT_ID


def count_user_turns(conversation_id: int) -> int:
"""Count active user messages, treating each one as one conversation turn."""
with get_db_session() as session:
statement = select(func.count(ConversationMessage.message_id)).where(
ConversationMessage.conversation_id == int(conversation_id),
ConversationMessage.message_role == MESSAGE_ROLE["USER"],
ConversationMessage.delete_flag == "N",
)
return int(session.execute(statement).scalar_one())


def _lock_limit_scope(session, scope: str) -> None:
"""Serialize quota checks for one user or conversation in PostgreSQL."""
session.execute(
text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"),
{"lock_key": scope},
)


def _enforce_user_conversation_limit(session, user_id: Optional[str]) -> None:
if not user_id:
return

_lock_limit_scope(session, f"conversation-user-limit:{user_id}")
statement = select(func.count(ConversationRecord.conversation_id)).where(
ConversationRecord.created_by == user_id,
ConversationRecord.delete_flag == "N",
)
conversation_count = int(session.execute(statement).scalar_one())
if conversation_count >= MAX_CONVERSATIONS_PER_USER:
raise AppException(
ErrorCode.TENANT_RESOURCE_EXCEEDED,
"Conversation history limit reached: "
f"maximum {MAX_CONVERSATIONS_PER_USER} conversations per user",
details={"resource": "conversations", "limit": MAX_CONVERSATIONS_PER_USER},
)


def _enforce_conversation_turn_limit(
session,
conversation_id: int,
message_role: str,
user_id: Optional[str],
) -> None:
if not user_id or message_role != MESSAGE_ROLE["USER"]:
return

_lock_limit_scope(session, f"conversation-turn-limit:{conversation_id}")
statement = select(func.count(ConversationMessage.message_id)).where(
ConversationMessage.conversation_id == conversation_id,
ConversationMessage.message_role == MESSAGE_ROLE["USER"],
ConversationMessage.delete_flag == "N",
)
turn_count = int(session.execute(statement).scalar_one())
if turn_count >= MAX_CONVERSATION_TURNS:
raise AppException(
ErrorCode.TENANT_RESOURCE_EXCEEDED,
"Conversation turn limit reached: "
f"maximum {MAX_CONVERSATION_TURNS} turns per conversation",
details={"resource": "conversation_turns", "limit": MAX_CONVERSATION_TURNS},
)


def create_conversation(conversation_title: str, user_id: Optional[str] = None,
agent_id: Optional[int] = None,
chat_mode: Optional[str] = None,
Expand All @@ -120,6 +190,8 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None,
Dict[str, Any]: Dictionary containing complete information of the newly created conversation
"""
with get_db_session() as session:
_enforce_user_conversation_limit(session, user_id)

# Prepare data dictionary
data = {"conversation_title": conversation_title, "delete_flag": 'N'}
if agent_id is not None:
Expand Down Expand Up @@ -180,6 +252,13 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[
# Ensure conversation_id is integer type
conversation_id = int(message_data['conversation_id'])
message_idx = int(message_data['message_idx'])
message_role = message_data['role']
_enforce_conversation_turn_limit(
session=session,
conversation_id=conversation_id,
message_role=message_role,
user_id=user_id,
)

minio_files = message_data.get('minio_files')
# Convert minio_files to JSON string for storage
Expand All @@ -189,7 +268,7 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[
minio_files = json.dumps(minio_files)

# Prepare data dictionary
data = {"conversation_id": conversation_id, "message_index": message_idx, "message_role": message_data['role'],
data = {"conversation_id": conversation_id, "message_index": message_idx, "message_role": message_role,
"message_content": message_data['content'], "minio_files": minio_files, "opinion_flag": None,
"delete_flag": 'N', "status": status}
if user_id:
Expand Down
13 changes: 13 additions & 0 deletions backend/services/agent_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
from utils.str_utils import convert_list_to_string, convert_string_to_list
from services.conversation_management_service import (
create_new_conversation,
ensure_conversation_turn_capacity,
generate_conversation_title_service, # noqa: F401 - compatibility patch point
get_conversation_service,
get_current_run_user_message_id,
Expand Down Expand Up @@ -3320,6 +3321,18 @@ async def run_agent_stream(
resolved_user_id,
)

if (
not agent_request.is_debug
and not resume
and not skip_user_save
and not is_new_conversation
and agent_request.conversation_id is not None
):
ensure_conversation_turn_capacity(
conversation_id=agent_request.conversation_id,
user_id=resolved_user_id,
)

if (
not agent_request.is_debug
and not is_new_conversation
Expand Down
30 changes: 28 additions & 2 deletions backend/services/conversation_management_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@

from jinja2 import StrictUndefined, Template

from consts.const import LANGUAGE, MODEL_CONFIG_MAPPING, MESSAGE_ROLE, DEFAULT_EN_TITLE, DEFAULT_ZH_TITLE
from consts.const import (
DEFAULT_EN_TITLE,
DEFAULT_ZH_TITLE,
LANGUAGE,
MAX_CONVERSATION_TURNS,
MESSAGE_ROLE,
MODEL_CONFIG_MAPPING,
)
from consts.error_code import ErrorCode
from consts.model import AgentRequest, MessageRequest, MessageUnit
from consts.exceptions import ConversationNotFoundError, ValidationError
from consts.exceptions import AppException, ConversationNotFoundError, ValidationError
from database.conversation_db import (
CHAT_MODE_VALUES,
count_user_turns,
create_conversation,
create_conversation_message,
create_message_unit,
Expand Down Expand Up @@ -106,6 +115,21 @@ def save_message(request: MessageRequest, user_id: str, tenant_id: str,
return create_conversation_message(message_data_copy, user_id, status=status)


def ensure_conversation_turn_capacity(conversation_id: int, user_id: str) -> None:
"""Reject a new turn before streaming starts when the conversation is full."""
if not user_id:
return

turn_count = count_user_turns(conversation_id)
if turn_count >= MAX_CONVERSATION_TURNS:
raise AppException(
ErrorCode.TENANT_RESOURCE_EXCEEDED,
"Conversation turn limit reached: "
f"maximum {MAX_CONVERSATION_TURNS} turns per conversation",
details={"resource": "conversation_turns", "limit": MAX_CONVERSATION_TURNS},
)


def save_message_unit(message_id: int, conversation_id: int, unit_index: int,
unit_type: str, unit_content: Any,
user_id: Optional[str] = None,
Expand Down Expand Up @@ -378,6 +402,8 @@ def create_new_conversation(
create_kwargs["knowledge_scope"] = knowledge_scope
conversation_data = create_conversation(title, user_id, **create_kwargs)
return conversation_data
except AppException:
raise
except Exception as e:
logging.error(f"Failed to create conversation: {str(e)}")
raise Exception(str(e))
Expand Down
5 changes: 4 additions & 1 deletion frontend/app/[locale]/chat/internal/chatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";

import { ROLE_ASSISTANT } from "@/const/agentConfig";
import { MESSAGE_ROLES } from "@/const/chatConfig";
import { getConversationResourceLimitMessage } from "@/const/errorMessageI18n";
import { useConfig } from "@/hooks/useConfig";
import { useModelList } from "@/hooks/model/useModelList";
import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider";
Expand Down Expand Up @@ -1034,7 +1035,9 @@ export function ChatInterface() {
});
} else {
log.error(t("chatInterface.errorLabel"), error);
const errorMessage = t("chatInterface.errorProcessingRequest");
const errorMessage =
getConversationResourceLimitMessage(error, t) ||
t("chatInterface.errorProcessingRequest");
setSessionMessages((prev) => {
const newMessages = { ...prev };
const lastMsg =
Expand Down
14 changes: 13 additions & 1 deletion frontend/app/[locale]/newchat/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { message } from "antd";
import type { Agent, PublishedAgent } from "@/types/agentConfig";
import { getAgentIcon } from "@/lib/chat/agentIconUtils";
import type { ModelOption } from "../ui/model-selector";
import { getConversationResourceLimitMessage } from "@/const/errorMessageI18n";
import AutomationProposalMessage from "@/features/agentAutomation/components/AutomationProposalMessage";
import type { AgentAutomationProposalData } from "@/types/agentAutomation";
import {
Expand Down Expand Up @@ -964,10 +965,21 @@ const ThreadScrollToBottom: FC = () => {
};

const MessageError: FC = () => {
const { t } = useTranslation("common");
const rawError = useAuiState((state) => {
const status = state.message.status;
return status?.type === "incomplete" && status.reason === "error"
? status.error
: undefined;
});
const localizedMessage = getConversationResourceLimitMessage(rawError, t);

return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className="aui-message-error-root border-destructive bg-destructive/10 text-destructive dark:bg-destructive/5 mt-2 rounded-md border p-3 text-sm dark:text-red-200">
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2">
{localizedMessage ?? undefined}
</ErrorPrimitive.Message>
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
Expand Down
Loading
Loading