-
- 💬
- 系统提示词
-
-
-
-
- 设置默认系统提示词,可在新建会话时覆盖
-
-
-
-
+
@@ -132,7 +152,7 @@
{{ error }}
-
+
@@ -174,15 +194,15 @@
+
+
+
+
+
-
-
-
@@ -342,12 +362,13 @@ const loadingUsers = ref(false)
const usersError = ref('')
const modelSettings = ref({
- default_provider: null,
+ default_provider_id: null,
temperature: 0.7,
max_tokens: 8192,
thinking_enabled: false,
system_prompt: 'You are a helpful assistant.'
})
+const settingsLoaded = ref(false)
const fetchUserInfo = async () => {
loadingUser.value = true
@@ -391,13 +412,18 @@ const updateUser = async () => {
}
const saveModelSettings = async () => {
- localStorage.setItem('modelSettings', JSON.stringify(modelSettings.value))
- alert('模型设置已保存')
-}
-
-const saveSystemPrompt = async () => {
- localStorage.setItem('defaultSystemPrompt', modelSettings.value.system_prompt)
- alert('系统提示词已保存')
+ try {
+ await authAPI.updateSettings({
+ default_provider_id: modelSettings.value.default_provider_id,
+ temperature: modelSettings.value.temperature,
+ max_tokens: modelSettings.value.max_tokens,
+ thinking_enabled: modelSettings.value.thinking_enabled,
+ system_prompt: modelSettings.value.system_prompt
+ })
+ alert('设置已保存')
+ } catch (e) {
+ alert('保存失败: ' + e.message)
+ }
}
const providers = ref([])
@@ -421,16 +447,31 @@ const fetchProviders = async () => {
const res = await providersAPI.list()
if (res.success) {
providers.value = res.data.providers || []
- const defaultProvider = providers.value.find(p => p.is_default)
- if (defaultProvider && !modelSettings.value.default_provider) {
- modelSettings.value.default_provider = defaultProvider.id
- }
}
else throw new Error(res.message)
} catch (e) { error.value = e.message }
finally { loading.value = false }
}
+const fetchSettings = async () => {
+ try {
+ const res = await authAPI.getSettings()
+ if (res.success && res.data) {
+ modelSettings.value = {
+ default_provider_id: res.data.default_provider_id,
+ temperature: res.data.temperature,
+ max_tokens: res.data.max_tokens,
+ thinking_enabled: res.data.thinking_enabled,
+ system_prompt: res.data.system_prompt
+ }
+ settingsLoaded.value = true
+ }
+ } catch (e) {
+ // 首次使用可能没有设置,用默认值
+ console.warn('获取设置失败,使用默认值:', e)
+ }
+}
+
const closeModal = () => {
showModal.value = false
editing.value = null
@@ -526,18 +567,10 @@ const toggleEnabled = async (p) => {
const saveDefaultProvider = async () => {
try {
- // 取消所有 Provider 的默认状态
- for (const p of providers.value) {
- if (p.is_default && p.id !== modelSettings.value.default_provider) {
- await providersAPI.update(p.id, { is_default: false })
- }
- }
- // 设置选中的 Provider 为默认
- if (modelSettings.value.default_provider) {
- await providersAPI.update(modelSettings.value.default_provider, { is_default: true })
- }
- await fetchProviders()
- } catch (e) { alert('设置默认 Provider 失败: ' + e.message) }
+ await authAPI.updateSettings({ default_provider_id: modelSettings.value.default_provider_id })
+ } catch (e) {
+ alert('设置默认 Provider 失败: ' + e.message)
+ }
}
const fetchUsers = async () => {
@@ -547,14 +580,14 @@ const fetchUsers = async () => {
const res = await authAPI.listUsers()
if (res.success) {
users.value = res.data.users || []
- } else if (res.message === 'Admin permission required') {
+ } else if (res.detail === 'Admin permission required' || res.message === 'Admin permission required') {
isAdmin.value = false
} else {
- usersError.value = res.message || '获取用户列表失败'
+ usersError.value = res.message || res.detail || '获取用户列表失败'
}
} catch (e) {
+ // 403 错误静默处理,设置为非管理员
isAdmin.value = false
- } finally {
loadingUsers.value = false
}
}
@@ -577,9 +610,10 @@ const getPermissionName = (level) => {
onMounted(() => {
fetchUserInfo()
+ fetchSettings()
fetchProviders()
fetchUsers()
-
+
// 检查是否是管理员
const userData = localStorage.getItem('user')
if (userData) {
@@ -588,19 +622,6 @@ onMounted(() => {
isAdmin.value = user.permission_level === 4
} catch (e) {}
}
-
- const savedSettings = localStorage.getItem('modelSettings')
- if (savedSettings) {
- try {
- const parsed = JSON.parse(savedSettings)
- modelSettings.value = { ...modelSettings.value, ...parsed }
- } catch (e) {}
- }
-
- const savedPrompt = localStorage.getItem('defaultSystemPrompt')
- if (savedPrompt) {
- modelSettings.value.system_prompt = savedPrompt
- }
})
@@ -614,25 +635,9 @@ onMounted(() => {
.section-icon { font-size: 1rem; }
.section-text { font-size: 1rem; font-weight: 700; color: var(--text-primary); }
-/* 设置卡片 */
-.settings-card { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
-
-/* 设置行 */
-.settings-row { display: flex; align-items: center; padding: 0.85rem 1rem; border-bottom: 1px solid var(--border-light); }
-.settings-row:last-child { border-bottom: none; }
-.settings-row.full { flex-direction: column; align-items: stretch; }
-.settings-row.actions { justify-content: flex-end; gap: 0.5rem; background: var(--bg-secondary); }
-.row-label { min-width: 140px; color: var(--text-secondary); font-size: 0.85rem; flex-shrink: 0; }
-.row-title { display: block; font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
-.row-desc { display: block; font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.15rem; }
-.row-value { flex: 1; display: flex; align-items: center; justify-content: flex-end; }
-.row-value .switch { margin-left: auto; }
-
/* 内联输入框 */
.inline-select, .inline-input { padding: 0.5rem 0.75rem; border: 1px solid var(--border-input); border-radius: 6px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; }
.inline-input { width: 120px; }
-textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; resize: vertical; min-height: 80px; box-sizing: border-box; }
-.hint-block { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 0.5rem; }
/* 表格容器 */
.settings-table-container { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
@@ -647,6 +652,19 @@ textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border-input);
.info-col { width: 60%; min-width: 200px; }
.switch-col { text-align: center; width: 80px; }
.ops-col { width: 15%; min-width: 180px; text-align: center; }
+.setting-key-col { width: 25%; min-width: 160px; }
+
+/* 设置项标签 */
+.setting-label { font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
+.setting-desc { font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.15rem; }
+
+/* 表格内 textarea */
+.table-textarea { width: 100%; padding: 0.5rem; border: 1px solid var(--border-input); border-radius: 6px; background: var(--bg-input); color: var(--text-primary); font-size: 0.85rem; resize: vertical; min-height: 60px; box-sizing: border-box; }
+
+/* 表格底部 */
+.table-footer { text-align: right; padding: 0.75rem 1rem; background: var(--bg-secondary); border-top: 1px solid var(--border-light); }
+
+/* 空行提示 */
/* Provider 单元格 */
.provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); }
@@ -659,8 +677,6 @@ textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border-input);
.info-item { font-size: 0.8rem; color: var(--text-primary); word-break: break-all; }
.info-item.sub { font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.2rem; }
-/* 开关样式已移至全局 style.css */
-
/* 操作按钮 */
.ops-buttons { display: flex; flex-wrap: nowrap; gap: 0.5rem; }
.btn-op { padding: 0.4rem 0.75rem; background: var(--bg-secondary); border: 1px solid var(--border-light); border-radius: 6px; font-size: 0.8rem; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; white-space: nowrap; }
@@ -668,12 +684,6 @@ textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border-input);
.btn-op.btn-danger { color: var(--danger-color); border-color: var(--danger-bg); }
.btn-op.btn-danger:hover { background: var(--danger-bg); }
-/* 用户信息区域按钮 */
-.btn-action { padding: 0.45rem 0.9rem; background: var(--bg-secondary); border: 1px solid var(--border-light); border-radius: 6px; font-size: 0.8rem; color: var(--text-primary); cursor: pointer; transition: all 0.2s; }
-.btn-action:hover { background: var(--bg-hover); border-color: var(--accent-primary); }
-.btn-action.btn-logout { color: var(--danger-color); }
-.btn-action.btn-logout:hover { background: var(--danger-bg); border-color: var(--danger-color); }
-
/* 主要按钮 */
.btn-primary { padding: 0.5rem 1rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; font-size: 0.85rem; cursor: pointer; transition: all 0.2s; }
.btn-primary:hover { background: var(--accent-primary-hover); }
diff --git a/luxx/__init__.py b/luxx/__init__.py
index bd1e09d..3075c3a 100644
--- a/luxx/__init__.py
+++ b/luxx/__init__.py
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI):
"""Application lifespan manager"""
# Import all models to ensure they are registered with Base
- from luxx.models import User, Conversation, Message, Project, LLMProvider # noqa
+ from luxx.models import User, Conversation, Message, Project, LLMProvider, ChatRoom, RoomAgent # noqa
init_db()
# Create default test user if not exists
diff --git a/luxx/models.py b/luxx/models.py
index 374e571..4d92d35 100644
--- a/luxx/models.py
+++ b/luxx/models.py
@@ -18,21 +18,19 @@ class LLMProvider(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
- provider_type: Mapped[str] = mapped_column(String(50), nullable=False, default="openai") # openai, deepseek, glm, etc.
+ provider_type: Mapped[str] = mapped_column(String(50), nullable=False, default="openai")
base_url: Mapped[str] = mapped_column(String(500), nullable=False)
api_key: Mapped[str] = mapped_column(String(500), nullable=False)
default_model: Mapped[str] = mapped_column(String(100), nullable=False, default="gpt-4")
- max_tokens: Mapped[int] = mapped_column(Integer, default=8192) # 默认 8192
+ max_tokens: Mapped[int] = mapped_column(Integer, default=8192)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
- # Relationships
user: Mapped["User"] = relationship("User", backref="llm_providers")
def to_dict(self, include_key: bool = False):
- """Convert to dictionary, optionally include API key"""
result = {
"id": self.id,
"user_id": self.user_id,
@@ -51,6 +49,37 @@ class LLMProvider(Base):
return result
+class UserSettings(Base):
+ """Per-user settings model"""
+ __tablename__ = "user_settings"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
+ default_provider_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("llm_providers.id"), nullable=True)
+ temperature: Mapped[float] = mapped_column(Float, default=0.7)
+ max_tokens: Mapped[int] = mapped_column(Integer, default=8192)
+ thinking_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+ system_prompt: Mapped[str] = mapped_column(Text, default="You are a helpful assistant.")
+ 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", backref="settings")
+ default_provider: Mapped[Optional["LLMProvider"]] = relationship("LLMProvider")
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "user_id": self.user_id,
+ "default_provider_id": self.default_provider_id,
+ "temperature": self.temperature,
+ "max_tokens": self.max_tokens,
+ "thinking_enabled": self.thinking_enabled,
+ "system_prompt": self.system_prompt,
+ "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 Project(Base):
"""Project model"""
__tablename__ = "projects"
@@ -62,7 +91,6 @@ class Project(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
- # Relationships
user: Mapped["User"] = relationship("User", backref="projects")
@@ -75,12 +103,11 @@ class User(Base):
email: Mapped[Optional[str]] = mapped_column(String(120), unique=True, nullable=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(String(20), default="user")
- permission_level: Mapped[int] = mapped_column(Integer, default=1) # 1=READ_ONLY, 2=WRITE, 3=EXECUTE, 4=ADMIN
- workspace_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # 用户工作空间路径
+ permission_level: Mapped[int] = mapped_column(Integer, default=1)
+ workspace_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
- # Relationships
conversations: Mapped[List["Conversation"]] = relationship(
"Conversation", back_populates="user", cascade="all, delete-orphan"
)
@@ -115,11 +142,11 @@ class Conversation(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
- # Relationships
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"
+ "Message", back_populates="conversation", cascade="all, delete-orphan",
+ primaryjoin="Conversation.id == foreign(Message.conversation_id)"
)
def to_dict(self):
@@ -142,84 +169,184 @@ class Conversation(Base):
class Message(Base):
"""Message model
- content 字段统一使用 JSON 格式存储:
-
- **User 消息:**
- {
- "text": "用户输入的文本内容",
- "attachments": [
- {"name": "utils.py", "extension": "py", "content": "..."}
- ]
- }
-
- **Assistant 消息:**
- {
- "steps": [ // 有序步骤,用于渲染(主要数据源)
- {"id": "step-0", "index": 0, "type": "thinking", "content": "..."},
- {"id": "step-1", "index": 1, "type": "text", "content": "..."},
- {"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_xxx", "name": "...", "arguments": "..."},
- {"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_xxx", "name": "...", "content": "..."}
- ]
- }
-
- 注意:to_dict() 返回时会从 steps 动态计算 text 和 content 字段。
+ 同时服务于普通会话和聊天室:
+ - 普通会话:conversation_id 非空,room_id 为空
+ - 聊天室:room_id 非空,conversation_id 为空,sender_name/sender_color/round_number 有值
"""
__tablename__ = "messages"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
- conversation_id: Mapped[str] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=False)
- role: Mapped[str] = mapped_column(String(16), nullable=False) # user, assistant, system, tool
+ conversation_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=True)
+ room_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("chat_rooms.id"), nullable=True)
+ role: Mapped[str] = mapped_column(String(16), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
token_count: Mapped[int] = mapped_column(Integer, default=0)
- usage: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON string for usage info
+ usage: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
+ # 聊天室专属字段(普通会话为空)
+ sender_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
+ sender_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True, default="#2563eb")
+ round_number: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
- # Relationships
- conversation: Mapped["Conversation"] = relationship("Conversation", back_populates="messages")
+ conversation: Mapped[Optional["Conversation"]] = relationship("Conversation", back_populates="messages")
+ room: Mapped[Optional["ChatRoom"]] = relationship("ChatRoom", back_populates="messages")
def to_dict(self):
- """Convert to dictionary, extracting process_steps for frontend"""
import json
result = {
"id": self.id,
"conversation_id": self.conversation_id,
+ "room_id": self.room_id,
"role": self.role,
"token_count": self.token_count,
"created_at": self.created_at.isoformat() if self.created_at else None
}
- # Parse usage JSON
if self.usage:
try:
result["usage"] = json.loads(self.usage)
except json.JSONDecodeError:
result["usage"] = None
- # Parse content JSON
+ # 聊天室专属字段
+ if self.sender_name:
+ result["sender_name"] = self.sender_name
+ if self.sender_color:
+ result["sender_color"] = self.sender_color
+ if self.round_number is not None:
+ result["round_number"] = self.round_number
+
try:
content_obj = json.loads(self.content) if self.content else {}
except json.JSONDecodeError:
- # Legacy plain text content
result["content"] = self.content
result["text"] = self.content
result["attachments"] = []
result["process_steps"] = []
return result
- # Extract steps as process_steps for frontend rendering
steps = content_obj.get("steps", [])
result["process_steps"] = steps
- # Extract text from steps (concatenate all text type steps)
text_content = "".join(
s.get("content", "") for s in steps
if s.get("type") == "text"
)
result["text"] = text_content
- result["content"] = text_content # Alias for convenience
-
- # Extract attachments
+ result["content"] = text_content
result["attachments"] = content_obj.get("attachments", [])
return result
+
+
+# ============ Chat Room Models ============
+
+class Agent(Base):
+ """Standalone reusable Agent template"""
+ __tablename__ = "agents"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
+ name: Mapped[str] = mapped_column(String(100), nullable=False)
+ role: Mapped[str] = mapped_column(String(255), nullable=False, default="")
+ provider_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("llm_providers.id"), nullable=True)
+ model: Mapped[str] = mapped_column(String(100), nullable=False, default="")
+ system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="You are a helpful AI assistant.")
+ color: Mapped[str] = mapped_column(String(7), nullable=False, default="#2563eb")
+ 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", backref="agents")
+ provider: Mapped[Optional["LLMProvider"]] = relationship("LLMProvider")
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "user_id": self.user_id,
+ "name": self.name,
+ "role": self.role,
+ "provider_id": self.provider_id,
+ "model": self.model,
+ "system_prompt": self.system_prompt,
+ "color": self.color,
+ "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 ChatRoom(Base):
+ """Multi-agent chat room model"""
+ __tablename__ = "chat_rooms"
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
+ task: Mapped[str] = mapped_column(Text, nullable=False, default="")
+ status: Mapped[str] = mapped_column(String(20), nullable=False, default="idle") # idle, running, paused, completed, error
+ max_rounds: Mapped[int] = mapped_column(Integer, default=5)
+ current_round: Mapped[int] = mapped_column(Integer, default=0)
+ 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", backref="chat_rooms")
+ agents: Mapped[List["RoomAgent"]] = relationship(
+ "RoomAgent", back_populates="room", cascade="all, delete-orphan", order_by="RoomAgent.turn_order"
+ )
+ messages: Mapped[List["Message"]] = relationship(
+ "Message", back_populates="room", cascade="all, delete-orphan",
+ primaryjoin="ChatRoom.id == foreign(Message.room_id)",
+ order_by="Message.created_at"
+ )
+
+ def to_dict(self, include_messages: bool = False):
+ result = {
+ "id": self.id,
+ "user_id": self.user_id,
+ "title": self.title,
+ "task": self.task,
+ "status": self.status,
+ "max_rounds": self.max_rounds,
+ "current_round": self.current_round,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+ "agents": [a.to_dict() for a in self.agents]
+ }
+ if include_messages:
+ result["messages"] = [m.to_dict() for m in self.messages]
+ return result
+
+
+class RoomAgent(Base):
+ """Agent assignment in a chat room (links Agent to Room with room-specific config)"""
+ __tablename__ = "room_agents"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ room_id: Mapped[str] = mapped_column(String(64), ForeignKey("chat_rooms.id"), nullable=False)
+ agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("agents.id"), nullable=True)
+ name: Mapped[str] = mapped_column(String(100), nullable=False)
+ role: Mapped[str] = mapped_column(String(255), nullable=False, default="")
+ provider_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("llm_providers.id"), nullable=True)
+ model: Mapped[str] = mapped_column(String(100), nullable=False, default="")
+ system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="You are a helpful AI assistant.")
+ color: Mapped[str] = mapped_column(String(7), nullable=False, default="#2563eb")
+ turn_order: Mapped[int] = mapped_column(Integer, default=0)
+
+ room: Mapped["ChatRoom"] = relationship("ChatRoom", back_populates="agents")
+ provider: Mapped[Optional["LLMProvider"]] = relationship("LLMProvider")
+ agent: Mapped[Optional["Agent"]] = relationship("Agent")
+
+ def to_dict(self):
+ return {
+ "id": self.id,
+ "room_id": self.room_id,
+ "agent_id": self.agent_id,
+ "name": self.name,
+ "role": self.role,
+ "provider_id": self.provider_id,
+ "model": self.model,
+ "system_prompt": self.system_prompt,
+ "color": self.color,
+ "turn_order": self.turn_order
+ }
+
diff --git a/luxx/routes/__init__.py b/luxx/routes/__init__.py
index c6519c8..247603a 100644
--- a/luxx/routes/__init__.py
+++ b/luxx/routes/__init__.py
@@ -1,7 +1,7 @@
"""API routes module"""
from fastapi import APIRouter
-from luxx.routes import auth, conversations, messages, tools, providers
+from luxx.routes import auth, conversations, messages, tools, providers, chat_rooms, agents
api_router = APIRouter()
@@ -12,3 +12,5 @@ api_router.include_router(conversations.router)
api_router.include_router(messages.router)
api_router.include_router(tools.router)
api_router.include_router(providers.router)
+api_router.include_router(chat_rooms.router)
+api_router.include_router(agents.router)
diff --git a/luxx/routes/agents.py b/luxx/routes/agents.py
new file mode 100644
index 0000000..6f12dda
--- /dev/null
+++ b/luxx/routes/agents.py
@@ -0,0 +1,143 @@
+"""Standalone Agent CRUD routes"""
+from typing import Optional
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from luxx.database import get_db
+from luxx.models import Agent, LLMProvider, User
+from luxx.routes.auth import get_current_user
+from luxx.utils.helpers import success_response, error_response
+
+router = APIRouter(prefix="/agents", tags=["Agents"])
+
+
+class AgentCreate(BaseModel):
+ name: str
+ role: str = ""
+ provider_id: Optional[int] = None
+ model: str = ""
+ system_prompt: str = "You are a helpful AI assistant."
+ color: str = "#2563eb"
+
+
+class AgentUpdate(BaseModel):
+ name: Optional[str] = None
+ role: Optional[str] = None
+ provider_id: Optional[int] = None
+ model: Optional[str] = None
+ system_prompt: Optional[str] = None
+ color: Optional[str] = None
+
+
+@router.get("/", response_model=dict)
+def list_agents(
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """List all agents for current user"""
+ agents = db.query(Agent).filter(
+ Agent.user_id == current_user.id
+ ).order_by(Agent.updated_at.desc()).all()
+ return success_response(data=[a.to_dict() for a in agents])
+
+
+@router.post("/", response_model=dict)
+def create_agent(
+ data: AgentCreate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Create a new agent"""
+ model = data.model
+ provider_id = data.provider_id
+ if provider_id and not model:
+ provider = db.query(LLMProvider).filter(
+ LLMProvider.id == provider_id,
+ LLMProvider.user_id == current_user.id
+ ).first()
+ if provider:
+ model = provider.default_model
+ if not model:
+ default_provider = db.query(LLMProvider).filter(
+ LLMProvider.user_id == current_user.id,
+ LLMProvider.is_default == True
+ ).first()
+ if default_provider:
+ provider_id = default_provider.id
+ model = default_provider.default_model
+ if not model:
+ model = "gpt-4"
+
+ agent = Agent(
+ user_id=current_user.id,
+ name=data.name,
+ role=data.role,
+ provider_id=provider_id,
+ model=model,
+ system_prompt=data.system_prompt,
+ color=data.color
+ )
+ db.add(agent)
+ db.commit()
+ db.refresh(agent)
+ return success_response(data=agent.to_dict(), message="Agent created")
+
+
+@router.get("/{agent_id}", response_model=dict)
+def get_agent(
+ agent_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Get agent details"""
+ agent = db.query(Agent).filter(
+ Agent.id == agent_id,
+ Agent.user_id == current_user.id
+ ).first()
+ if not agent:
+ return error_response("Agent not found", 404)
+ return success_response(data=agent.to_dict())
+
+
+@router.put("/{agent_id}", response_model=dict)
+def update_agent(
+ agent_id: int,
+ data: AgentUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Update an agent"""
+ agent = db.query(Agent).filter(
+ Agent.id == agent_id,
+ Agent.user_id == current_user.id
+ ).first()
+ if not agent:
+ return error_response("Agent not found", 404)
+
+ update_data = data.dict(exclude_unset=True)
+ for key, value in update_data.items():
+ setattr(agent, key, value)
+
+ db.commit()
+ db.refresh(agent)
+ return success_response(data=agent.to_dict(), message="Agent updated")
+
+
+@router.delete("/{agent_id}", response_model=dict)
+def delete_agent(
+ agent_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Delete an agent"""
+ agent = db.query(Agent).filter(
+ Agent.id == agent_id,
+ Agent.user_id == current_user.id
+ ).first()
+ if not agent:
+ return error_response("Agent not found", 404)
+
+ db.delete(agent)
+ db.commit()
+ return success_response(message="Agent deleted")
diff --git a/luxx/routes/auth.py b/luxx/routes/auth.py
index 00daaae..094711c 100644
--- a/luxx/routes/auth.py
+++ b/luxx/routes/auth.py
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from pydantic import BaseModel
from luxx.database import get_db
-from luxx.models import User
+from luxx.models import User, UserSettings
from luxx.utils.helpers import (
hash_password,
verify_password,
@@ -49,6 +49,15 @@ class UserPermissionUpdate(BaseModel):
permission_level: int
+class SettingsUpdate(BaseModel):
+ """User settings update model"""
+ default_provider_id: int | None = None
+ temperature: float | None = None
+ max_tokens: int | None = None
+ thinking_enabled: bool | None = None
+ system_prompt: str | None = None
+
+
class TokenResponse(BaseModel):
"""Token response model"""
access_token: str
@@ -167,3 +176,40 @@ def update_user(user_id: int, data: UserPermissionUpdate, admin_user: User = Dep
db.commit()
return success_response(data=user.to_dict(), message="User permission updated")
+
+
+def _get_or_create_settings(db: Session, user_id: int) -> UserSettings:
+ """Get or create user settings"""
+ settings = db.query(UserSettings).filter(UserSettings.user_id == user_id).first()
+ if not settings:
+ settings = UserSettings(user_id=user_id)
+ db.add(settings)
+ db.commit()
+ db.refresh(settings)
+ return settings
+
+
+@router.get("/settings", response_model=dict)
+def get_settings(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
+ """Get current user settings"""
+ settings = _get_or_create_settings(db, current_user.id)
+ return success_response(data=settings.to_dict())
+
+
+@router.put("/settings", response_model=dict)
+def update_settings(
+ data: SettingsUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Update current user settings"""
+ settings = _get_or_create_settings(db, current_user.id)
+
+ update_data = data.dict(exclude_unset=True)
+ for key, value in update_data.items():
+ setattr(settings, key, value)
+
+ db.commit()
+ db.refresh(settings)
+
+ return success_response(data=settings.to_dict(), message="Settings updated")
diff --git a/luxx/routes/chat_rooms.py b/luxx/routes/chat_rooms.py
new file mode 100644
index 0000000..2d8c65b
--- /dev/null
+++ b/luxx/routes/chat_rooms.py
@@ -0,0 +1,462 @@
+"""Chat room routes for multi-agent conversations"""
+from typing import Optional, List
+from fastapi import APIRouter, Depends
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+from datetime import datetime
+
+from luxx.database import get_db, SessionLocal
+from luxx.models import ChatRoom, RoomAgent, Agent, Message, LLMProvider, User
+from luxx.routes.auth import get_current_user
+from luxx.services.chat_room import orchestrator
+from luxx.utils.helpers import generate_id, success_response, error_response, paginate
+
+router = APIRouter(prefix="/chat-rooms", tags=["Chat Rooms"])
+
+
+# ============ Request Models ============
+
+class AgentConfig(BaseModel):
+ agent_id: Optional[int] = None # Link to existing Agent
+ name: str = ""
+ role: str = ""
+ provider_id: Optional[int] = None
+ model: str = ""
+ system_prompt: str = "You are a helpful AI assistant."
+ color: str = "#2563eb"
+
+
+class ChatRoomCreate(BaseModel):
+ title: str
+ task: str
+ max_rounds: int = 5
+ agents: List[AgentConfig] = []
+
+
+class ChatRoomUpdate(BaseModel):
+ title: Optional[str] = None
+ task: Optional[str] = None
+ max_rounds: Optional[int] = None
+ status: Optional[str] = None
+
+
+class AgentCreate(BaseModel):
+ agent_id: Optional[int] = None # Link to existing Agent
+ name: str = ""
+ role: str = ""
+ provider_id: Optional[int] = None
+ model: str = ""
+ system_prompt: str = "You are a helpful AI assistant."
+ color: str = "#2563eb"
+
+
+class AgentUpdate(BaseModel):
+ name: Optional[str] = None
+ role: Optional[str] = None
+ provider_id: Optional[int] = None
+ model: Optional[str] = None
+ system_prompt: Optional[str] = None
+ color: Optional[str] = None
+ turn_order: Optional[int] = None
+
+
+# ============ Room CRUD ============
+
+@router.get("/", response_model=dict)
+def list_rooms(
+ page: int = 1,
+ page_size: int = 20,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """List chat rooms"""
+ query = db.query(ChatRoom).filter(ChatRoom.user_id == current_user.id)
+ result = paginate(query.order_by(ChatRoom.updated_at.desc()), page, page_size)
+ return success_response(data={
+ "items": [r.to_dict() for r in result["items"]],
+ "total": result["total"],
+ "page": result["page"],
+ "page_size": result["page_size"]
+ })
+
+
+@router.post("/", response_model=dict)
+def create_room(
+ data: ChatRoomCreate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Create a chat room with agents"""
+ room = ChatRoom(
+ id=generate_id("room"),
+ user_id=current_user.id,
+ title=data.title,
+ task=data.task,
+ max_rounds=data.max_rounds
+ )
+ db.add(room)
+ db.flush()
+
+ for i, agent_cfg in enumerate(data.agents):
+ # If agent_id provided, copy config from existing Agent
+ if agent_cfg.agent_id:
+ existing = db.query(Agent).filter(
+ Agent.id == agent_cfg.agent_id,
+ Agent.user_id == current_user.id
+ ).first()
+ if existing:
+ name = agent_cfg.name or existing.name
+ role = agent_cfg.role or existing.role
+ provider_id = agent_cfg.provider_id or existing.provider_id
+ model = agent_cfg.model or existing.model
+ system_prompt = agent_cfg.system_prompt if agent_cfg.system_prompt != "You are a helpful AI assistant." else existing.system_prompt
+ color = agent_cfg.color if agent_cfg.color != "#2563eb" else existing.color
+ agent_id = existing.id
+ else:
+ return error_response(f"Agent {agent_cfg.agent_id} not found", 404)
+ else:
+ name = agent_cfg.name or f"Agent {i+1}"
+ role = agent_cfg.role
+ provider_id = agent_cfg.provider_id
+ model = agent_cfg.model
+ system_prompt = agent_cfg.system_prompt
+ color = agent_cfg.color
+ agent_id = None
+
+ # Resolve model from provider if not specified
+ if provider_id and not model:
+ provider = db.query(LLMProvider).filter(
+ LLMProvider.id == provider_id,
+ LLMProvider.user_id == current_user.id
+ ).first()
+ if provider:
+ model = provider.default_model
+ if not model:
+ default_provider = db.query(LLMProvider).filter(
+ LLMProvider.user_id == current_user.id,
+ LLMProvider.is_default == True
+ ).first()
+ if default_provider:
+ provider_id = default_provider.id
+ model = default_provider.default_model
+ if not model:
+ model = "gpt-4"
+
+ agent = RoomAgent(
+ room_id=room.id,
+ agent_id=agent_id,
+ name=name,
+ role=role,
+ provider_id=provider_id,
+ model=model,
+ system_prompt=system_prompt,
+ color=color,
+ turn_order=i
+ )
+ db.add(agent)
+
+ db.commit()
+ db.refresh(room)
+ return success_response(data=room.to_dict(include_messages=False), message="Room created")
+
+
+@router.get("/{room_id}", response_model=dict)
+def get_room(
+ room_id: str,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Get room details with agents"""
+ room = db.query(ChatRoom).filter(
+ ChatRoom.id == room_id,
+ ChatRoom.user_id == current_user.id
+ ).first()
+ if not room:
+ return error_response("Room not found", 404)
+
+ result = room.to_dict(include_messages=False)
+ # Also get message count
+ msg_count = db.query(Message).filter(Message.room_id == room_id).count()
+ result["message_count"] = msg_count
+ return success_response(data=result)
+
+
+@router.put("/{room_id}", response_model=dict)
+def update_room(
+ room_id: str,
+ data: ChatRoomUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Update room"""
+ room = db.query(ChatRoom).filter(
+ ChatRoom.id == room_id,
+ ChatRoom.user_id == current_user.id
+ ).first()
+ if not room:
+ return error_response("Room not found", 404)
+
+ if room.status == "running":
+ return error_response("Cannot update a running room", 400)
+
+ update_data = data.dict(exclude_unset=True)
+ for key, value in update_data.items():
+ setattr(room, key, value)
+
+ db.commit()
+ db.refresh(room)
+ return success_response(data=room.to_dict(), message="Room updated")
+
+
+@router.delete("/{room_id}", response_model=dict)
+def delete_room(
+ room_id: str,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Delete room"""
+ room = db.query(ChatRoom).filter(
+ ChatRoom.id == room_id,
+ ChatRoom.user_id == current_user.id
+ ).first()
+ if not room:
+ return error_response("Room not found", 404)
+
+ if room.status == "running":
+ return error_response("Cannot delete a running room. Stop it first.", 400)
+
+ db.delete(room)
+ db.commit()
+ return success_response(message="Room deleted")
+
+
+# ============ Room Actions ============
+
+@router.post("/{room_id}/start")
+async def start_room(
+ room_id: str,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Start the multi-agent conversation as SSE stream"""
+ room = db.query(ChatRoom).filter(
+ ChatRoom.id == room_id,
+ ChatRoom.user_id == current_user.id
+ ).first()
+ if not room:
+ return error_response("Room not found", 404)
+
+ if room.status == "running":
+ return error_response("Room is already running", 400)
+
+ async def event_generator():
+ async for sse_str in orchestrator.run_room(room_id):
+ yield sse_str
+
+ return StreamingResponse(
+ event_generator(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no"
+ }
+ )
+
+
+@router.post("/{room_id}/stop", response_model=dict)
+def stop_room(
+ room_id: str,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Stop a running room"""
+ room = db.query(ChatRoom).filter(
+ ChatRoom.id == room_id,
+ ChatRoom.user_id == current_user.id
+ ).first()
+ if not room:
+ return error_response("Room not found", 404)
+
+ orchestrator.cancel(room_id)
+ room.status = "paused"
+ db.commit()
+ return success_response(message="Room stopped")
+
+
+@router.post("/{room_id}/reset", response_model=dict)
+def reset_room(
+ room_id: str,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Reset room to initial state, clearing all messages"""
+ room = db.query(ChatRoom).filter(
+ ChatRoom.id == room_id,
+ ChatRoom.user_id == current_user.id
+ ).first()
+ if not room:
+ return error_response("Room not found", 404)
+
+ if room.status == "running":
+ return error_response("Cannot reset a running room", 400)
+
+ # Delete all messages in this room
+ db.query(Message).filter(Message.room_id == room_id).delete()
+ room.status = "idle"
+ room.current_round = 0
+ db.commit()
+ return success_response(message="Room reset")
+
+
+# ============ Messages ============
+
+@router.get("/{room_id}/messages", response_model=dict)
+def get_room_messages(
+ room_id: str,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Get all messages in a room"""
+ room = db.query(ChatRoom).filter(
+ ChatRoom.id == room_id,
+ ChatRoom.user_id == current_user.id
+ ).first()
+ if not room:
+ return error_response("Room not found", 404)
+
+ messages = db.query(Message).filter(
+ Message.room_id == room_id
+ ).order_by(Message.created_at).all()
+
+ return success_response(data={
+ "messages": [m.to_dict() for m in messages],
+ "room": room.to_dict()
+ })
+
+
+# ============ Agent CRUD ============
+
+@router.post("/{room_id}/agents", response_model=dict)
+def add_agent(
+ room_id: str,
+ data: AgentCreate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Add an agent to a room"""
+ room = db.query(ChatRoom).filter(
+ ChatRoom.id == room_id,
+ ChatRoom.user_id == current_user.id
+ ).first()
+ if not room:
+ return error_response("Room not found", 404)
+
+ if room.status == "running":
+ return error_response("Cannot modify agents while room is running", 400)
+
+ # Get max turn_order
+ max_order = db.query(RoomAgent).filter(
+ RoomAgent.room_id == room_id
+ ).count()
+
+ # If agent_id provided, copy from existing Agent
+ if data.agent_id:
+ existing = db.query(Agent).filter(
+ Agent.id == data.agent_id,
+ Agent.user_id == current_user.id
+ ).first()
+ if not existing:
+ return error_response(f"Agent {data.agent_id} not found", 404)
+ name = data.name or existing.name
+ role = data.role or existing.role
+ provider_id = data.provider_id or existing.provider_id
+ model = data.model or existing.model
+ system_prompt = data.system_prompt if data.system_prompt != "You are a helpful AI assistant." else existing.system_prompt
+ color = data.color if data.color != "#2563eb" else existing.color
+ agent_id = existing.id
+ else:
+ name = data.name or f"Agent {max_order + 1}"
+ role = data.role
+ provider_id = data.provider_id
+ model = data.model
+ system_prompt = data.system_prompt
+ color = data.color
+ agent_id = None
+
+ model = model
+ if provider_id and not model:
+ provider = db.query(LLMProvider).filter(LLMProvider.id == provider_id).first()
+ if provider:
+ model = provider.default_model
+ if not model:
+ model = "gpt-4"
+
+ agent = RoomAgent(
+ room_id=room_id,
+ agent_id=agent_id,
+ name=name,
+ role=role,
+ provider_id=provider_id,
+ model=model,
+ system_prompt=system_prompt,
+ color=color,
+ turn_order=max_order
+ )
+ db.add(agent)
+ db.commit()
+ db.refresh(agent)
+ return success_response(data=agent.to_dict(), message="Agent added")
+
+
+@router.put("/{room_id}/agents/{agent_id}", response_model=dict)
+def update_agent(
+ room_id: str,
+ agent_id: int,
+ data: AgentUpdate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Update an agent"""
+ agent = db.query(RoomAgent).filter(
+ RoomAgent.id == agent_id,
+ RoomAgent.room_id == room_id
+ ).first()
+ if not agent:
+ return error_response("Agent not found", 404)
+
+ room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
+ if room and room.status == "running":
+ return error_response("Cannot modify agents while room is running", 400)
+
+ update_data = data.dict(exclude_unset=True)
+ for key, value in update_data.items():
+ setattr(agent, key, value)
+
+ db.commit()
+ return success_response(data=agent.to_dict(), message="Agent updated")
+
+
+@router.delete("/{room_id}/agents/{agent_id}", response_model=dict)
+def delete_agent(
+ room_id: str,
+ agent_id: int,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Remove an agent from a room"""
+ agent = db.query(RoomAgent).filter(
+ RoomAgent.id == agent_id,
+ RoomAgent.room_id == room_id
+ ).first()
+ if not agent:
+ return error_response("Agent not found", 404)
+
+ room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
+ if room and room.status == "running":
+ return error_response("Cannot remove agents while room is running", 400)
+
+ db.delete(agent)
+ db.commit()
+ return success_response(message="Agent removed")
diff --git a/luxx/services/chat_room.py b/luxx/services/chat_room.py
new file mode 100644
index 0000000..e6f4967
--- /dev/null
+++ b/luxx/services/chat_room.py
@@ -0,0 +1,288 @@
+"""Multi-agent chat room service.
+
+Orchestrates multiple agents taking turns to discuss and solve a task.
+Each agent uses its own LLM provider/model and system prompt.
+"""
+import json
+import logging
+import asyncio
+import traceback
+from typing import List, Dict, Any, AsyncGenerator, Optional
+
+from luxx.database import SessionLocal
+from luxx.models import ChatRoom, RoomAgent, Message, LLMProvider
+from luxx.services.llm_client import LLMClient
+from luxx.services.stream_context import StreamState, StepType
+from luxx.services.events import sse_event
+from luxx.utils.helpers import generate_id
+
+logger = logging.getLogger(__name__)
+
+
+class ChatRoomOrchestrator:
+ """Orchestrates multi-agent conversations in a chat room."""
+
+ def __init__(self):
+ self._running_rooms: Dict[str, asyncio.Task] = {}
+
+ def is_running(self, room_id: str) -> bool:
+ return room_id in self._running_rooms and not self._running_rooms[room_id].done()
+
+ def cancel(self, room_id: str):
+ task = self._running_rooms.get(room_id)
+ if task and not task.done():
+ task.cancel()
+
+ async def run_room(
+ self,
+ room_id: str,
+ db_session=None
+ ) -> AsyncGenerator[str, None]:
+ """Run a chat room: agents take turns discussing the task."""
+ db = db_session or SessionLocal()
+ own_session = db_session is None
+
+ try:
+ room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
+ if not room:
+ yield sse_event("error", {"content": "Room not found"})
+ return
+
+ agents = db.query(RoomAgent).filter(
+ RoomAgent.room_id == room_id
+ ).order_by(RoomAgent.turn_order).all()
+
+ if not agents:
+ yield sse_event("error", {"content": "No agents in room"})
+ return
+
+ room.status = "running"
+ db.commit()
+
+ # Yield room started event
+ yield sse_event("room_started", {"room_id": room_id, "task": room.task})
+
+ # Build conversation history from existing messages
+ history = self._load_history(room_id, db)
+
+ # If no messages yet, add the task as the initial user message
+ if not history:
+ task_msg = Message(
+ id=generate_id("msg"),
+ room_id=room_id,
+ role="user",
+ content=json.dumps({"text": room.task}, ensure_ascii=False),
+ sender_name="用户",
+ sender_color="#10b981",
+ round_number=0
+ )
+ db.add(task_msg)
+ db.commit()
+ history.append({"role": "user", "content": room.task})
+ yield sse_event("message", task_msg.to_dict())
+
+ # Run rounds
+ for round_num in range(room.current_round + 1, room.max_rounds + 1):
+ room.current_round = round_num
+ db.commit()
+
+ yield sse_event("round_start", {
+ "round": round_num,
+ "max_rounds": room.max_rounds
+ })
+
+ for agent in agents:
+ try:
+ async for event in self._agent_turn(
+ room_id, agent, history, round_num, db
+ ):
+ yield event
+ except asyncio.CancelledError:
+ room.status = "paused"
+ db.commit()
+ yield sse_event("room_paused", {"room_id": room_id, "round": round_num})
+ return
+ except Exception as e:
+ logger.error(f"Agent {agent.name} error: {e}\n{traceback.format_exc()}")
+ yield sse_event("agent_error", {
+ "agent": agent.name,
+ "error": str(e)
+ })
+
+ yield sse_event("round_end", {"round": round_num})
+
+ # Completed
+ room.status = "completed"
+ db.commit()
+ yield sse_event("room_completed", {
+ "room_id": room_id,
+ "total_rounds": room.max_rounds
+ })
+
+ except asyncio.CancelledError:
+ room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
+ if room:
+ room.status = "paused"
+ db.commit()
+ yield sse_event("room_paused", {"room_id": room_id})
+ except Exception as e:
+ logger.error(f"Room error: {e}\n{traceback.format_exc()}")
+ room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
+ if room:
+ room.status = "error"
+ db.commit()
+ yield sse_event("error", {"content": str(e)})
+ finally:
+ if own_session:
+ db.close()
+ self._running_rooms.pop(room_id, None)
+
+ async def _agent_turn(
+ self,
+ room_id: str,
+ agent: RoomAgent,
+ history: List[Dict],
+ round_num: int,
+ db
+ ) -> AsyncGenerator[str, None]:
+ """Execute one agent's turn in the conversation."""
+ # Get LLM client for this agent
+ llm, max_tokens = self._create_llm_client(agent, db)
+ if not llm:
+ yield sse_event("agent_error", {
+ "agent": agent.name,
+ "error": "No LLM provider configured"
+ })
+ return
+
+ model = agent.model or llm.default_model or "gpt-4"
+
+ # Build messages for this agent
+ messages = self._build_agent_messages(agent, history)
+
+ # Call LLM (non-streaming for simplicity in multi-agent context)
+ try:
+ response = await llm.async_sync_call(
+ model=model,
+ messages=messages,
+ temperature=0.7,
+ max_tokens=max_tokens or 2000
+ )
+ except Exception as e:
+ logger.error(f"LLM call failed for {agent.name}: {e}")
+ yield sse_event("agent_error", {
+ "agent": agent.name,
+ "error": f"LLM call failed: {str(e)}"
+ })
+ return
+
+ content = response.get("content", "")
+ usage = response.get("usage", {})
+ token_count = usage.get("total_tokens", len(content) // 4)
+
+ # Build steps for storage (compatible with Message content format)
+ steps = [{"id": "step-0", "index": 0, "type": "text", "content": content}]
+ content_json = {"steps": steps}
+
+ # Save message
+ msg = Message(
+ id=generate_id("msg"),
+ room_id=room_id,
+ role="assistant",
+ content=json.dumps(content_json, ensure_ascii=False),
+ token_count=token_count,
+ usage=json.dumps(usage) if usage else None,
+ sender_name=agent.name,
+ sender_color=agent.color,
+ round_number=round_num
+ )
+ db.add(msg)
+ db.commit()
+
+ # Update history
+ history.append({"role": "assistant", "content": content, "sender": agent.name})
+
+ # Yield message event
+ msg_dict = msg.to_dict()
+ yield sse_event("message", msg_dict)
+
+ # Close client
+ await llm.close()
+
+ def _create_llm_client(self, agent: RoomAgent, db) -> tuple:
+ """Create LLM client for an agent."""
+ if agent.provider_id:
+ provider = db.query(LLMProvider).filter(
+ LLMProvider.id == agent.provider_id
+ ).first()
+ if provider:
+ client = LLMClient(
+ api_key=provider.api_key,
+ api_url=provider.base_url,
+ model=agent.model or provider.default_model,
+ provider_type=provider.provider_type
+ )
+ return client, provider.max_tokens
+
+ return None, None
+
+ def _build_agent_messages(self, agent: RoomAgent, history: List[Dict]) -> List[Dict]:
+ """Build the message list for an agent's LLM call."""
+ messages = [{"role": "system", "content": agent.system_prompt}]
+
+ for h in history:
+ role = h.get("role", "user")
+ content = h.get("content", "")
+ sender = h.get("sender", "")
+
+ if role == "user":
+ messages.append({"role": "user", "content": content})
+ elif role == "assistant":
+ # Prefix with sender name so the agent knows who said what
+ prefix = f"[{sender}]: " if sender else ""
+ messages.append({"role": "assistant", "content": prefix + content})
+
+ return messages
+
+ def _load_history(self, room_id: str, db) -> List[Dict]:
+ """Load conversation history from existing room messages."""
+ messages = db.query(Message).filter(
+ Message.room_id == room_id
+ ).order_by(Message.created_at).all()
+
+ history = []
+ for msg in messages:
+ # Extract text from message content
+ text = self._extract_text(msg.content)
+ entry = {"role": msg.role, "content": text}
+ if msg.sender_name and msg.role == "assistant":
+ entry["sender"] = msg.sender_name
+ history.append(entry)
+
+ return history
+
+ @staticmethod
+ def _extract_text(content: str) -> str:
+ """Extract text from message content JSON."""
+ if not content:
+ return ""
+ try:
+ parsed = json.loads(content)
+ if isinstance(parsed, dict):
+ # Try steps-based format
+ steps = parsed.get("steps", [])
+ if steps:
+ return "".join(
+ s.get("content", "") for s in steps
+ if s.get("type") == "text"
+ )
+ # Try simple text format
+ if "text" in parsed:
+ return parsed["text"]
+ return content
+ except (json.JSONDecodeError, TypeError):
+ return content
+
+
+# Singleton orchestrator
+orchestrator = ChatRoomOrchestrator()