refactor: 增加多agent设置

This commit is contained in:
ViperEkura 2026-04-25 20:33:31 +08:00
parent 71960aed6d
commit 617b8e67fa
13 changed files with 2568 additions and 229 deletions

View File

@ -32,6 +32,10 @@ const navItems = [
path: '/conversations', 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>` 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', 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>` 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> <template>
<div class="message-bubble" :class="[message.role]"> <div class="message-bubble" :class="[message.role, { 'room-msg': message.room_id }]">
<div v-if="message.role === 'user'" class="avatar">user</div> <div class="avatar" :style="avatarStyle">{{ avatarText }}</div>
<div v-else class="avatar">Luxx</div>
<div class="message-container"> <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 --> <!-- File attachments list -->
<div v-if="message.attachments && message.attachments.length > 0" class="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"> <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 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 renderedContent = computed(() => {
const text = props.message.content || props.message.text || '' const text = props.message.content || props.message.text || ''
if (!text) return '' if (!text) return ''
@ -94,33 +111,44 @@ const regenerateIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="no
</script> </script>
<style scoped> <style scoped>
/* ============ Attachments ============ */
.attachments-list { .attachments-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 8px;
margin-bottom: 8px; margin-bottom: 12px;
width: 100%; width: 100%;
} }
.attachment-item { .attachment-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
padding: 4px 10px; padding: 6px 12px;
background: var(--bg-code); background: linear-gradient(135deg, var(--bg-code) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: 6px; border-radius: 10px;
font-size: 12px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
transition: all 0.2s ease;
cursor: pointer;
}
.attachment-item:hover {
border-color: var(--attachment-color);
box-shadow: 0 2px 8px rgba(202, 138, 4, 0.15);
transform: translateY(-1px);
} }
.attachment-icon { .attachment-icon {
background: var(--attachment-bg); background: var(--attachment-bg);
color: var(--attachment-color); color: var(--attachment-color);
padding: 2px 6px; padding: 3px 8px;
border-radius: 4px; border-radius: 6px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.attachment-name { .attachment-name {
@ -128,12 +156,156 @@ const regenerateIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="no
font-weight: 500; font-weight: 500;
} }
/* ============ Message Footer ============ */
.message-footer { .message-footer {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
padding: 6px 0 0; padding: 8px 0 0;
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
border-top: 1px solid transparent;
margin-top: 8px;
padding-top: 10px;
}
.message-bubble:hover .message-footer {
border-top-color: var(--border-light);
}
.token-item {
font-size: 11px;
color: var(--text-tertiary);
font-family: var(--mono);
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 6px;
transition: all 0.2s ease;
}
.token-item:hover {
background: var(--accent-primary-light);
color: var(--accent-primary);
}
.message-time {
font-size: 11px;
color: var(--text-tertiary);
font-family: var(--mono);
}
/* ============ Sender Info ============ */
.sender-info {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.sender-name {
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.3px;
transition: all 0.2s ease;
}
.sender-name:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.round-tag {
font-size: 0.7rem;
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
padding: 2px 8px;
border-radius: 8px;
color: var(--text-secondary);
font-weight: 600;
font-family: var(--mono);
border: 1px solid var(--border-light);
transition: all 0.2s ease;
}
.round-tag:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
/* ============ Message Body Enhancements ============ */
.message-body {
position: relative;
}
.message-body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-primary-hover));
border-radius: 12px 12px 0 0;
opacity: 0;
transition: opacity 0.3s ease;
}
.message-bubble:hover .message-body::before {
opacity: 1;
}
/* ============ Room Message Special Styles ============ */
.message-bubble.room-msg {
animation: fadeInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ============ Avatar Enhancements ============ */
:deep(.avatar) {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15);
}
.message-bubble:hover :deep(.avatar) {
transform: scale(1.1) rotate(5deg);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
}
/* ============ Content Styling ============ */
.message-content {
line-height: 1.7;
}
/* ============ Ghost Button Enhancements ============ */
:deep(.ghost-btn) {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 8px;
padding: 6px 8px;
}
:deep(.ghost-btn:hover) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
:deep(.ghost-btn.success:hover) {
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.2);
}
:deep(.ghost-btn.danger:hover) {
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
}
:deep(.ghost-btn.accent:hover) {
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
} }
</style> </style>

View File

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

View File

@ -44,7 +44,9 @@ export const authAPI = {
logout: () => api.post('/auth/logout'), logout: () => api.post('/auth/logout'),
getMe: () => api.get('/auth/me'), getMe: () => api.get('/auth/me'),
listUsers: () => api.get('/auth/users'), listUsers: () => api.get('/auth/users'),
updateUserPermission: (userId, data) => api.put(`/auth/users/${userId}`, data) updateUserPermission: (userId, data) => api.put(`/auth/users/${userId}`, data),
getSettings: () => api.get('/auth/settings'),
updateSettings: (data) => api.put('/auth/settings', data)
} }
// ============ 会话接口 ============ // ============ 会话接口 ============
@ -84,4 +86,31 @@ export const providersAPI = {
test: (id) => api.post(`/providers/${id}/test`) test: (id) => api.post(`/providers/${id}/test`)
} }
// ============ Agent 接口 ============
export const agentsAPI = {
list: () => api.get('/agents/'),
create: (data) => api.post('/agents/', data),
get: (id) => api.get(`/agents/${id}`),
update: (id, data) => api.put(`/agents/${id}`, data),
delete: (id) => api.delete(`/agents/${id}`)
}
// ============ 聊天室接口 ============
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 export default api

