Luxx/luxx/models/chat.py

168 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Chat-related models"""
import json
from datetime import datetime
from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import String, Integer, Boolean, Float, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from luxx.core.database import Base
if TYPE_CHECKING:
from luxx.models.user import LLMProvider, User
def local_now():
return datetime.now()
class Conversation(Base):
"""Conversation model"""
__tablename__ = "conversations"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
provider_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("llm_providers.id"), nullable=True)
project_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
model: Mapped[str] = mapped_column(String(64), nullable=False, default="deepseek-chat")
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="You are helpful.")
temperature: Mapped[float] = mapped_column(Float, default=0.7)
max_tokens: Mapped[int] = mapped_column(Integer, default=2000)
thinking_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
user: Mapped["User"] = relationship("User", back_populates="conversations")
provider: Mapped[Optional["LLMProvider"]] = relationship("LLMProvider")
messages: Mapped[List["Message"]] = relationship(
"Message", back_populates="conversation", cascade="all, delete-orphan"
)
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"provider_id": self.provider_id,
"project_id": self.project_id,
"title": self.title,
"model": self.model,
"system_prompt": self.system_prompt,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"thinking_enabled": self.thinking_enabled,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
class Message(Base):
"""Unified Message model for Conversation and ChatRoom.
统一消息模型,支持:
- Conversation: 单人会话
- ChatRoom: 聊天室(多 Agent
sender_type: user | agent | system
content: JSON 格式 {"text": "...", "steps": [...]}
"""
__tablename__ = "messages"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
conversation_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=True)
room_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
# 发送者信息
sender_id: Mapped[str] = mapped_column(String(64), nullable=False, default="") # 用户ID 或 AgentID
sender_type: Mapped[str] = mapped_column(String(16), nullable=False, default="user") # "user" | "agent" | "system"
sender_name: Mapped[str] = mapped_column(String(50), nullable=False, default="")
# 消息内容(兼容旧格式,同时保留 role 字段用于兼容 Conversation
role: Mapped[str] = mapped_column(String(16), nullable=False, default="user") # 保留,兼容 Conversation
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 流式响应元数据
is_streaming: Mapped[bool] = mapped_column(Boolean, default=False)
stream_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
parent_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) # 回复的消息ID
# 元数据
mentions: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
token_count: Mapped[int] = mapped_column(Integer, default=0)
usage: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
conversation: Mapped[Optional["Conversation"]] = relationship("Conversation", back_populates="messages")
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
@property
def target_type(self) -> str:
return "conversation" if self.conversation_id else "room"
@property
def target_id(self) -> str:
return self.conversation_id or self.room_id or ""
def to_dict(self, include_stream_data: bool = False):
"""转换消息为字典
统一使用 sender 格式content 保留 JSON 和纯文本两种格式
"""
result = {
"id": self.id,
"conversation_id": self.conversation_id,
"room_id": self.room_id,
"target_type": self.target_type,
"target_id": self.target_id,
"sender": {
"id": self.sender_id,
"type": self.sender_type,
"name": self.sender_name
},
# 兼容字段
"sender_id": self.sender_id,
"sender_type": self.sender_type,
"sender_name": self.sender_name,
"role": self.role, # 保留,兼容 Conversation
"token_count": self.token_count,
"is_streaming": self.is_streaming,
"created_at": self.created_at.isoformat() if self.created_at else None
}
# 流式数据
if include_stream_data:
result["stream_id"] = self.stream_id
result["parent_id"] = self.parent_id
# Parse usage JSON
if self.usage:
try:
result["usage"] = json.loads(self.usage)
except json.JSONDecodeError:
result["usage"] = None
# Parse mentions JSON
if self.mentions:
try:
result["mentions"] = json.loads(self.mentions)
except json.JSONDecodeError:
result["mentions"] = []
else:
result["mentions"] = []
# Parse content JSON - 提取 text 和 steps
try:
content_obj = json.loads(self.content) if self.content else {}
result["content"] = content_obj.get("text", content_obj.get("content", self.content))
result["text"] = result["content"]
result["process_steps"] = content_obj.get("steps", content_obj.get("process_steps", []))
result["attachments"] = content_obj.get("attachments", [])
result["tool_calls"] = content_obj.get("tool_calls", [])
except json.JSONDecodeError:
# 纯文本内容
result["text"] = self.content
result["content"] = self.content
result["process_steps"] = []
result["attachments"] = []
result["tool_calls"] = []
return result