refactor: 增加多agent设置

This commit is contained in:
ViperEkura 2026-04-25 17:54:37 +08:00
parent 71960aed6d
commit 42f5bd379b
10 changed files with 1574 additions and 50 deletions

View File

@ -32,6 +32,10 @@ const navItems = [
path: '/conversations',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`
},
{
path: '/chat-rooms',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>`
},
{
path: '/tools',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`

View File

@ -1,8 +1,11 @@
<template>
<div class="message-bubble" :class="[message.role]">
<div v-if="message.role === 'user'" class="avatar">user</div>
<div v-else class="avatar">Luxx</div>
<div class="message-bubble" :class="[message.role, { 'room-msg': message.room_id }]">
<div class="avatar" :style="avatarStyle">{{ avatarText }}</div>
<div class="message-container">
<div v-if="message.sender_name || message.round_number" class="sender-info">
<span v-if="message.sender_name" class="sender-name" :style="{ color: message.sender_color }">{{ message.sender_name }}</span>
<span v-if="message.round_number" class="round-tag">R{{ message.round_number }}</span>
</div>
<!-- File attachments list -->
<div v-if="message.attachments && message.attachments.length > 0" class="attachments-list">
<div v-for="(file, index) in message.attachments" :key="index" class="attachment-item">
@ -61,6 +64,20 @@ defineEmits(['delete', 'regenerate'])
const messageRef = ref(null)
const avatarStyle = computed(() => {
if (props.message.sender_color && props.message.role === 'assistant') {
return { background: props.message.sender_color }
}
return {}
})
const avatarText = computed(() => {
if (props.message.sender_name && props.message.role === 'assistant') {
return props.message.sender_name.charAt(0)
}
return props.message.role === 'user' ? 'user' : 'Luxx'
})
const renderedContent = computed(() => {
const text = props.message.content || props.message.text || ''
if (!text) return ''
@ -136,4 +153,24 @@ const regenerateIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="no
font-size: 12px;
color: var(--text-tertiary);
}
.sender-info {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.sender-name {
font-size: 0.8rem;
font-weight: 600;
}
.round-tag {
font-size: 0.65rem;
background: var(--bg-secondary);
padding: 1px 6px;
border-radius: 4px;
color: var(--text-secondary);
}
</style>

View File

@ -32,6 +32,12 @@ const routes = [
component: () => import('../views/ToolsView.vue'),
meta: { requiresAuth: true }
},
{
path: '/chat-rooms',
name: 'ChatRooms',
component: () => import('../views/ChatRoomView.vue'),
meta: { requiresAuth: true }
},
// 首页重定向
{
path: '/home',

View File

@ -84,4 +84,21 @@ export const providersAPI = {
test: (id) => api.post(`/providers/${id}/test`)
}
// ============ 聊天室接口 ============
export const chatRoomsAPI = {
list: (params) => api.get('/chat-rooms/', { params }),
create: (data) => api.post('/chat-rooms/', data),
get: (id) => api.get(`/chat-rooms/${id}`),
update: (id, data) => api.put(`/chat-rooms/${id}`, data),
delete: (id) => api.delete(`/chat-rooms/${id}`),
getMessages: (id) => api.get(`/chat-rooms/${id}/messages`),
start: (id) => `/api/chat-rooms/${id}/start`,
stop: (id) => api.post(`/chat-rooms/${id}/stop`),
reset: (id) => api.post(`/chat-rooms/${id}/reset`),
addAgent: (roomId, data) => api.post(`/chat-rooms/${roomId}/agents`, data),
updateAgent: (roomId, agentId, data) => api.put(`/chat-rooms/${roomId}/agents/${agentId}`, data),
deleteAgent: (roomId, agentId) => api.delete(`/chat-rooms/${roomId}/agents/${agentId}`)
}
export default api

View File

@ -0,0 +1,698 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { chatRoomsAPI, providersAPI } from '../utils/api.js'
import MessageBubble from '../components/MessageBubble.vue'
// ============ Room list state ============
const rooms = ref([])
const roomsLoading = ref(false)
const providers = ref([])
const showCreate = ref(false)
const creating = ref(false)
const newRoom = ref({
title: '',
task: '',
max_rounds: 5,
agents: [
{ name: '分析师', role: '', model: '', provider_id: null, system_prompt: '你是一位资深分析师,擅长分析问题、拆解需求,提出关键问题和潜在风险。请用简洁的中文回复。', color: '#2563eb' },
{ name: '架构师', role: '', model: '', provider_id: null, system_prompt: '你是一位经验丰富的架构师,擅长设计技术方案和系统架构。请用简洁的中文回复。', color: '#10b981' },
{ name: '评审员', role: '', model: '', provider_id: null, system_prompt: '你是一位严格的评审员,负责审查方案的可行性、安全性和性能。请用简洁的中文回复。', color: '#f59e0b' }
]
})
// ============ Selected room state ============
const selectedId = ref(null)
const room = ref(null)
const messages = ref([])
const messagesLoading = ref(false)
const streaming = ref(false)
const error = ref('')
const messagesContainer = ref(null)
const showAgentPanel = ref(true)
// Agent editing
const editingAgent = ref(null)
const agentForm = ref({})
const agentSaving = ref(false)
let abortController = null
const statusMap = {
idle: { label: '就绪', class: 'status-idle' },
running: { label: '进行中', class: 'status-running' },
paused: { label: '已暂停', class: 'status-paused' },
completed: { label: '已完成', class: 'status-completed' },
error: { label: '错误', class: 'status-error' }
}
const agents = computed(() => room.value?.agents || [])
const canEditAgents = computed(() => room.value?.status !== 'running' && !streaming.value)
// ============ Room list ============
async function loadRooms() {
roomsLoading.value = true
try {
const res = await chatRoomsAPI.list()
rooms.value = res.data?.items || []
} catch (e) {
console.error('Failed to load rooms:', e)
} finally {
roomsLoading.value = false
}
}
async function loadProviders() {
try {
const res = await providersAPI.list()
providers.value = res.data || []
} catch (e) {
console.error('Failed to load providers:', e)
}
}
async function createRoom() {
if (!newRoom.value.title || !newRoom.value.task) return
creating.value = true
try {
const res = await chatRoomsAPI.create(newRoom.value)
showCreate.value = false
resetNewRoom()
await loadRooms()
// Auto-select the new room
const created = res.data
if (created?.id) selectRoom(created.id)
} catch (e) {
console.error('Failed to create room:', e)
} finally {
creating.value = false
}
}
function resetNewRoom() {
newRoom.value = {
title: '',
task: '',
max_rounds: 5,
agents: [
{ name: '分析师', role: '', model: '', provider_id: null, system_prompt: '你是一位资深分析师,擅长分析问题、拆解需求,提出关键问题和潜在风险。请用简洁的中文回复。', color: '#2563eb' },
{ name: '架构师', role: '', model: '', provider_id: null, system_prompt: '你是一位经验丰富的架构师,擅长设计技术方案和系统架构。请用简洁的中文回复。', color: '#10b981' },
{ name: '评审员', role: '', model: '', provider_id: null, system_prompt: '你是一位严格的评审员,负责审查方案的可行性、安全性和性能。请用简洁的中文回复。', color: '#f59e0b' }
]
}
}
function addNewAgent() {
newRoom.value.agents.push({ name: `Agent ${newRoom.value.agents.length + 1}`, role: '', model: '', provider_id: null, system_prompt: 'You are a helpful AI assistant.', color: randomColor() })
}
function removeNewAgent(index) {
if (newRoom.value.agents.length <= 1) return
newRoom.value.agents.splice(index, 1)
}
async function deleteRoom(id) {
if (!confirm('确定删除此聊天室?')) return
try {
await chatRoomsAPI.delete(id)
if (selectedId.value === id) {
selectedId.value = null
room.value = null
messages.value = []
}
await loadRooms()
} catch (e) {
console.error('Failed to delete room:', e)
}
}
// ============ Select room ============
async function selectRoom(id) {
if (streaming.value) {
if (abortController) abortController.abort()
streaming.value = false
}
selectedId.value = id
error.value = ''
messagesLoading.value = true
try {
const [roomRes, msgRes] = await Promise.all([
chatRoomsAPI.get(id),
chatRoomsAPI.getMessages(id)
])
room.value = roomRes.data
messages.value = msgRes.data?.messages || []
if (msgRes.data?.room) room.value = msgRes.data.room
await nextTick()
scrollToBottom()
} catch (e) {
console.error('Failed to load room:', e)
error.value = '加载失败'
} finally {
messagesLoading.value = false
}
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// ============ SSE Streaming ============
async function startRoom() {
if (!selectedId.value || streaming.value) return
streaming.value = true
error.value = ''
abortController = new AbortController()
try {
const token = localStorage.getItem('access_token')
const url = chatRoomsAPI.start(selectedId.value)
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
signal: abortController.signal
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.message || `HTTP ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (value) buffer += decoder.decode(value, { stream: true })
if (done) break
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
handleSSEEvent(currentEvent, data)
} catch (e) { /* ignore parse errors */ }
}
}
}
} catch (e) {
if (e.name !== 'AbortError') error.value = e.message
} finally {
streaming.value = false
if (selectedId.value) {
const res = await chatRoomsAPI.get(selectedId.value)
room.value = res.data
// Also refresh sidebar room list for status
await loadRooms()
}
}
}
function handleSSEEvent(event, data) {
switch (event) {
case 'message':
messages.value.push(data)
nextTick(scrollToBottom)
break
case 'room_started':
room.value = { ...room.value, status: 'running' }
break
case 'room_completed':
room.value = { ...room.value, status: 'completed' }
break
case 'room_paused':
room.value = { ...room.value, status: 'paused' }
break
case 'round_start':
room.value = { ...room.value, current_round: data.round }
break
case 'agent_error':
error.value = `Agent ${data.agent} 出错: ${data.error}`
break
case 'error':
error.value = data.content || '未知错误'
break
}
}
async function stopRoom() {
try {
if (abortController) { abortController.abort(); abortController = null }
await chatRoomsAPI.stop(selectedId.value)
room.value = { ...room.value, status: 'paused' }
streaming.value = false
} catch (e) {
console.error('Failed to stop room:', e)
}
}
async function resetRoom() {
if (!confirm('确定重置?将清除所有消息')) return
try {
await chatRoomsAPI.reset(selectedId.value)
messages.value = []
const res = await chatRoomsAPI.get(selectedId.value)
room.value = res.data
} catch (e) {
console.error('Failed to reset room:', e)
}
}
// ============ Agent Management ============
function startAddAgent() {
editingAgent.value = 'new'
agentForm.value = {
name: `Agent ${agents.value.length + 1}`,
role: '',
provider_id: null,
model: '',
system_prompt: 'You are a helpful AI assistant.',
color: randomColor()
}
}
function startEditAgent(agent) {
editingAgent.value = agent
agentForm.value = { ...agent }
}
function cancelAgentEdit() {
editingAgent.value = null
agentForm.value = {}
}
async function saveAgent() {
agentSaving.value = true
try {
if (editingAgent.value === 'new') {
await chatRoomsAPI.addAgent(selectedId.value, agentForm.value)
} else {
await chatRoomsAPI.updateAgent(selectedId.value, editingAgent.value.id, agentForm.value)
}
cancelAgentEdit()
const res = await chatRoomsAPI.get(selectedId.value)
room.value = res.data
} catch (e) {
console.error('Failed to save agent:', e)
error.value = '保存 Agent 失败'
} finally {
agentSaving.value = false
}
}
async function deleteAgent(agent) {
if (!confirm(`确定删除 Agent「${agent.name}」?`)) return
try {
await chatRoomsAPI.deleteAgent(selectedId.value, agent.id)
cancelAgentEdit()
const res = await chatRoomsAPI.get(selectedId.value)
room.value = res.data
} catch (e) {
console.error('Failed to delete agent:', e)
error.value = '删除 Agent 失败'
}
}
function randomColor() {
const colors = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
return colors[Math.floor(Math.random() * colors.length)]
}
function providerName(pid) {
const p = providers.value.find(x => x.id === pid)
return p ? p.name : '默认'
}
watch(messages, () => { nextTick(scrollToBottom) }, { deep: true })
onMounted(() => {
loadRooms()
loadProviders()
})
onUnmounted(() => {
if (abortController) abortController.abort()
})
</script>
<template>
<div class="page-container chat-rooms">
<div class="rooms-layout">
<!-- Left sidebar: room list + create -->
<aside class="rooms-sidebar">
<div class="sidebar-header">
<button @click="showCreate = true" class="btn-new-room">+ 新建聊天室</button>
</div>
<div v-if="roomsLoading" class="sidebar-loading"><div class="spinner-small"></div></div>
<div v-else-if="rooms.length === 0" class="sidebar-empty">暂无聊天室</div>
<div v-else class="room-list">
<div
v-for="r in rooms"
:key="r.id"
class="room-item"
:class="{ active: selectedId === r.id }"
@click="selectRoom(r.id)"
>
<div class="room-item-header">
<span class="room-item-title">{{ r.title }}</span>
<span class="status-dot" :class="statusMap[r.status]?.class" :title="statusMap[r.status]?.label"></span>
</div>
<div class="room-item-meta">
<span class="meta-text">
<span v-for="a in (r.agents || []).slice(0, 4)" :key="a.id" class="mini-dot" :style="{ background: a.color }" :title="a.name"></span>
{{ r.agents?.length || 0 }} Agents
</span>
<span class="meta-text">{{ r.current_round }}/{{ r.max_rounds }}</span>
<div class="room-item-actions" @click.stop>
<button @click="deleteRoom(r.id)" class="btn-icon-sm" title="删除">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
</div>
</div>
</div>
</aside>
<!-- Right content -->
<main class="rooms-content">
<!-- No room selected -->
<div v-if="!room" class="empty-content">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
</div>
<p>选择一个聊天室或创建新的</p>
</div>
<!-- Room selected -->
<div v-else class="room-detail">
<!-- Toolbar -->
<div class="room-toolbar">
<div class="toolbar-info">
<h3>{{ room.title }}</h3>
<div class="toolbar-badges">
<span class="status-badge" :class="statusMap[room.status]?.class">{{ statusMap[room.status]?.label }}</span>
<span class="round-badge" v-if="room.current_round > 0">R{{ room.current_round }}/{{ room.max_rounds }}</span>
</div>
</div>
<div class="toolbar-actions">
<button class="btn-icon" :class="{ active: showAgentPanel }" @click="showAgentPanel = !showAgentPanel" title="Agent 面板">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
</button>
<button v-if="!streaming && room.status !== 'running'" class="btn-ctrl btn-start" @click="startRoom">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
{{ room.status === 'paused' ? '继续' : '开始' }}
</button>
<button v-if="streaming || room.status === 'running'" class="btn-ctrl btn-stop" @click="stopRoom">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
停止
</button>
<button v-if="room.status !== 'running' && messages.length > 0" class="btn-ctrl btn-reset" @click="resetRoom">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
重置
</button>
</div>
</div>
<div v-if="error" class="error-bar">{{ error }}<button @click="error = ''">&times;</button></div>
<!-- Content area -->
<div class="room-body">
<!-- Chat messages -->
<div class="chat-messages" ref="messagesContainer">
<div v-if="messagesLoading" class="loading-messages"><div class="spinner-small"></div><span>加载中...</span></div>
<div v-else-if="messages.length === 0" class="chat-empty">
<p>点击开始启动多 Agent 对话</p>
</div>
<div v-else>
<MessageBubble v-for="msg in messages" :key="msg.id" :message="msg" />
</div>
<div v-if="streaming" class="streaming-hint">
<div class="spinner-sm"></div><span>Agent 正在思考...</span>
</div>
</div>
<!-- Agent panel (right) -->
<div v-if="showAgentPanel" class="agent-panel">
<div class="panel-header">
<h4>Agents</h4>
<button v-if="canEditAgents" class="btn-xs btn-primary" @click="startAddAgent">+ 添加</button>
</div>
<div class="panel-agents">
<div v-for="agent in agents" :key="agent.id" class="agent-row" :class="{ editing: editingAgent?.id === agent.id }" @click="startEditAgent(agent)">
<span class="agent-dot" :style="{ background: agent.color }">{{ agent.name.charAt(0) }}</span>
<div class="agent-info">
<span class="agent-name" :style="{ color: agent.color }">{{ agent.name }}</span>
<span class="agent-model">{{ agent.model || 'default' }}</span>
</div>
<button v-if="canEditAgents" class="btn-del-agent" @click.stop="deleteAgent(agent)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div v-if="agents.length === 0" class="no-agents">
<p>暂无 Agent</p>
<button v-if="canEditAgents" class="btn-xs btn-primary" @click="startAddAgent">添加</button>
</div>
</div>
<!-- Inline agent edit form -->
<div v-if="editingAgent" class="agent-form">
<div class="agent-form-header">
<span>{{ editingAgent === 'new' ? '添加 Agent' : '编辑' }}</span>
<button class="btn-close-xs" @click="cancelAgentEdit">&times;</button>
</div>
<div class="agent-form-body">
<div class="form-row"><input v-model="agentForm.name" placeholder="名称" class="fi" /><input type="color" v-model="agentForm.color" class="fi-color" /></div>
<div class="form-row">
<select v-model="agentForm.provider_id" class="fi">
<option :value="null">默认 Provider</option>
<option v-for="p in providers" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="form-row"><input v-model="agentForm.model" placeholder="模型" class="fi" /></div>
<div class="form-row"><textarea v-model="agentForm.system_prompt" rows="2" placeholder="系统提示词" class="fi"></textarea></div>
<div class="form-row" style="justify-content: flex-end; gap: 0.4rem;">
<button class="btn-xs btn-secondary" @click="cancelAgentEdit">取消</button>
<button class="btn-xs btn-primary" @click="saveAgent" :disabled="!agentForm.name || agentSaving">{{ agentSaving ? '...' : '保存' }}</button>
</div>
</div>
</div>
<!-- Room info -->
<div class="panel-room-info">
<div class="info-item"><span class="info-label">任务</span><p class="info-value">{{ room.task }}</p></div>
<div class="info-item"><span class="info-label">轮次</span><p class="info-value">{{ room.max_rounds }}</p></div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Create room modal -->
<div v-if="showCreate" class="modal-overlay" @click.self="showCreate = false">
<div class="modal">
<div class="modal-head"><h3>新建聊天室</h3><button class="btn-close" @click="showCreate = false">&times;</button></div>
<div class="modal-body">
<div class="fg"><label>标题</label><input v-model="newRoom.title" placeholder="项目架构设计讨论" /></div>
<div class="fg"><label>任务描述</label><textarea v-model="newRoom.task" rows="3" placeholder="描述需要 Agent 讨论的问题..."></textarea></div>
<div class="fg"><label>最大轮次</label><input v-model.number="newRoom.max_rounds" type="number" min="1" max="20" /></div>
<div class="agents-config">
<div class="agents-config-header"><label>Agent 配置</label><button class="btn-xs btn-primary" @click="addNewAgent">+ 添加</button></div>
<div v-for="(agent, i) in newRoom.agents" :key="i" class="agent-cfg-item">
<div class="agent-cfg-row">
<input v-model="agent.name" placeholder="名称" class="agent-cfg-name" />
<input type="color" v-model="agent.color" class="agent-cfg-color" />
<select v-model="agent.provider_id" class="agent-cfg-provider">
<option :value="null">默认</option>
<option v-for="p in providers" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<button v-if="newRoom.agents.length > 1" class="btn-rm-agent" @click="removeNewAgent(i)">&times;</button>
</div>
<textarea v-model="agent.system_prompt" rows="2" placeholder="系统提示词" class="agent-cfg-prompt"></textarea>
</div>
</div>
</div>
<div class="modal-foot">
<button class="btn-secondary" @click="showCreate = false">取消</button>
<button class="btn-primary" @click="createRoom" :disabled="!newRoom.title || !newRoom.task || creating">{{ creating ? '创建中...' : '创建' }}</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Layout */
.page-container { padding: 0 !important; overflow: hidden; height: 100% !important; min-height: 0; display: flex; flex-direction: column; }
.chat-rooms { height: 100% !important; }
.rooms-layout { display: flex; gap: 1rem; height: 100%; flex: 1; min-height: 0; }
/* ===== Left Sidebar ===== */
.rooms-sidebar { width: 20%; min-width: 180px; max-width: 280px; background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; }
.sidebar-header { padding: 0.75rem; border-bottom: 1px solid var(--border-light); }
.btn-new-room { width: 100%; padding: 0.5rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; transition: all 0.2s; }
.btn-new-room:hover { background: var(--accent-primary-hover); }
.sidebar-loading, .sidebar-empty { display: flex; align-items: center; justify-content: center; padding: 2rem; color: var(--text-secondary); font-size: 0.85rem; }
.room-list { flex: 1; overflow-y: auto; }
.room-item { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border-light); cursor: pointer; transition: all 0.15s; }
.room-item:hover { background: var(--bg-hover); }
.room-item.active { background: var(--accent-primary-light); border-left: 3px solid var(--accent-primary); }
.room-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; }
.room-item-title { font-size: 0.8rem; font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.status-idle { background: var(--text-secondary); }
.status-running { background: #22c55e; animation: pulse 1.5s ease-in-out infinite; }
.status-paused { background: #f59e0b; }
.status-completed { background: #3b82f6; }
.status-error { background: #ef4444; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.room-item-meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.7rem; color: var(--text-secondary); }
.meta-text { display: flex; align-items: center; gap: 3px; }
.mini-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.room-item-actions { margin-left: auto; }
.btn-icon-sm { background: transparent; border: none; cursor: pointer; padding: 2px; border-radius: 3px; color: var(--text-secondary); transition: all 0.15s; display: flex; align-items: center; }
.btn-icon-sm:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
/* ===== Right Content ===== */
.rooms-content { flex: 1; background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
.empty-content { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-secondary); }
.empty-content .empty-icon { opacity: 0.4; margin-bottom: 0.75rem; }
.empty-content p { font-size: 0.9rem; }
/* Room detail */
.room-detail { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.room-toolbar { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 1rem; border-bottom: 1px solid var(--border-light); flex-shrink: 0; }
.toolbar-info { flex: 1; min-width: 0; }
.toolbar-info h3 { font-size: 0.95rem; font-weight: 600; color: var(--text-primary); margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.toolbar-badges { display: flex; gap: 0.5rem; align-items: center; margin-top: 2px; }
.status-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 8px; font-weight: 600; }
.status-idle { background: var(--bg-secondary); color: var(--text-secondary); }
.status-running { background: rgba(34,197,94,0.1); color: #22c55e; }
.status-paused { background: rgba(245,158,11,0.1); color: #f59e0b; }
.status-completed { background: rgba(59,130,246,0.1); color: #3b82f6; }
.status-error { background: rgba(239,68,68,0.1); color: #ef4444; }
.round-badge { font-size: 0.65rem; color: var(--text-secondary); }
.toolbar-actions { display: flex; gap: 0.4rem; align-items: center; }
.btn-icon { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: none; border: 1px solid var(--border-light); border-radius: 6px; cursor: pointer; color: var(--text-secondary); transition: all 0.2s; }
.btn-icon:hover, .btn-icon.active { background: var(--bg-secondary); color: var(--accent-primary); border-color: var(--accent-primary); }
.btn-ctrl { display: flex; align-items: center; gap: 0.3rem; padding: 0.35rem 0.75rem; border: none; border-radius: 6px; font-size: 0.8rem; cursor: pointer; font-weight: 500; }
.btn-start { background: #22c55e; color: white; }
.btn-start:hover { background: #16a34a; }
.btn-stop { background: #ef4444; color: white; }
.btn-stop:hover { background: #dc2626; }
.btn-reset { background: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border-light); }
.btn-reset:hover { border-color: var(--accent-primary); color: var(--accent-primary); }
.error-bar { padding: 0.5rem 1rem; background: rgba(239,68,68,0.1); color: #ef4444; font-size: 0.8rem; display: flex; justify-content: space-between; align-items: center; }
.error-bar button { background: none; border: none; color: #ef4444; cursor: pointer; font-size: 1.2rem; }
/* Room body: chat + agent panel */
.room-body { display: flex; flex: 1; overflow: hidden; }
.chat-messages { flex: 1; overflow-y: auto; padding: 0.75rem; }
.chat-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); font-size: 0.85rem; }
.loading-messages { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; color: var(--text-secondary); font-size: 0.85rem; }
.spinner-small { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; }
.spinner-sm { width: 16px; height: 16px; border: 2px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.streaming-hint { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; color: var(--text-secondary); font-size: 0.8rem; }
/* Agent panel */
.agent-panel { width: 260px; border-left: 1px solid var(--border-light); display: flex; flex-direction: column; flex-shrink: 0; overflow-y: auto; }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border-light); }
.panel-header h4 { margin: 0; font-size: 0.8rem; font-weight: 600; }
.panel-agents { padding: 0.4rem 0.75rem; }
.agent-row { display: flex; align-items: center; gap: 0.4rem; padding: 0.4rem; border-radius: 6px; cursor: pointer; transition: background 0.15s; }
.agent-row:hover, .agent-row.editing { background: var(--bg-secondary); }
.agent-dot { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; font-weight: 700; color: white; flex-shrink: 0; }
.agent-info { flex: 1; min-width: 0; }
.agent-name { font-size: 0.75rem; font-weight: 600; display: block; }
.agent-model { font-size: 0.6rem; color: var(--text-secondary); display: block; }
.btn-del-agent { background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 2px; opacity: 0; transition: all 0.15s; }
.agent-row:hover .btn-del-agent { opacity: 1; }
.btn-del-agent:hover { color: #ef4444; }
.no-agents { text-align: center; padding: 1rem 0; color: var(--text-secondary); font-size: 0.75rem; }
.no-agents p { margin: 0 0 0.4rem; }
/* Agent form */
.agent-form { border-top: 1px solid var(--border-light); background: var(--bg-secondary); }
.agent-form-header { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0.75rem; font-size: 0.75rem; font-weight: 600; }
.btn-close-xs { background: none; border: none; font-size: 1.1rem; cursor: pointer; color: var(--text-secondary); padding: 0; line-height: 1; }
.agent-form-body { padding: 0 0.75rem 0.6rem; }
.form-row { margin-bottom: 0.4rem; display: flex; gap: 0.3rem; }
.fi { width: 100%; padding: 0.35rem 0.5rem; border: 1px solid var(--border-light); border-radius: 5px; background: var(--bg-primary); color: var(--text-primary); font-size: 0.75rem; }
.fi:focus { outline: none; border-color: var(--accent-primary); }
.fi-color { width: 28px !important; height: 28px; padding: 1px !important; border-radius: 5px; cursor: pointer; flex-shrink: 0; }
textarea.fi { resize: vertical; }
/* Panel room info */
.panel-room-info { border-top: 1px solid var(--border-light); padding: 0.6rem 0.75rem; margin-top: auto; }
.info-item { margin-bottom: 0.3rem; }
.info-label { font-size: 0.6rem; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
.info-value { font-size: 0.75rem; color: var(--text-primary); margin: 0.1rem 0 0; line-height: 1.4; }
/* ===== Buttons ===== */
.btn-xs { padding: 0.2rem 0.5rem; font-size: 0.7rem; border-radius: 4px; border: none; cursor: pointer; }
.btn-xs.btn-primary { background: var(--accent-primary); color: white; }
.btn-xs.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-xs.btn-secondary { background: var(--bg-secondary); color: var(--text-secondary); border: 1px solid var(--border-light); }
.btn-primary { padding: 0.5rem 1rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
.btn-primary:hover { background: var(--accent-primary-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { padding: 0.5rem 1rem; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-light); border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
/* ===== Modal ===== */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg-primary); border-radius: 16px; width: 560px; max-width: 95vw; max-height: 85vh; overflow-y: auto; }
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-light); }
.modal-head h3 { margin: 0; font-size: 1rem; }
.btn-close { background: none; border: none; font-size: 1.4rem; cursor: pointer; color: var(--text-secondary); padding: 0; line-height: 1; }
.modal-body { padding: 1.25rem; }
.modal-foot { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 1rem 1.25rem; border-top: 1px solid var(--border-light); }
.fg { margin-bottom: 1rem; }
.fg label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--text-primary); margin-bottom: 0.3rem; }
.fg input, .fg textarea, .fg select { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-light); border-radius: 8px; background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem; box-sizing: border-box; }
.fg textarea { resize: vertical; }
.agents-config { margin-top: 0.5rem; }
.agents-config-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
.agents-config-header label { font-size: 0.8rem; font-weight: 600; }
.agent-cfg-item { background: var(--bg-secondary); border-radius: 8px; padding: 0.6rem; margin-bottom: 0.5rem; }
.agent-cfg-row { display: flex; gap: 0.4rem; align-items: center; margin-bottom: 0.3rem; }
.agent-cfg-name { flex: 1; padding: 0.35rem 0.5rem; border: 1px solid var(--border-light); border-radius: 5px; background: var(--bg-primary); color: var(--text-primary); font-size: 0.8rem; }
.agent-cfg-color { width: 28px !important; height: 28px; padding: 1px !important; border-radius: 5px; cursor: pointer; }
.agent-cfg-provider { flex: 1; padding: 0.35rem 0.5rem; border: 1px solid var(--border-light); border-radius: 5px; background: var(--bg-primary); color: var(--text-primary); font-size: 0.8rem; }
.agent-cfg-prompt { width: 100%; padding: 0.35rem 0.5rem; border: 1px solid var(--border-light); border-radius: 5px; background: var(--bg-primary); color: var(--text-primary); font-size: 0.8rem; resize: vertical; box-sizing: border-box; }
.btn-rm-agent { background: none; border: none; color: #ef4444; font-size: 1.1rem; cursor: pointer; padding: 0 2px; }
</style>

View File

@ -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

View File

@ -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,
@ -62,7 +60,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 +72,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 +111,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 +138,148 @@ 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 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 configuration in a chat room"""
__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)
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")
def to_dict(self):
return {
"id": self.id,
"room_id": self.room_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
}

View File

@ -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
api_router = APIRouter()
@ -12,3 +12,4 @@ 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)

413
luxx/routes/chat_rooms.py Normal file
View File

@ -0,0 +1,413 @@
"""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, 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):
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):
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):
# Resolve model from provider if not specified
model = agent_cfg.model
provider_id = agent_cfg.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:
# Use default provider
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,
name=agent_cfg.name,
role=agent_cfg.role,
provider_id=provider_id,
model=model,
system_prompt=agent_cfg.system_prompt,
color=agent_cfg.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()
model = data.model
provider_id = data.provider_id
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,
name=data.name,
role=data.role,
provider_id=provider_id,
model=model,
system_prompt=data.system_prompt,
color=data.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")

288
luxx/services/chat_room.py Normal file
View File

@ -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()