File diff suppressed because it is too large Load Diff

View File

@ -10,26 +10,34 @@
<span class="section-icon">👤</span> <span class="section-icon">👤</span>
<span class="section-text">用户信息</span> <span class="section-text">用户信息</span>
</div> </div>
<div class="settings-card"> <div v-if="loadingUser" class="loading-small"><div class="spinner-small"></div>加载中...</div>
<div v-if="loadingUser" class="loading-small"><div class="spinner-small"></div>加载中...</div> <div v-else class="settings-table-container">
<template v-else> <table class="settings-table">
<div class="settings-row"> <thead>
<div class="row-label"> <tr>
<span class="label-icon">用户名</span> <th class="setting-key-col">项目</th>
</div> <th></th>
<div class="row-value">{{ userForm.username || '-' }}</div> </tr>
</div> </thead>
<div class="settings-row"> <tbody>
<div class="row-label"> <tr>
<span class="label-icon">邮箱</span> <td class="setting-key-col"><div class="setting-label">用户名</div></td>
</div> <td>{{ userForm.username || '-' }}</td>
<div class="row-value">{{ userForm.email || '-' }}</div> </tr>
</div> <tr>
<div class="settings-row actions"> <td class="setting-key-col"><div class="setting-label">邮箱</div></td>
<button @click="openUserModal" class="btn-action">编辑资料</button> <td>{{ userForm.email || '-' }}</td>
<button @click="handleLogout" class="btn-action btn-logout">退出登录</button> </tr>
</div> </tbody>
</template> <tfoot>
<tr>
<td colspan="2" class="table-footer">
<button @click="openUserModal" class="btn-op">编辑资料</button>
<button @click="handleLogout" class="btn-op btn-danger">退出登录</button>
</td>
</tr>
</tfoot>
</table>
</div> </div>
</div> </div>
@ -39,19 +47,29 @@
<span class="section-icon">🎨</span> <span class="section-icon">🎨</span>
<span class="section-text">外观</span> <span class="section-text">外观</span>
</div> </div>
<div class="settings-card"> <div class="settings-table-container">
<div class="settings-row"> <table class="settings-table">
<div class="row-label"> <thead>
<span class="row-title">夜间模式</span> <tr>
<span class="row-desc">切换深色/浅色主题</span> <th class="setting-key-col">设置项</th>
</div> <th></th>
<div class="row-value"> </tr>
<label class="switch" @click.prevent="toggleTheme"> </thead>
<input type="checkbox" v-model="isDark" /> <tbody>
<span class="slider"></span> <tr>
</label> <td class="setting-key-col">
</div> <div class="setting-label">夜间模式</div>
</div> <div class="setting-desc">切换深色/浅色主题</div>
</td>
<td>
<label class="switch" @click.prevent="toggleTheme">
<input type="checkbox" v-model="isDark" />
<span class="slider"></span>
</label>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
@ -61,65 +79,67 @@
<span class="section-icon">🤖</span> <span class="section-icon">🤖</span>
<span class="section-text">模型设置</span> <span class="section-text">模型设置</span>
</div> </div>
<div class="settings-card"> <div class="settings-table-container">
<div class="settings-row"> <table class="settings-table">
<div class="row-label"> <thead>
<span class="row-title">默认 Provider</span> <tr>
<span class="row-desc">选择默认使用的 LLM Provider</span> <th class="setting-key-col">设置项</th>
</div> <th></th>
<div class="row-value"> </tr>
<select v-model="modelSettings.default_provider" class="inline-select" @change="saveDefaultProvider"> </thead>
<option :value="null" disabled>选择 Provider</option> <tbody>
<option v-for="p in providers" :key="p.id" :value="p.id"> <tr>
{{ p.name }} ({{ p.default_model }}) <td class="setting-key-col">
</option> <div class="setting-label">默认 Provider</div>
</select> <div class="setting-desc">选择默认使用的 LLM Provider</div>
</div> </td>
</div> <td>
<div class="settings-row"> <select v-model="modelSettings.default_provider_id" class="inline-select" @change="saveDefaultProvider">
<div class="row-label">温度 (Temperature)</div> <option :value="null" disabled>选择 Provider</option>
<div class="row-value"> <option v-for="p in providers" :key="p.id" :value="p.id">
<input v-model.number="modelSettings.temperature" type="number" min="0" max="2" step="0.1" class="inline-input" /> {{ p.name }} ({{ p.default_model }})
</div> </option>
</div> </select>
<div class="settings-row"> </td>
<div class="row-label">最大 Tokens</div> </tr>
<div class="row-value"> <tr>
<input v-model.number="modelSettings.max_tokens" type="number" min="100" max="32000" class="inline-input" /> <td class="setting-key-col"><div class="setting-label">温度 (Temperature)</div></td>
</div> <td><input v-model.number="modelSettings.temperature" type="number" min="0" max="2" step="0.1" class="inline-input" /></td>
</div> </tr>
<div class="settings-row"> <tr>
<div class="row-label"> <td class="setting-key-col"><div class="setting-label">最大 Tokens</div></td>
<span class="row-title">推理模式</span> <td><input v-model.number="modelSettings.max_tokens" type="number" min="100" max="32000" class="inline-input" /></td>
<span class="row-desc">使用 CoT 推理消耗更多 token 但更准确</span> </tr>
</div> <tr>
<div class="row-value"> <td class="setting-key-col">
<label class="switch" @click.prevent="modelSettings.thinking_enabled = !modelSettings.thinking_enabled"> <div class="setting-label">推理模式</div>
<input v-model="modelSettings.thinking_enabled" type="checkbox" /> <div class="setting-desc">使用 CoT 推理消耗更多 token 但更准确</div>
<span class="slider"></span> </td>
</label> <td>
</div> <label class="switch" @click.prevent="modelSettings.thinking_enabled = !modelSettings.thinking_enabled">
</div> <input v-model="modelSettings.thinking_enabled" type="checkbox" />
<div class="settings-row actions"> <span class="slider"></span>
<button @click="saveModelSettings" class="btn-primary">保存设置</button> </label>
</div> </td>
</div> </tr>
</div> <tr>
<td class="setting-key-col">
<!-- 系统提示词 --> <div class="setting-label">系统提示词</div>
<div class="settings-section"> <div class="setting-desc">设置默认系统提示词可在新建会话时覆盖</div>
<div class="section-title"> </td>
<span class="section-icon">💬</span> <td>
<span class="section-text">系统提示词</span> <textarea v-model="modelSettings.system_prompt" rows="3" placeholder="You are a helpful assistant." class="table-textarea"></textarea>
</div> </td>
<div class="settings-card"> </tr>
<div class="settings-row full"> </tbody>
<textarea v-model="modelSettings.system_prompt" rows="4" placeholder="You are a helpful assistant."></textarea> <tfoot>
<span class="hint-block">设置默认系统提示词可在新建会话时覆盖</span> <tr>
</div> <td colspan="2" class="table-footer">
<div class="settings-row actions"> <button @click="saveModelSettings" class="btn-primary">保存设置</button>
<button @click="saveSystemPrompt" class="btn-primary">保存提示词</button> </td>
</div> </tr>
</tfoot>
</table>
</div> </div>
</div> </div>
@ -132,7 +152,7 @@
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div> <div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div> <div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="providers.length" class="settings-table-container"> <div v-else class="settings-table-container">
<table class="settings-table"> <table class="settings-table">
<thead> <thead>
<tr> <tr>
@ -174,15 +194,15 @@
</td> </td>
</tr> </tr>
</tbody> </tbody>
<tfoot>
<tr>
<td colspan="4" class="table-footer">
<button @click="showModal = true" class="btn-primary">+ 添加 Provider</button>
</td>
</tr>
</tfoot>
</table> </table>
</div> </div>
<!-- 添加按钮 -->
<div class="settings-card">
<div class="settings-row actions">
<button @click="showModal = true" class="btn-primary">+ 添加 Provider</button>
</div>
</div>
</div> </div>
<!-- 用户管理 (仅管理员可见) --> <!-- 用户管理 (仅管理员可见) -->
@ -342,12 +362,13 @@ const loadingUsers = ref(false)
const usersError = ref('') const usersError = ref('')
const modelSettings = ref({ const modelSettings = ref({
default_provider: null, default_provider_id: null,
temperature: 0.7, temperature: 0.7,
max_tokens: 8192, max_tokens: 8192,
thinking_enabled: false, thinking_enabled: false,
system_prompt: 'You are a helpful assistant.' system_prompt: 'You are a helpful assistant.'
}) })
const settingsLoaded = ref(false)
const fetchUserInfo = async () => { const fetchUserInfo = async () => {
loadingUser.value = true loadingUser.value = true
@ -391,13 +412,18 @@ const updateUser = async () => {
} }
const saveModelSettings = async () => { const saveModelSettings = async () => {
localStorage.setItem('modelSettings', JSON.stringify(modelSettings.value)) try {
alert('模型设置已保存') await authAPI.updateSettings({
} default_provider_id: modelSettings.value.default_provider_id,
temperature: modelSettings.value.temperature,
const saveSystemPrompt = async () => { max_tokens: modelSettings.value.max_tokens,
localStorage.setItem('defaultSystemPrompt', modelSettings.value.system_prompt) thinking_enabled: modelSettings.value.thinking_enabled,
alert('系统提示词已保存') system_prompt: modelSettings.value.system_prompt
})
alert('设置已保存')
} catch (e) {
alert('保存失败: ' + e.message)
}
} }
const providers = ref([]) const providers = ref([])
@ -421,16 +447,31 @@ const fetchProviders = async () => {
const res = await providersAPI.list() const res = await providersAPI.list()
if (res.success) { if (res.success) {
providers.value = res.data.providers || [] 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) else throw new Error(res.message)
} catch (e) { error.value = e.message } } catch (e) { error.value = e.message }
finally { loading.value = false } 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 = () => { const closeModal = () => {
showModal.value = false showModal.value = false
editing.value = null editing.value = null
@ -526,18 +567,10 @@ const toggleEnabled = async (p) => {
const saveDefaultProvider = async () => { const saveDefaultProvider = async () => {
try { try {
// Provider await authAPI.updateSettings({ default_provider_id: modelSettings.value.default_provider_id })
for (const p of providers.value) { } catch (e) {
if (p.is_default && p.id !== modelSettings.value.default_provider) { alert('设置默认 Provider 失败: ' + e.message)
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) }
} }
const fetchUsers = async () => { const fetchUsers = async () => {
@ -547,14 +580,14 @@ const fetchUsers = async () => {
const res = await authAPI.listUsers() const res = await authAPI.listUsers()
if (res.success) { if (res.success) {
users.value = res.data.users || [] 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 isAdmin.value = false
} else { } else {
usersError.value = res.message || '获取用户列表失败' usersError.value = res.message || res.detail || '获取用户列表失败'
} }
} catch (e) { } catch (e) {
// 403
isAdmin.value = false isAdmin.value = false
} finally {
loadingUsers.value = false loadingUsers.value = false
} }
} }
@ -577,6 +610,7 @@ const getPermissionName = (level) => {
onMounted(() => { onMounted(() => {
fetchUserInfo() fetchUserInfo()
fetchSettings()
fetchProviders() fetchProviders()
fetchUsers() fetchUsers()
@ -588,19 +622,6 @@ onMounted(() => {
isAdmin.value = user.permission_level === 4 isAdmin.value = user.permission_level === 4
} catch (e) {} } 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
}
}) })
</script> </script>
@ -614,25 +635,9 @@ onMounted(() => {
.section-icon { font-size: 1rem; } .section-icon { font-size: 1rem; }
.section-text { font-size: 1rem; font-weight: 700; color: var(--text-primary); } .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-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; } .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; } .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; } .info-col { width: 60%; min-width: 200px; }
.switch-col { text-align: center; width: 80px; } .switch-col { text-align: center; width: 80px; }
.ops-col { width: 15%; min-width: 180px; text-align: center; } .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 单元格 */
.provider-name { font-weight: 600; font-size: 0.9rem; color: var(--text-primary); } .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 { 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; } .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; } .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; } .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 { color: var(--danger-color); border-color: var(--danger-bg); }
.btn-op.btn-danger:hover { background: 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 { 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); } .btn-primary:hover { background: var(--accent-primary-hover); }

View File

@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan manager""" """Application lifespan manager"""
# Import all models to ensure they are registered with Base # 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() init_db()
# Create default test user if not exists # 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) id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(100), 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) base_url: Mapped[str] = mapped_column(String(500), nullable=False)
api_key: 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") 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) is_default: Mapped[bool] = mapped_column(Boolean, default=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now) created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now) updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
# Relationships
user: Mapped["User"] = relationship("User", backref="llm_providers") user: Mapped["User"] = relationship("User", backref="llm_providers")
def to_dict(self, include_key: bool = False): def to_dict(self, include_key: bool = False):
"""Convert to dictionary, optionally include API key"""
result = { result = {
"id": self.id, "id": self.id,
"user_id": self.user_id, "user_id": self.user_id,
@ -51,6 +49,37 @@ class LLMProvider(Base):
return result 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): class Project(Base):
"""Project model""" """Project model"""
__tablename__ = "projects" __tablename__ = "projects"
@ -62,7 +91,6 @@ class Project(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now) created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now) updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
# Relationships
user: Mapped["User"] = relationship("User", backref="projects") 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) email: Mapped[Optional[str]] = mapped_column(String(120), unique=True, nullable=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(String(20), default="user") 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 permission_level: Mapped[int] = mapped_column(Integer, default=1)
workspace_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # 用户工作空间路径 workspace_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now) created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
# Relationships
conversations: Mapped[List["Conversation"]] = relationship( conversations: Mapped[List["Conversation"]] = relationship(
"Conversation", back_populates="user", cascade="all, delete-orphan" "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) created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now) updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="conversations") user: Mapped["User"] = relationship("User", back_populates="conversations")
provider: Mapped[Optional["LLMProvider"]] = relationship("LLMProvider") provider: Mapped[Optional["LLMProvider"]] = relationship("LLMProvider")
messages: Mapped[List["Message"]] = relationship( 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): def to_dict(self):
@ -142,84 +169,184 @@ class Conversation(Base):
class Message(Base): class Message(Base):
"""Message model """Message model
content 字段统一使用 JSON 格式存储 同时服务于普通会话和聊天室
- 普通会话conversation_id 非空room_id 为空
**User 消息** - 聊天室room_id 非空conversation_id 为空sender_name/sender_color/round_number 有值
{
"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 字段
""" """
__tablename__ = "messages" __tablename__ = "messages"
id: Mapped[str] = mapped_column(String(64), primary_key=True) id: Mapped[str] = mapped_column(String(64), primary_key=True)
conversation_id: Mapped[str] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=False) conversation_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("conversations.id"), nullable=True)
role: Mapped[str] = mapped_column(String(16), nullable=False) # user, assistant, system, tool 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="") content: Mapped[str] = mapped_column(Text, nullable=False, default="")
token_count: Mapped[int] = mapped_column(Integer, default=0) 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) created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
# Relationships conversation: Mapped[Optional["Conversation"]] = relationship("Conversation", back_populates="messages")
conversation: Mapped["Conversation"] = relationship("Conversation", back_populates="messages") room: Mapped[Optional["ChatRoom"]] = relationship("ChatRoom", back_populates="messages")
def to_dict(self): def to_dict(self):
"""Convert to dictionary, extracting process_steps for frontend"""
import json import json
result = { result = {
"id": self.id, "id": self.id,
"conversation_id": self.conversation_id, "conversation_id": self.conversation_id,
"room_id": self.room_id,
"role": self.role, "role": self.role,
"token_count": self.token_count, "token_count": self.token_count,
"created_at": self.created_at.isoformat() if self.created_at else None "created_at": self.created_at.isoformat() if self.created_at else None
} }
# Parse usage JSON
if self.usage: if self.usage:
try: try:
result["usage"] = json.loads(self.usage) result["usage"] = json.loads(self.usage)
except json.JSONDecodeError: except json.JSONDecodeError:
result["usage"] = None 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: try:
content_obj = json.loads(self.content) if self.content else {} content_obj = json.loads(self.content) if self.content else {}
except json.JSONDecodeError: except json.JSONDecodeError:
# Legacy plain text content
result["content"] = self.content result["content"] = self.content
result["text"] = self.content result["text"] = self.content
result["attachments"] = [] result["attachments"] = []
result["process_steps"] = [] result["process_steps"] = []
return result return result
# Extract steps as process_steps for frontend rendering
steps = content_obj.get("steps", []) steps = content_obj.get("steps", [])
result["process_steps"] = steps result["process_steps"] = steps
# Extract text from steps (concatenate all text type steps)
text_content = "".join( text_content = "".join(
s.get("content", "") for s in steps s.get("content", "") for s in steps
if s.get("type") == "text" if s.get("type") == "text"
) )
result["text"] = text_content result["text"] = text_content
result["content"] = text_content # Alias for convenience result["content"] = text_content
# Extract attachments
result["attachments"] = content_obj.get("attachments", []) result["attachments"] = content_obj.get("attachments", [])
return result 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
}

View File

@ -1,7 +1,7 @@
"""API routes module""" """API routes module"""
from fastapi import APIRouter 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() api_router = APIRouter()
@ -12,3 +12,5 @@ api_router.include_router(conversations.router)
api_router.include_router(messages.router) api_router.include_router(messages.router)
api_router.include_router(tools.router) api_router.include_router(tools.router)
api_router.include_router(providers.router) api_router.include_router(providers.router)
api_router.include_router(chat_rooms.router)
api_router.include_router(agents.router)

143
luxx/routes/agents.py Normal file
View File

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

View File

@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel
from luxx.database import get_db from luxx.database import get_db
from luxx.models import User from luxx.models import User, UserSettings
from luxx.utils.helpers import ( from luxx.utils.helpers import (
hash_password, hash_password,
verify_password, verify_password,
@ -49,6 +49,15 @@ class UserPermissionUpdate(BaseModel):
permission_level: int 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): class TokenResponse(BaseModel):
"""Token response model""" """Token response model"""
access_token: str access_token: str
@ -167,3 +176,40 @@ def update_user(user_id: int, data: UserPermissionUpdate, admin_user: User = Dep
db.commit() db.commit()
return success_response(data=user.to_dict(), message="User permission updated") 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")

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

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

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

@ -0,0 +1,318 @@
"""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 with streaming output."""
# 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)
# Create placeholder message for streaming updates
msg_id = generate_id("msg")
accumulated_content = ""
# Yield streaming start event with placeholder
yield sse_event("message_start", {
"id": msg_id,
"room_id": room_id,
"role": "assistant",
"sender_name": agent.name,
"sender_color": agent.color,
"round_number": round_num
})
# Stream LLM response
try:
async for delta in llm.stream_call(
model=model,
messages=messages,
temperature=0.7,
max_tokens=max_tokens or 2000
):
if delta.content:
accumulated_content += delta.content
yield sse_event("message_chunk", {
"id": msg_id,
"content": delta.content,
"accumulated": accumulated_content
})
if delta.is_complete:
break
except Exception as e:
logger.error(f"LLM stream failed for {agent.name}: {e}")
yield sse_event("agent_error", {
"agent": agent.name,
"error": f"LLM stream failed: {str(e)}"
})
await llm.close()
return
# Estimate token count
token_count = len(accumulated_content) // 4
# Build steps for storage
steps = [{"id": "step-0", "index": 0, "type": "text", "content": accumulated_content}]
content_json = {"steps": steps}
# Save complete message to DB
msg = Message(
id=msg_id,
room_id=room_id,
role="assistant",
content=json.dumps(content_json, ensure_ascii=False),
token_count=token_count,
sender_name=agent.name,
sender_color=agent.color,
round_number=round_num
)
db.add(msg)
db.commit()
# Update history
history.append({"role": "assistant", "content": accumulated_content, "sender": agent.name})
# Yield message end event
yield sse_event("message_end", {
"id": msg_id,
"content": accumulated_content,
"token_count": token_count
})
# Also yield the complete message for consistency
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()