refactor: 优化项目架构

This commit is contained in:
ViperEkura 2026-04-21 10:51:09 +08:00
parent 08d2a2be98
commit e5c0720650
53 changed files with 3158 additions and 625 deletions

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { useAuth } from './utils/useAuth.js' import { useAuth } from './composables/useAuth.js'
import AppHeader from './components/AppHeader.vue' import AppHeader from './components/AppHeader.vue'
const { isLoggedIn } = useAuth() const { isLoggedIn } = useAuth()

View File

@ -72,14 +72,11 @@ export function createSSEStream(url, body, { onProcessStep, onDone, onError }) {
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
// 处理数据
if (value) { if (value) {
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
} }
// 流结束时,先处理 buffer 中的剩余数据,再 break
if (done) { if (done) {
// 处理 buffer 中剩余的数据
const lines = buffer.split('\n') const lines = buffer.split('\n')
buffer = '' buffer = ''
@ -99,12 +96,11 @@ export function createSSEStream(url, body, { onProcessStep, onDone, onError }) {
onError(data.content) onError(data.content)
} }
} catch (e) { } catch (e) {
console.error('SSE parse error:', e, 'line:', line) console.error('SSE parse error:', e)
} }
} }
} }
// 如果没有收到 done 事件,触发错误
if (!completed && onError) { if (!completed && onError) {
onError('stream ended without done event') onError('stream ended without done event')
} }
@ -129,14 +125,11 @@ export function createSSEStream(url, body, { onProcessStep, onDone, onError }) {
} else if (currentEvent === 'error' && onError) { } else if (currentEvent === 'error' && onError) {
onError(data.content) onError(data.content)
} }
} catch (e) { } catch (e) {}
// 忽略解析错误
}
} }
} }
} }
// 流结束但没有收到 done 事件,才报错
if (!completed && onError) { if (!completed && onError) {
onError('stream ended unexpectedly') onError('stream ended unexpectedly')
} }
@ -152,7 +145,6 @@ export function createSSEStream(url, body, { onProcessStep, onDone, onError }) {
} }
// ============ 认证接口 ============ // ============ 认证接口 ============
export const authAPI = { export const authAPI = {
login: (data) => api.post('/auth/login', data), login: (data) => api.post('/auth/login', data),
register: (data) => api.post('/auth/register', data), register: (data) => api.post('/auth/register', data),
@ -163,7 +155,6 @@ export const authAPI = {
} }
// ============ 会话接口 ============ // ============ 会话接口 ============
export const conversationsAPI = { export const conversationsAPI = {
list: (params) => api.get('/conversations/', { params }), list: (params) => api.get('/conversations/', { params }),
create: (data) => api.post('/conversations/', data), create: (data) => api.post('/conversations/', data),
@ -173,12 +164,9 @@ export const conversationsAPI = {
} }
// ============ 消息接口 ============ // ============ 消息接口 ============
export const messagesAPI = { export const messagesAPI = {
list: (conversationId, params) => api.get('/messages/', { params: { conversation_id: conversationId, ...params } }), list: (conversationId, params) => api.get('/messages/', { params: { conversation_id: conversationId, ...params } }),
send: (data) => api.post('/messages/', data), send: (data) => api.post('/messages/', data),
// 发送消息(流式)
sendStream: (data, callbacks) => { sendStream: (data, callbacks) => {
return createSSEStream('/messages/stream', { return createSSEStream('/messages/stream', {
conversation_id: data.conversation_id, conversation_id: data.conversation_id,
@ -187,12 +175,10 @@ export const messagesAPI = {
enabled_tools: data.enabled_tools || [] enabled_tools: data.enabled_tools || []
}, callbacks) }, callbacks)
}, },
delete: (id) => api.delete(`/messages/${id}`) delete: (id) => api.delete(`/messages/${id}`)
} }
// ============ 工具接口 ============ // ============ 工具接口 ============
export const toolsAPI = { export const toolsAPI = {
list: (params) => api.get('/tools/', { params }), list: (params) => api.get('/tools/', { params }),
get: (name) => api.get(`/tools/${name}`), get: (name) => api.get(`/tools/${name}`),
@ -200,7 +186,6 @@ export const toolsAPI = {
} }
// ============ LLM Provider 接口 ============ // ============ LLM Provider 接口 ============
export const providersAPI = { export const providersAPI = {
list: () => api.get('/providers/'), list: () => api.get('/providers/'),
create: (data) => api.post('/providers/', data), create: (data) => api.post('/providers/', data),
@ -210,4 +195,101 @@ export const providersAPI = {
test: (id) => api.post(`/providers/${id}/test`) test: (id) => api.post(`/providers/${id}/test`)
} }
// ============ Agent 接口 ============
export const agentsAPI = {
list: (params = {}) => api.get('/agents/', { params }),
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}`)
}
// ============ Chat Room 接口 ============
export const roomsAPI = {
list: () => api.get('/chat-rooms/'),
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}`),
listAgents: (roomId) => api.get(`/chat-rooms/${roomId}/agents`),
addAgent: (roomId, agentId) => api.post(`/chat-rooms/${roomId}/agents`, { agent_id: agentId }),
removeAgent: (roomId, agentId) => api.delete(`/chat-rooms/${roomId}/agents/${agentId}`),
listMessages: (roomId, params = {}) => api.get(`/chat-rooms/${roomId}/messages`, { params })
}
// ============ WebSocket ============
export function createRoomWS(roomId, callbacks = {}) {
const token = localStorage.getItem('access_token')
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/chat-room/${roomId}${token ? '?token=' + token : ''}`
const ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log('WebSocket connected to room:', roomId)
callbacks.onConnect?.()
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
const eventName = msg.event
switch (eventName) {
case 'connected':
callbacks.onConnected?.(msg.data)
break
case 'history':
callbacks.onHistory?.(msg.data.messages)
break
case 'agents':
callbacks.onAgentsUpdate?.(msg.data.agents)
break
case 'message':
callbacks.onMessage?.(msg.data)
break
case 'typing':
callbacks.onTyping?.(msg.data)
break
case 'process_step':
case 'done':
case 'error':
callbacks.onStream?.(eventName, msg.data, msg.agent_id, msg.agent_name)
break
case 'system':
callbacks.onSystem?.(msg.data)
break
default:
console.log('Unknown event:', eventName, msg)
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e)
}
}
ws.onerror = (error) => {
console.error('WebSocket error:', error)
callbacks.onError?.(error)
}
ws.onclose = () => {
console.log('WebSocket disconnected from room:', roomId)
callbacks.onDisconnect?.()
}
return {
send: (action, data = {}) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action, ...data }))
}
},
sendMessage: (content, userId = 'user', userName = 'User') => {
ws.send(JSON.stringify({ action: 'send_message', content, user_id: userId, user_name: userName }))
},
ping: () => {
ws.send(JSON.stringify({ action: 'ping' }))
},
close: () => ws.close()
}
}
export default api export default api

View File

@ -32,10 +32,18 @@ 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: '/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>`
}, },
{
path: '/agents',
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="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"></path><path d="M8.5 8.5v.01"></path><path d="M16 15.5v.01"></path><path d="M12 12v.01"></path><path d="M11 17v.01"></path><path d="M7 14v.01"></path></svg>`
},
{ {
path: '/settings', path: '/settings',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></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"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`

View File

@ -48,8 +48,8 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { renderMarkdown } from '../utils/markdown.js' import { renderMarkdown } from '@/utils/markdown.js'
import { formatNumber } from '../utils/useFormatters.js' import { formatNumber } from '@/composables/useFormatters.js'
import ProcessBlock from './ProcessBlock.vue' import ProcessBlock from './ProcessBlock.vue'
const props = defineProps({ const props = defineProps({

View File

@ -69,7 +69,7 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { renderMarkdown } from '../utils/markdown.js' import { renderMarkdown } from '@/utils/markdown.js'
const props = defineProps({ const props = defineProps({
processSteps: { type: Array, default: () => [] }, processSteps: { type: Array, default: () => [] },

View File

@ -1,7 +1,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { conversationsAPI, messagesAPI, toolsAPI, providersAPI } from './api.js' import { conversationsAPI, messagesAPI, toolsAPI, providersAPI } from '@/api'
import { streamManager } from './streamManager.js' import { streamManager } from '@/utils/streamManager.js'
import { useStreamStore } from './streamStore.js' import { useStreamStore } from '@/utils/streamStore.js'
// 对话管理 Composable // 对话管理 Composable
export function useConversations() { export function useConversations() {

View File

@ -2,7 +2,7 @@ import { createApp } from 'vue'
import './style.css' import './style.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { pinia } from './utils' import pinia from './utils/store.js'
// 初始化夜间模式 // 初始化夜间模式
if (localStorage.getItem('theme') === 'dark') { if (localStorage.getItem('theme') === 'dark') {

View File

@ -32,6 +32,20 @@ const routes = [
component: () => import('../views/ToolsView.vue'), component: () => import('../views/ToolsView.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
// Agent 管理
{
path: '/agents',
name: 'Agents',
component: () => import('../views/AgentsView.vue'),
meta: { requiresAuth: true }
},
// 聊天室
{
path: '/rooms',
name: 'Rooms',
component: () => import('../views/RoomView.vue'),
meta: { requiresAuth: true }
},
// 首页重定向 // 首页重定向
{ {
path: '/home', path: '/home',
@ -63,13 +77,11 @@ router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.some(record => record.meta.requiresAuth) const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
if (requiresAuth && !token) { if (requiresAuth && !token) {
// 需要认证但未登录,重定向到登录页
next({ next({
name: 'Auth', name: 'Auth',
query: { redirect: to.fullPath } query: { redirect: to.fullPath }
}) })
} else if (to.name === 'Auth' && token) { } else if (to.name === 'Auth' && token) {
// 已登录访问登录页,重定向到首页
next({ name: 'Home' }) next({ name: 'Home' })
} else { } else {
next() next()

View File

@ -1,25 +0,0 @@
/**
* Luxx 前端工具库
* 合并了 composablesservices stores 的统一导出
*/
// ============ API 服务 ============
export { default as api, authAPI, conversationsAPI, messagesAPI, toolsAPI, providersAPI, createSSEStream } from './api.js'
// ============ Pinia 状态管理 ============
export { default as pinia } from './store.js'
// ============ 认证相关 ============
export { useAuth } from './useAuth.js'
// ============ API 请求组合式函数 ============
export { useApi, usePagination, useForm } from './useApi.js'
// ============ 格式化工具 ============
export { formatDate, formatNumber, truncate, formatFileSize, capitalize, formatTokens } from './useFormatters.js'
// ============ 通用工具函数 ============
export { debounce, throttle, deepClone, generateId, storage, getDeviceType, copyToClipboard } from './useUtils.js'
// ============ Markdown 渲染 ============
export { renderMarkdown } from './markdown.js'

View File

@ -1,171 +0,0 @@
/**
* API 请求组合式函数
* 提供统一的错误处理和加载状态管理
*/
import { ref } from 'vue'
export function useApi() {
const loading = ref(false)
const error = ref(null)
/**
* 执行 API 请求
* @param {Function} apiFn - API 函数
* @param {Object} options - 配置选项
* @returns {Promise<Object>} 响应数据
*/
const request = async (apiFn, options = {}) => {
const { showLoading = true, errorMessage = '请求失败' } = options
loading.value = true
error.value = null
try {
const response = await apiFn()
if (response.success) {
return response.data
} else {
throw new Error(response.message || errorMessage)
}
} catch (err) {
error.value = err.message || errorMessage
throw err
} finally {
loading.value = false
}
}
/**
* 重置状态
*/
const reset = () => {
loading.value = false
error.value = null
}
return {
loading,
error,
request,
reset
}
}
/**
* 分页组合式函数
*/
export function usePagination(initialPage = 1, initialPageSize = 20) {
const page = ref(initialPage)
const pageSize = ref(initialPageSize)
const total = ref(0)
const totalPages = () => Math.ceil(total.value / pageSize.value)
const nextPage = () => {
if (page.value < totalPages()) {
page.value++
}
}
const prevPage = () => {
if (page.value > 1) {
page.value--
}
}
const goToPage = (p) => {
if (p >= 1 && p <= totalPages()) {
page.value = p
}
}
const resetPagination = () => {
page.value = initialPage
total.value = 0
}
return {
page,
pageSize,
total,
totalPages,
nextPage,
prevPage,
goToPage,
resetPagination
}
}
/**
* 表单组合式函数
*/
export function useForm(initialData = {}) {
const formData = ref({ ...initialData })
const errors = ref({})
const submitting = ref(false)
const updateField = (field, value) => {
formData.value[field] = value
// 清除字段错误
if (errors.value[field]) {
delete errors.value[field]
}
}
const setFieldError = (field, message) => {
errors.value[field] = message
}
const setErrors = (errorObj) => {
errors.value = errorObj
}
const clearErrors = () => {
errors.value = {}
}
const resetForm = (newData = {}) => {
formData.value = { ...initialData, ...newData }
errors.value = {}
}
const validate = (rules) => {
const newErrors = {}
for (const [field, rule] of Object.entries(rules)) {
const value = formData.value[field]
if (rule.required && !value) {
newErrors[field] = rule.message || `${field}不能为空`
}
if (rule.minLength && value && value.length < rule.minLength) {
newErrors[field] = rule.message || `${field}长度不能少于${rule.minLength}`
}
if (rule.maxLength && value && value.length > rule.maxLength) {
newErrors[field] = rule.message || `${field}长度不能超过${rule.maxLength}`
}
if (rule.pattern && value && !rule.pattern.test(value)) {
newErrors[field] = rule.message || `${field}格式不正确`
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
return {
formData,
errors,
submitting,
updateField,
setFieldError,
setErrors,
clearErrors,
resetForm,
validate
}
}

View File

@ -0,0 +1,387 @@
<template>
<div class="agents-view">
<div class="header">
<h1>Agent 管理</h1>
<button class="btn-primary" @click="showCreateModal = true">
<span class="icon">+</span> 创建 Agent
</button>
</div>
<!-- Agent 列表 -->
<div class="agents-grid">
<div v-for="agent in agents" :key="agent.id" class="agent-card">
<div class="agent-header">
<div class="agent-avatar">
{{ agent.name?.charAt(0).toUpperCase() || 'A' }}
</div>
<div class="agent-info">
<h3>{{ agent.name }}</h3>
<span class="role-badge" :class="agent.role">{{ agent.role }}</span>
</div>
</div>
<div class="agent-meta">
<div class="meta-item">
<span class="label">优先级</span>
<span class="value">{{ agent.priority }}</span>
</div>
<div class="meta-item">
<span class="label">响应方式</span>
<span class="value">
{{ agent.auto_response ? '自动' : '@触发' }}
</span>
</div>
</div>
<p class="agent-prompt">{{ agent.system_prompt?.slice(0, 100) }}...</p>
<div class="agent-actions">
<button class="btn-small" @click="editAgent(agent)">编辑</button>
<button class="btn-small btn-danger" @click="deleteAgent(agent.id)">删除</button>
</div>
</div>
</div>
<!-- 创建/编辑 Modal -->
<div v-if="showCreateModal || editingAgent" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<h2>{{ editingAgent ? '编辑 Agent' : '创建 Agent' }}</h2>
<form @submit.prevent="saveAgent">
<div class="form-group">
<label>名称 *</label>
<input v-model="form.name" type="text" required placeholder="Agent 名称" />
</div>
<div class="form-group">
<label>角色 *</label>
<input v-model="form.role" type="text" required placeholder="如: coder, thinker" />
</div>
<div class="form-group">
<label>系统提示词 *</label>
<textarea v-model="form.system_prompt" rows="6" required
placeholder="定义 Agent 的行为和职责..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>优先级</label>
<input v-model.number="form.priority" type="number" min="1" max="10" />
</div>
<div class="form-group">
<label>温度</label>
<input v-model.number="form.temperature" type="number" step="0.1" min="0" max="2" />
</div>
<div class="form-group">
<label>最大 Token</label>
<input v-model.number="form.max_tokens" type="number" min="100" max="32000" />
</div>
</div>
<div class="form-row">
<label class="checkbox-label">
<input v-model="form.auto_response" type="checkbox" />
自动响应
</label>
<label class="checkbox-label">
<input v-model="form.mention_trigger" type="checkbox" />
@触发
</label>
</div>
<div class="form-actions">
<button type="button" class="btn-secondary" @click="closeModal">取消</button>
<button type="submit" class="btn-primary">
{{ editingAgent ? '保存' : '创建' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { agentsAPI } from '@/api'
const agents = ref([])
const showCreateModal = ref(false)
const editingAgent = ref(null)
const form = reactive({
name: '',
role: 'helper',
system_prompt: '',
priority: 5,
temperature: 0.7,
max_tokens: 2048,
auto_response: true,
mention_trigger: false
})
async function loadAgents() {
try {
const res = await agentsAPI.list()
agents.value = res.agents || []
} catch (e) {
console.error('Failed to load agents:', e)
}
}
function editAgent(agent) {
editingAgent.value = agent
Object.assign(form, {
name: agent.name,
role: agent.role,
system_prompt: agent.system_prompt,
priority: agent.priority,
temperature: agent.temperature,
max_tokens: agent.max_tokens,
auto_response: agent.auto_response,
mention_trigger: agent.mention_trigger
})
}
function closeModal() {
showCreateModal.value = false
editingAgent.value = null
Object.assign(form, {
name: '', role: 'helper', system_prompt: '',
priority: 5, temperature: 0.7, max_tokens: 2048,
auto_response: true, mention_trigger: false
})
}
async function saveAgent() {
try {
if (editingAgent.value) {
await agentsAPI.update(editingAgent.value.id, { ...form })
} else {
await agentsAPI.create({ ...form })
}
closeModal()
loadAgents()
} catch (e) {
console.error('Failed to save agent:', e)
alert('保存失败: ' + e.message)
}
}
async function deleteAgent(id) {
if (!confirm('确定删除此 Agent?')) return
try {
await agentsAPI.delete(id)
loadAgents()
} catch (e) {
console.error('Failed to delete agent:', e)
}
}
onMounted(loadAgents)
</script>
<style scoped>
.agents-view {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.agents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.agent-card {
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 12px;
padding: 20px;
transition: box-shadow 0.2s;
}
.agent-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.agent-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.agent-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
}
.agent-info h3 {
margin: 0 0 4px 0;
font-size: 16px;
}
.role-badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
background: var(--badge-bg, #e5e7eb);
color: var(--badge-color, #666);
}
.agent-meta {
display: flex;
gap: 16px;
margin-bottom: 12px;
font-size: 13px;
}
.meta-item {
display: flex;
gap: 4px;
}
.meta-item .label {
color: #888;
}
.agent-prompt {
font-size: 13px;
color: #666;
margin-bottom: 16px;
line-height: 1.5;
}
.agent-actions {
display: flex;
gap: 8px;
}
.btn-primary {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.btn-secondary {
background: #e5e7eb;
color: #333;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
}
.btn-small {
padding: 6px 12px;
font-size: 13px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: transparent;
cursor: pointer;
}
.btn-danger {
color: #dc2626;
border-color: #dc2626;
}
/* 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(--card-bg, #fff);
border-radius: 16px;
padding: 24px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.modal h2 {
margin: 0 0 20px 0;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
box-sizing: border-box;
}
.form-group textarea {
resize: vertical;
font-family: inherit;
}
.form-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.form-row .form-group {
flex: 1;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
</style>

View File

@ -48,8 +48,8 @@
<script setup> <script setup>
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { authAPI } from '../utils/api.js' import { authAPI } from '@/api'
import { useAuth } from '../utils/useAuth.js' import { useAuth } from '@/composables/useAuth.js'
const router = useRouter() const router = useRouter()
const { login } = useAuth() const { login } = useAuth()

View File

@ -163,8 +163,8 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useConversations } from '../utils/useConversations.js' import { useConversations } from '@/composables/useConversations.js'
import { formatDate } from '../utils/useFormatters.js' import { formatDate } from '@/composables/useFormatters.js'
import ProcessBlock from '../components/ProcessBlock.vue' import ProcessBlock from '../components/ProcessBlock.vue'
import MessageNav from '../components/MessageNav.vue' import MessageNav from '../components/MessageNav.vue'
import MessageBubble from '../components/MessageBubble.vue' import MessageBubble from '../components/MessageBubble.vue'

View File

@ -29,8 +29,8 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { conversationsAPI, toolsAPI } from '../utils/api.js' import { conversationsAPI, toolsAPI } from '@/api'
import { formatTokens } from '../utils/useFormatters.js' import { formatTokens } from '@/composables/useFormatters.js'
const stats = ref({ conversations: 0, tools: 0, totalTokens: 0 }) const stats = ref({ conversations: 0, tools: 0, totalTokens: 0 })

View File

@ -0,0 +1,785 @@
<template>
<div class="room-view">
<!-- 左侧房间列表 -->
<aside class="sidebar">
<div class="sidebar-header">
<h2>聊天室</h2>
<button class="btn-icon" @click="showCreateRoom = true" title="创建房间">+</button>
</div>
<div class="room-list">
<div v-for="room in rooms" :key="room.id"
class="room-item" :class="{ active: currentRoom?.id === room.id }"
@click="joinRoom(room)">
<div class="room-icon">💬</div>
<div class="room-info">
<span class="room-name">{{ room.name }}</span>
<span class="room-desc">{{ room.description || '无描述' }}</span>
</div>
</div>
</div>
<div class="sidebar-footer">
<router-link to="/agents" class="nav-link">
<span></span> 管理 Agent
</router-link>
</div>
</aside>
<!-- 主聊天区 -->
<main class="chat-main">
<template v-if="currentRoom">
<!-- 房间头部 -->
<header class="chat-header">
<div class="header-info">
<h1>{{ currentRoom.name }}</h1>
<span class="agents-count">{{ roomAgents.length }} Agent</span>
</div>
<div class="header-actions">
<button class="btn-small" @click="showManageAgents = true">管理成员</button>
</div>
</header>
<!-- Agent 在线状态 -->
<div class="agents-bar">
<div v-for="agent in roomAgents" :key="agent.id"
class="agent-pill" :class="{ typing: typingAgents.has(agent.id) }">
<span class="agent-avatar-sm">{{ agent.name?.charAt(0) }}</span>
<span class="agent-name">{{ agent.name }}</span>
<span v-if="typingAgents.has(agent.id)" class="typing-indicator">...</span>
</div>
</div>
<!-- 消息列表 -->
<div class="messages-container" ref="messagesContainer">
<div v-for="msg in messages" :key="msg.id" class="message-wrapper"
:class="msg.sender_type">
<div class="message-avatar">
{{ msg.sender_name?.charAt(0).toUpperCase() }}
</div>
<div class="message-content">
<div class="message-meta">
<span class="sender-name">{{ msg.sender_name }}</span>
<span class="message-time">{{ formatTime(msg.created_at) }}</span>
</div>
<div class="message-text" v-html="renderMarkdown(msg.content)"></div>
</div>
</div>
<!-- 正在输入的流式消息 -->
<div v-for="(stream, agentId) in streamingMessages" :key="'stream-' + agentId"
class="message-wrapper agent streaming">
<div class="message-avatar">
{{ stream.agentName?.charAt(0).toUpperCase() }}
</div>
<div class="message-content">
<div class="message-meta">
<span class="sender-name">{{ stream.agentName }}</span>
<span class="typing-indicator">正在输入...</span>
</div>
<div class="message-text" v-html="renderMarkdown(stream.content)"></div>
</div>
</div>
</div>
<!-- 输入区 -->
<div class="input-area">
<div class="input-hint">
<span v-if="autoAgents.length">@ 触发特定 Agent或等待自动响应</span>
<span v-else>所有 Agent 需要 @ 触发</span>
</div>
<div class="input-row">
<textarea v-model="inputMessage" rows="1" placeholder="输入消息... (@AgentName 触发特定Agent)"
@keydown.enter.exact.prevent="sendMessage" @input="autoResize"></textarea>
<button class="btn-send" @click="sendMessage" :disabled="!inputMessage.trim()">
发送
</button>
</div>
</div>
</template>
<!-- 空状态 -->
<div v-else class="empty-state">
<div class="empty-icon">💬</div>
<h2>选择一个聊天室</h2>
<p>或创建新房间开始与多个 Agent 互动</p>
<button class="btn-primary" @click="showCreateRoom = true">创建聊天室</button>
</div>
</main>
<!-- 创建房间 Modal -->
<div v-if="showCreateRoom" class="modal-overlay" @click.self="showCreateRoom = false">
<div class="modal">
<h2>创建聊天室</h2>
<form @submit.prevent="createRoom">
<div class="form-group">
<label>房间名称 *</label>
<input v-model="newRoom.name" type="text" required />
</div>
<div class="form-group">
<label>描述</label>
<input v-model="newRoom.description" type="text" />
</div>
<div class="form-actions">
<button type="button" class="btn-secondary" @click="showCreateRoom = false">取消</button>
<button type="submit" class="btn-primary">创建</button>
</div>
</form>
</div>
</div>
<!-- 管理 Agent Modal -->
<div v-if="showManageAgents" class="modal-overlay" @click.self="showManageAgents = false">
<div class="modal">
<h2>管理聊天室成员</h2>
<div class="agents-management">
<div class="section">
<h3>当前成员</h3>
<div class="agent-tags">
<span v-for="agent in roomAgents" :key="agent.id" class="agent-tag">
{{ agent.name }}
<button @click="removeAgentFromRoom(agent.id)">×</button>
</span>
<div v-if="!roomAgents.length" class="empty-hint">暂无成员</div>
</div>
</div>
<div class="section">
<h3>添加成员</h3>
<div class="agent-list-select">
<div v-for="agent in availableAgents" :key="agent.id"
class="agent-option" @click="addAgentToRoom(agent.id)">
<span class="agent-avatar-sm">{{ agent.name?.charAt(0) }}</span>
<span>{{ agent.name }}</span>
<span class="role">{{ agent.role }}</span>
</div>
</div>
</div>
</div>
<div class="form-actions">
<button class="btn-secondary" @click="showManageAgents = false">关闭</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { roomsAPI, createRoomWS, agentsAPI } from '@/api'
import { marked } from 'marked'
const rooms = ref([])
const currentRoom = ref(null)
const messages = ref([])
const roomAgents = ref([])
const allAgents = ref([])
const typingAgents = ref(new Set())
const streamingMessages = ref({})
const showCreateRoom = ref(false)
const showManageAgents = ref(false)
const inputMessage = ref('')
const messagesContainer = ref(null)
let ws = null
const newRoom = reactive({ name: '', description: '' })
const availableAgents = computed(() => {
const roomAgentIds = new Set(roomAgents.value.map(a => a.id))
return allAgents.value.filter(a => !roomAgentIds.has(a.id))
})
const autoAgents = computed(() => roomAgents.value.filter(a => a.auto_response && !a.mention_trigger))
function formatTime(iso) {
if (!iso) return ''
return new Date(iso).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function renderMarkdown(text) {
if (!text) return ''
return marked.parse(text)
}
function autoResize(e) {
e.target.style.height = 'auto'
e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px'
}
async function loadRooms() {
try {
const res = await roomsAPI.list()
rooms.value = res.rooms || []
} catch (e) {
console.error('Failed to load rooms:', e)
}
}
async function loadAllAgents() {
try {
const res = await agentsAPI.list()
allAgents.value = res.agents || []
} catch (e) {
console.error('Failed to load agents:', e)
}
}
async function createRoom() {
try {
const res = await roomsAPI.create(newRoom)
rooms.value.push(res.room)
showCreateRoom.value = false
Object.assign(newRoom, { name: '', description: '' })
joinRoom(res.room)
} catch (e) {
console.error('Failed to create room:', e)
alert('创建失败')
}
}
async function joinRoom(room) {
if (ws) {
ws.close()
ws = null
}
currentRoom.value = room
messages.value = []
streamingMessages.value = {}
typingAgents.value = new Set()
//
try {
const res = await roomsAPI.listAgents(room.id)
roomAgents.value = res.agents || []
} catch (e) {
console.error('Failed to load room agents:', e)
roomAgents.value = []
}
// WebSocket
ws = createRoomWS(room.id, {
onConnected: () => console.log('Connected to room'),
onHistory: (msgs) => {
messages.value = msgs || []
scrollToBottom()
},
onAgentsUpdate: (agents) => {
roomAgents.value = agents || []
},
onMessage: (msg) => {
messages.value.push(msg)
scrollToBottom()
},
onTyping: (data) => {
if (data.is_typing) {
typingAgents.value.add(data.agent_id)
} else {
typingAgents.value.delete(data.agent_id)
}
},
onStream: (event, data, agentId, agentName) => {
if (event === 'process_step') {
if (!streamingMessages.value[agentId]) {
streamingMessages.value[agentId] = { agentName, content: '' }
}
if (data.type === 'text') {
streamingMessages.value[agentId].content = data.content
}
} else if (event === 'done') {
delete streamingMessages.value[agentId]
typingAgents.value.delete(agentId)
}
},
onSystem: (data) => {
console.log('System:', data)
}
})
}
function sendMessage() {
if (!inputMessage.value.trim() || !ws) return
const user = JSON.parse(localStorage.getItem('user') || '{}')
ws.sendMessage(inputMessage.value, user.id || 'user', user.username || 'User')
inputMessage.value = ''
}
async function addAgentToRoom(agentId) {
try {
await roomsAPI.addAgent(currentRoom.value.id, agentId)
const res = await roomsAPI.listAgents(currentRoom.value.id)
roomAgents.value = res.agents || []
} catch (e) {
console.error('Failed to add agent:', e)
}
}
async function removeAgentFromRoom(agentId) {
try {
await roomsAPI.removeAgent(currentRoom.value.id, agentId)
roomAgents.value = roomAgents.value.filter(a => a.id !== agentId)
} catch (e) {
console.error('Failed to remove agent:', e)
}
}
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
onMounted(() => {
loadRooms()
loadAllAgents()
})
onUnmounted(() => {
if (ws) ws.close()
})
</script>
<style scoped>
.room-view {
display: flex;
height: 100vh;
background: var(--bg-color, #f5f7fa);
}
/* Sidebar */
.sidebar {
width: 280px;
background: var(--card-bg, #fff);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h2 {
margin: 0;
font-size: 18px;
}
.btn-icon {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: #667eea;
color: white;
cursor: pointer;
font-size: 18px;
}
.room-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.room-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.room-item:hover {
background: var(--hover-bg, #f0f0f0);
}
.room-item.active {
background: #667eea20;
border-left: 3px solid #667eea;
}
.room-icon {
font-size: 24px;
}
.room-info {
display: flex;
flex-direction: column;
}
.room-name {
font-weight: 500;
}
.room-desc {
font-size: 12px;
color: #888;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border-color);
}
.nav-link {
display: flex;
align-items: center;
gap: 8px;
color: #666;
text-decoration: none;
padding: 8px;
border-radius: 6px;
}
.nav-link:hover {
background: var(--hover-bg);
}
/* Main */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-header {
padding: 16px 24px;
background: var(--card-bg, #fff);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-header h1 {
margin: 0;
font-size: 20px;
}
.agents-count {
font-size: 13px;
color: #888;
}
.agents-bar {
padding: 8px 24px;
background: var(--card-bg, #fff);
border-bottom: 1px solid var(--border-color);
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.agent-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: #e5e7eb;
border-radius: 20px;
font-size: 13px;
}
.agent-pill.typing {
background: #fef3c7;
}
.agent-avatar-sm {
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.typing-indicator {
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Messages */
.messages-container {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.message-wrapper {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.message-wrapper.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.message-wrapper.user .message-avatar {
background: linear-gradient(135deg, #11998e, #38ef7d);
}
.message-content {
max-width: 70%;
}
.message-meta {
display: flex;
gap: 8px;
margin-bottom: 4px;
font-size: 12px;
}
.message-wrapper.user .message-meta {
flex-direction: row-reverse;
}
.sender-name {
font-weight: 500;
}
.message-time {
color: #888;
}
.message-text {
padding: 12px 16px;
background: var(--card-bg, #fff);
border-radius: 12px;
border: 1px solid var(--border-color);
line-height: 1.6;
}
.message-wrapper.user .message-text {
background: #667eea;
color: white;
border: none;
}
/* Input */
.input-area {
padding: 16px 24px;
background: var(--card-bg, #fff);
border-top: 1px solid var(--border-color);
}
.input-hint {
font-size: 12px;
color: #888;
margin-bottom: 8px;
}
.input-row {
display: flex;
gap: 12px;
}
.input-row textarea {
flex: 1;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
resize: none;
font-family: inherit;
font-size: 14px;
}
.btn-send {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn-send:disabled {
background: #ccc;
cursor: not-allowed;
}
/* Empty state */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #888;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
}
.btn-primary {
background: #667eea;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
margin-top: 16px;
}
/* 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(--card-bg, #fff);
border-radius: 16px;
padding: 24px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modal h2 {
margin: 0 0 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
box-sizing: border-box;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
.btn-secondary {
background: #e5e7eb;
color: #333;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
}
/* Agent management */
.agents-management .section {
margin-bottom: 20px;
}
.agents-management h3 {
margin: 0 0 12px;
font-size: 14px;
color: #888;
}
.agent-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.agent-tag {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #667eea20;
border-radius: 20px;
font-size: 13px;
}
.agent-tag button {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #666;
}
.agent-list-select {
max-height: 200px;
overflow-y: auto;
}
.agent-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
cursor: pointer;
border-radius: 8px;
}
.agent-option:hover {
background: var(--hover-bg);
}
.agent-option .role {
margin-left: auto;
font-size: 12px;
color: #888;
}
.empty-hint {
color: #888;
font-size: 13px;
}
.btn-small {
padding: 6px 12px;
font-size: 13px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: transparent;
cursor: pointer;
}
</style>

View File

@ -295,9 +295,8 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { providersAPI } from '../utils/api.js' import { providersAPI, authAPI } from '@/api'
import { useAuth } from '../utils/useAuth.js' import { useAuth } from '@/composables/useAuth.js'
import { authAPI } from '../utils/api.js'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()

View File

@ -47,7 +47,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { toolsAPI } from '../utils/api.js' import { toolsAPI } from '@/api'
const list = ref([]) const list = ref([])
const loading = ref(true) const loading = ref(true)

View File

@ -1,9 +1,15 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
@ -25,4 +31,4 @@ export default defineConfig({
} }
} }
} }
}) })

View File

@ -3,10 +3,11 @@ import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.websockets import WebSocket
from luxx.config import config from luxx.core.config import config
from luxx.database import init_db from luxx.core.database import init_db
from luxx.routes import api_router from luxx.api import api_router
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -15,14 +16,15 @@ 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.user import User, LLMProvider, Project
from luxx.models.chat import Conversation, Message
from luxx.models.room import ChatRoom, Agent, ChatRoomAgent, ChatRoomMessage
init_db() init_db()
# Create default test user if not exists # Create default test user if not exists
from luxx.database import SessionLocal from luxx.core.database import SessionLocal
from luxx.models import User
from luxx.utils.helpers import hash_password from luxx.utils.helpers import hash_password
db = SessionLocal() db = SessionLocal()
try: try:
default_user = db.query(User).filter(User.username == "admin").first() default_user = db.query(User).filter(User.username == "admin").first()
@ -37,9 +39,10 @@ async def lifespan(app: FastAPI):
logger.info("Default admin user created: admin / admin") logger.info("Default admin user created: admin / admin")
finally: finally:
db.close() db.close()
# Import and register tools # Import and register tools
from luxx.tools.builtin import crawler, code, data from luxx.tools.builtin import crawler, code, data
yield yield
@ -51,24 +54,31 @@ def create_app() -> FastAPI:
version="1.0.0", version="1.0.0",
lifespan=lifespan lifespan=lifespan
) )
# Configure CORS # Configure CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # Should be restricted in production allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# Register routes # Register routes
app.include_router(api_router, prefix="/api") app.include_router(api_router, prefix="/api")
# WebSocket endpoint for chat rooms
from luxx.services.room_ws import websocket_handler
@app.websocket("/ws/chat-room/{room_id}")
async def chat_room_websocket(websocket: WebSocket, room_id: str):
await websocket_handler(websocket, room_id)
# Health check # Health check
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
return {"status": "healthy", "service": "luxx"} return {"status": "healthy", "service": "luxx"}
@app.get("/") @app.get("/")
async def root(): async def root():
return { return {
@ -76,7 +86,7 @@ def create_app() -> FastAPI:
"version": "1.0.0", "version": "1.0.0",
"docs": "/docs" "docs": "/docs"
} }
return app return app

5
luxx/agents/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""Agents package"""
from luxx.agents.base import BaseAgent
from luxx.agents.registry import agent_registry
__all__ = ["BaseAgent", "agent_registry"]

288
luxx/agents/base.py Normal file
View File

@ -0,0 +1,288 @@
"""Base Agent class"""
import json
import uuid
import logging
from typing import List, Dict, Any, Optional, AsyncGenerator
from abc import ABC, abstractmethod
from luxx.services.llm_client import LLMClient
logger = logging.getLogger(__name__)
class BaseAgent(ABC):
"""Base class for all agents"""
def __init__(
self,
agent_id: str,
name: str,
role: str,
system_prompt: str,
provider_id: int = None,
model: str = None,
tools: List[str] = None,
temperature: float = 0.7,
max_tokens: int = 2048,
priority: int = 5,
auto_response: bool = True,
mention_trigger: bool = False,
avatar: str = None
):
self.agent_id = agent_id
self.name = name
self.role = role
self.system_prompt = system_prompt
self.provider_id = provider_id
self.model = model
self.tools = tools or []
self.temperature = temperature
self.max_tokens = max_tokens
self.priority = priority
self.auto_response = auto_response
self.mention_trigger = mention_trigger
self.avatar = avatar
self.llm_client = None
def _get_llm_client(self, room_id: str = None):
"""Get LLM client, optionally using agent's provider"""
if self.llm_client:
return self.llm_client
if self.provider_id:
from luxx.core.database import SessionLocal
from luxx.models import LLMProvider
db = SessionLocal()
try:
provider = db.query(LLMProvider).filter(LLMProvider.id == self.provider_id).first()
if provider:
self.llm_client = LLMClient(
api_key=provider.api_key,
api_url=provider.base_url,
model=provider.default_model
)
return self.llm_client
finally:
db.close()
# Fallback to global config
self.llm_client = LLMClient()
return self.llm_client
async def stream_response(
self,
user_message: str,
conversation_history: List[Dict] = None,
context: Dict = None,
thinking_enabled: bool = False
) -> AsyncGenerator[Dict[str, Any], None]:
"""
Generate streaming response for the agent.
Args:
user_message: The user's message
conversation_history: Previous messages in the room
context: Additional context (workspace, user info, etc.)
thinking_enabled: Enable reasoning chain
Yields:
SSE-formatted event dictionaries
"""
messages = []
# Add system prompt
final_system_prompt = self._build_system_prompt(context)
messages.append({"role": "system", "content": final_system_prompt})
# Add conversation history (last 10 messages)
if conversation_history:
for msg in conversation_history[-10:]:
role = "assistant" if msg["sender_type"] == "agent" else "user"
messages.append({
"role": role,
"content": msg["content"]
})
# Add current user message
messages.append({"role": "user", "content": user_message})
# Get LLM client
llm = self._get_llm_client()
# Get tools if enabled
enabled_tools = []
if self.tools:
from luxx.tools.core import registry
for tool_name in self.tools:
tool = registry.get(tool_name)
if tool:
enabled_tools.append(tool)
# Stream response
step_index = 0
full_content = ""
try:
async for sse_line in llm.stream_call(
model=self.model or llm.default_model,
messages=messages,
tools=enabled_tools if enabled_tools else None,
temperature=self.temperature,
max_tokens=self.max_tokens,
thinking_enabled=thinking_enabled
):
# Parse SSE line
event_type = None
data_str = None
for line in sse_line.strip().split('\n'):
if line.startswith('event: '):
event_type = line[7:].strip()
elif line.startswith('data: '):
data_str = line[6:].strip()
if data_str is None:
continue
# Handle error events
if event_type == 'error':
try:
error_data = json.loads(data_str)
yield {
"event": "error",
"data": {"content": error_data.get("content", "Unknown error")}
}
except json.JSONDecodeError:
yield {
"event": "error",
"data": {"content": data_str}
}
return
# Parse the data
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
continue
# Check for error in response
if "error" in chunk:
error_msg = chunk["error"].get("message", str(chunk["error"]))
yield {
"event": "error",
"data": {"content": f"API Error: {error_msg}"}
}
return
# Get delta
choices = chunk.get("choices", [])
if not choices:
continue
delta = choices[0].get("delta", {})
# Handle reasoning (thinking)
reasoning = delta.get("reasoning_content", "")
if reasoning:
step_index += 1
yield {
"event": "process_step",
"data": {
"step": {
"id": f"{self.agent_id}-step-{step_index}",
"type": "thinking",
"content": reasoning
}
}
}
# Handle content
content = delta.get("content", "")
if content:
step_index += 1
full_content += content
yield {
"event": "process_step",
"data": {
"step": {
"id": f"{self.agent_id}-step-{step_index}",
"type": "text",
"content": full_content
}
}
}
# Final message
yield {
"event": "done",
"data": {
"message_id": str(uuid.uuid4()),
"agent_id": self.agent_id,
"agent_name": self.name,
"content": full_content,
"token_count": len(full_content) // 4
}
}
except Exception as e:
logger.error(f"Agent {self.name} stream error: {e}")
yield {
"event": "error",
"data": {"content": str(e)}
}
def _build_system_prompt(self, context: Dict = None) -> str:
"""Build the final system prompt with context"""
prompt = self.system_prompt
if context:
workspace = context.get("workspace", "")
if workspace:
prompt += f"\n\nCurrent workspace: {workspace}"
user_name = context.get("username", "")
if user_name:
prompt += f"\nCurrent user: {user_name}"
return prompt
def to_dict(self) -> Dict[str, Any]:
"""Convert agent to dictionary"""
return {
"id": self.agent_id,
"name": self.name,
"role": self.role,
"avatar": self.avatar,
"system_prompt": self.system_prompt,
"model": self.model,
"tools": self.tools,
"priority": self.priority,
"auto_response": self.auto_response,
"mention_trigger": self.mention_trigger,
"temperature": self.temperature,
"max_tokens": self.max_tokens
}
@classmethod
def from_model(cls, agent_db_model) -> "BaseAgent":
"""Create agent instance from database model"""
import json
tools = []
if agent_db_model.tools:
try:
tools = json.loads(agent_db_model.tools)
except json.JSONDecodeError:
pass
return cls(
agent_id=agent_db_model.id,
name=agent_db_model.name,
role=agent_db_model.role,
system_prompt=agent_db_model.system_prompt,
provider_id=agent_db_model.provider_id,
model=agent_db_model.model,
tools=tools,
temperature=float(agent_db_model.temperature) if agent_db_model.temperature else 0.7,
max_tokens=agent_db_model.max_tokens or 2048,
priority=agent_db_model.priority or 5,
auto_response=agent_db_model.auto_response,
mention_trigger=agent_db_model.mention_trigger,
avatar=agent_db_model.avatar
)

View File

@ -0,0 +1 @@
# Builtins package - user-defined agent templates can be placed here

63
luxx/agents/registry.py Normal file
View File

@ -0,0 +1,63 @@
"""Agent Registry - manages agent instances in memory"""
from typing import Dict, List, Optional
import logging
from luxx.agents.base import BaseAgent
logger = logging.getLogger(__name__)
class AgentRegistry:
"""Registry for managing agent instances"""
def __init__(self):
self._agents: Dict[str, BaseAgent] = {}
def register(self, agent: BaseAgent) -> None:
"""Register an agent instance"""
self._agents[agent.agent_id] = agent
logger.info(f"Registered agent: {agent.name} ({agent.role})")
def unregister(self, agent_id: str) -> bool:
"""Unregister an agent"""
if agent_id in self._agents:
agent = self._agents.pop(agent_id)
logger.info(f"Unregistered agent: {agent.name}")
return True
return False
def get(self, agent_id: str) -> Optional[BaseAgent]:
"""Get an agent by ID"""
return self._agents.get(agent_id)
def get_by_role(self, role: str) -> Optional[BaseAgent]:
"""Get an agent by role (returns first match)"""
for agent in self._agents.values():
if agent.role == role:
return agent
return None
def list_all(self) -> List[BaseAgent]:
"""List all registered agents"""
return list(self._agents.values())
def list_by_role(self, role: str) -> List[BaseAgent]:
"""List all agents with a specific role"""
return [a for a in self._agents.values() if a.role == role]
def list_auto_response(self) -> List[BaseAgent]:
"""List all agents that auto-respond"""
return [a for a in self._agents.values() if a.auto_response and not a.mention_trigger]
def list_mention_trigger(self) -> List[BaseAgent]:
"""List all agents that respond on mention"""
return [a for a in self._agents.values() if a.mention_trigger]
def clear(self) -> None:
"""Clear all registered agents"""
self._agents.clear()
logger.info("Cleared all registered agents")
# Global registry instance
agent_registry = AgentRegistry()

View File

@ -1,7 +1,8 @@
"""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.api import auth, tools, providers, agents, rooms
from luxx.api.chat import conversations, messages
api_router = APIRouter() api_router = APIRouter()
@ -12,3 +13,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(agents.router)
api_router.include_router(rooms.router)

108
luxx/api/agents.py Normal file
View File

@ -0,0 +1,108 @@
"""Agent Management API Routes"""
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from luxx.services.agent import agent_manager
router = APIRouter(prefix="/agents", tags=["agents"])
class CreateAgentRequest(BaseModel):
name: str
role: str
system_prompt: str
provider_id: Optional[int] = None
model: Optional[str] = None
tools: Optional[List[str]] = None
priority: int = 5
auto_response: bool = True
mention_trigger: bool = False
temperature: float = 0.7
max_tokens: int = 2048
avatar: Optional[str] = None
class UpdateAgentRequest(BaseModel):
name: Optional[str] = None
role: Optional[str] = None
system_prompt: Optional[str] = None
provider_id: Optional[int] = None
model: Optional[str] = None
tools: Optional[List[str]] = None
priority: Optional[int] = None
auto_response: Optional[bool] = None
mention_trigger: Optional[bool] = None
temperature: Optional[float] = None
max_tokens: Optional[int] = None
is_active: Optional[bool] = None
avatar: Optional[str] = None
@router.get("")
async def list_agents(include_inactive: bool = False):
"""List all agents"""
agents = agent_manager.list_agents(include_inactive=include_inactive)
return {"agents": agents}
@router.post("")
async def create_agent(request: CreateAgentRequest):
"""Create a new agent"""
agent = agent_manager.create_agent(
name=request.name,
role=request.role,
system_prompt=request.system_prompt,
provider_id=request.provider_id,
model=request.model,
tools=request.tools,
priority=request.priority,
auto_response=request.auto_response,
mention_trigger=request.mention_trigger,
temperature=request.temperature,
max_tokens=request.max_tokens,
avatar=request.avatar
)
return {"agent": agent}
@router.get("/{agent_id}")
async def get_agent(agent_id: str):
"""Get an agent by ID"""
agent = agent_manager.get_agent(agent_id)
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
return {"agent": agent}
@router.put("/{agent_id}")
async def update_agent(agent_id: str, request: UpdateAgentRequest):
"""Update an agent"""
agent = agent_manager.update_agent(
agent_id=agent_id,
name=request.name,
role=request.role,
system_prompt=request.system_prompt,
provider_id=request.provider_id,
model=request.model,
tools=request.tools,
priority=request.priority,
auto_response=request.auto_response,
mention_trigger=request.mention_trigger,
temperature=request.temperature,
max_tokens=request.max_tokens,
is_active=request.is_active,
avatar=request.avatar
)
if not agent:
raise HTTPException(status_code=404, detail="Agent not found")
return {"agent": agent}
@router.delete("/{agent_id}")
async def delete_agent(agent_id: str):
"""Delete an agent"""
success = agent_manager.delete_agent(agent_id)
if not success:
raise HTTPException(status_code=404, detail="Agent not found")
return {"success": True}

View File

@ -5,8 +5,8 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel
from luxx.database import get_db from luxx.core.database import get_db
from luxx.models import User from luxx.models.user import User
from luxx.utils.helpers import ( from luxx.utils.helpers import (
hash_password, hash_password,
verify_password, verify_password,
@ -23,20 +23,17 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
class UserRegister(BaseModel): class UserRegister(BaseModel):
"""User registration model"""
username: str username: str
email: str | None = None email: str | None = None
password: str password: str
class UserLogin(BaseModel): class UserLogin(BaseModel):
"""User login model"""
username: str username: str
password: str password: str
class UserResponse(BaseModel): class UserResponse(BaseModel):
"""User response model"""
id: int id: int
username: str username: str
email: str | None email: str | None
@ -45,12 +42,10 @@ class UserResponse(BaseModel):
class UserPermissionUpdate(BaseModel): class UserPermissionUpdate(BaseModel):
"""User permission update model"""
permission_level: int permission_level: int
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
"""Token response model"""
access_token: str access_token: str
token_type: str token_type: str
@ -85,22 +80,23 @@ def register(user_data: UserRegister, db: Session = Depends(get_db)):
existing_user = db.query(User).filter(User.username == user_data.username).first() existing_user = db.query(User).filter(User.username == user_data.username).first()
if existing_user: if existing_user:
return error_response("Username already exists", 400) return error_response("Username already exists", 400)
if user_data.email: if user_data.email:
existing_email = db.query(User).filter(User.email == user_data.email).first() existing_email = db.query(User).filter(User.email == user_data.email).first()
if existing_email: if existing_email:
return error_response("Email already registered", 400) return error_response("Email already registered", 400)
password_hash = hash_password(user_data.password) password_hash = hash_password(user_data.password)
user = User( user = User(
username=user_data.username, username=user_data.username,
email=user_data.email, email=user_data.email,
password_hash=password_hash password_hash=password_hash
) )
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
return success_response( return success_response(
data={"id": user.id, "username": user.username}, data={"id": user.id, "username": user.username},
message="Registration successful" message="Registration successful"
@ -111,18 +107,18 @@ def register(user_data: UserRegister, db: Session = Depends(get_db)):
def login(user_data: UserLogin, db: Session = Depends(get_db)): def login(user_data: UserLogin, db: Session = Depends(get_db)):
"""User login""" """User login"""
user = db.query(User).filter(User.username == user_data.username).first() user = db.query(User).filter(User.username == user_data.username).first()
if not user or not verify_password(user_data.password, user.password_hash or ""): if not user or not verify_password(user_data.password, user.password_hash or ""):
return error_response("Invalid username or password", 401) return error_response("Invalid username or password", 401)
if not user.is_active: if not user.is_active:
return error_response("User account is disabled", 403) return error_response("User account is disabled", 403)
access_token = create_access_token( access_token = create_access_token(
data={"sub": str(user.id)}, data={"sub": str(user.id)},
expires_delta=timedelta(days=7) expires_delta=timedelta(days=7)
) )
return success_response( return success_response(
data={ data={
"access_token": access_token, "access_token": access_token,
@ -135,7 +131,7 @@ def login(user_data: UserLogin, db: Session = Depends(get_db)):
@router.post("/logout") @router.post("/logout")
def logout(current_user: User = Depends(get_current_user)): def logout(current_user: User = Depends(get_current_user)):
"""User logout (client should delete token)""" """User logout"""
return success_response(message="Logout successful") return success_response(message="Logout successful")
@ -158,12 +154,11 @@ def update_user(user_id: int, data: UserPermissionUpdate, admin_user: User = Dep
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user:
return error_response("User not found", 404) return error_response("User not found", 404)
# Validate permission level
if data.permission_level < 1 or data.permission_level > 4: if data.permission_level < 1 or data.permission_level > 4:
return error_response("Invalid permission level (1-4)", 400) return error_response("Invalid permission level (1-4)", 400)
user.permission_level = data.permission_level user.permission_level = data.permission_level
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")

View File

@ -0,0 +1 @@
"""Chat API package"""

View File

@ -1,12 +1,13 @@
"""Conversation routes""" """Conversation routes"""
from typing import Optional, List from typing import Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from luxx.database import get_db from luxx.core.database import get_db
from luxx.models import Conversation, Message, User from luxx.models.chat import Conversation, Message
from luxx.routes.auth import get_current_user from luxx.models.user import User
from luxx.api.auth import get_current_user
from luxx.utils.helpers import generate_id, success_response, error_response, paginate from luxx.utils.helpers import generate_id, success_response, error_response, paginate
@ -14,7 +15,6 @@ router = APIRouter(prefix="/conversations", tags=["Conversations"])
class ConversationCreate(BaseModel): class ConversationCreate(BaseModel):
"""Create conversation model"""
project_id: Optional[str] = None project_id: Optional[str] = None
provider_id: Optional[int] = None provider_id: Optional[int] = None
title: Optional[str] = None title: Optional[str] = None
@ -26,7 +26,6 @@ class ConversationCreate(BaseModel):
class ConversationUpdate(BaseModel): class ConversationUpdate(BaseModel):
"""Update conversation model"""
title: Optional[str] = None title: Optional[str] = None
model: Optional[str] = None model: Optional[str] = None
system_prompt: Optional[str] = None system_prompt: Optional[str] = None
@ -46,19 +45,17 @@ def list_conversations(
import json import json
query = db.query(Conversation).filter(Conversation.user_id == current_user.id) query = db.query(Conversation).filter(Conversation.user_id == current_user.id)
result = paginate(query.order_by(Conversation.updated_at.desc()), page, page_size) result = paginate(query.order_by(Conversation.updated_at.desc()), page, page_size)
items = [] items = []
for c in result["items"]: for c in result["items"]:
conv_dict = c.to_dict() conv_dict = c.to_dict()
# Get first user message for fallback title
first_msg = db.query(Message).filter( first_msg = db.query(Message).filter(
Message.conversation_id == c.id, Message.conversation_id == c.id,
Message.role == 'user' Message.role == 'user'
).order_by(Message.created_at).first() ).order_by(Message.created_at).first()
if first_msg: if first_msg:
conv_dict['first_message'] = first_msg.content[:50] + ('...' if len(first_msg.content) > 50 else '') conv_dict['first_message'] = first_msg.content[:50] + ('...' if len(first_msg.content) > 50 else '')
# Calculate total tokens from all assistant messages in this conversation
assistant_messages = db.query(Message).filter( assistant_messages = db.query(Message).filter(
Message.conversation_id == c.id, Message.conversation_id == c.id,
Message.role == 'assistant' Message.role == 'assistant'
@ -66,7 +63,6 @@ def list_conversations(
total_tokens = 0 total_tokens = 0
for msg in assistant_messages: for msg in assistant_messages:
total_tokens += msg.token_count or 0 total_tokens += msg.token_count or 0
# Also try to get usage from the usage field
if msg.usage: if msg.usage:
try: try:
usage_obj = json.loads(msg.usage) usage_obj = json.loads(msg.usage)
@ -75,7 +71,7 @@ def list_conversations(
pass pass
conv_dict['token_count'] = total_tokens conv_dict['token_count'] = total_tokens
items.append(conv_dict) items.append(conv_dict)
return success_response(data={ return success_response(data={
"items": items, "items": items,
"total": result["total"], "total": result["total"],
@ -91,21 +87,19 @@ def create_conversation(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Create conversation""" """Create conversation"""
from luxx.models import LLMProvider from luxx.models.user import LLMProvider
# Get provider info - use default provider if not specified
provider_id = data.provider_id provider_id = data.provider_id
model = data.model model = data.model
if not provider_id: if not provider_id:
# Find default provider
default_provider = db.query(LLMProvider).filter( default_provider = db.query(LLMProvider).filter(
LLMProvider.user_id == current_user.id, LLMProvider.user_id == current_user.id,
LLMProvider.is_default == True LLMProvider.is_default == True
).first() ).first()
if default_provider: if default_provider:
provider_id = default_provider.id provider_id = default_provider.id
if provider_id and not model: if provider_id and not model:
provider = db.query(LLMProvider).filter( provider = db.query(LLMProvider).filter(
LLMProvider.id == provider_id, LLMProvider.id == provider_id,
@ -113,10 +107,10 @@ def create_conversation(
).first() ).first()
if provider: if provider:
model = provider.default_model model = provider.default_model
if not model: if not model:
model = "gpt-4" model = "gpt-4"
conversation = Conversation( conversation = Conversation(
id=generate_id("conv"), id=generate_id("conv"),
user_id=current_user.id, user_id=current_user.id,
@ -129,11 +123,11 @@ def create_conversation(
max_tokens=data.max_tokens, max_tokens=data.max_tokens,
thinking_enabled=data.thinking_enabled thinking_enabled=data.thinking_enabled
) )
db.add(conversation) db.add(conversation)
db.commit() db.commit()
db.refresh(conversation) db.refresh(conversation)
return success_response(data=conversation.to_dict(), message="Conversation created successfully") return success_response(data=conversation.to_dict(), message="Conversation created successfully")
@ -148,10 +142,10 @@ def get_conversation(
Conversation.id == conversation_id, Conversation.id == conversation_id,
Conversation.user_id == current_user.id Conversation.user_id == current_user.id
).first() ).first()
if not conversation: if not conversation:
return error_response("Conversation not found", 404) return error_response("Conversation not found", 404)
return success_response(data=conversation.to_dict()) return success_response(data=conversation.to_dict())
@ -167,17 +161,17 @@ def update_conversation(
Conversation.id == conversation_id, Conversation.id == conversation_id,
Conversation.user_id == current_user.id Conversation.user_id == current_user.id
).first() ).first()
if not conversation: if not conversation:
return error_response("Conversation not found", 404) return error_response("Conversation not found", 404)
update_data = data.dict(exclude_unset=True) update_data = data.dict(exclude_unset=True)
for key, value in update_data.items(): for key, value in update_data.items():
setattr(conversation, key, value) setattr(conversation, key, value)
db.commit() db.commit()
db.refresh(conversation) db.refresh(conversation)
return success_response(data=conversation.to_dict(), message="Conversation updated successfully") return success_response(data=conversation.to_dict(), message="Conversation updated successfully")
@ -192,11 +186,11 @@ def delete_conversation(
Conversation.id == conversation_id, Conversation.id == conversation_id,
Conversation.user_id == current_user.id Conversation.user_id == current_user.id
).first() ).first()
if not conversation: if not conversation:
return error_response("Conversation not found", 404) return error_response("Conversation not found", 404)
db.delete(conversation) db.delete(conversation)
db.commit() db.commit()
return success_response(message="Conversation deleted successfully") return success_response(message="Conversation deleted successfully")

View File

@ -1,15 +1,15 @@
"""Message routes""" """Message routes"""
import json from typing import List
from typing import List, Optional
from fastapi import APIRouter, Depends, Response from fastapi import APIRouter, Depends, Response
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
from luxx.database import get_db from luxx.core.database import get_db
from luxx.models import Conversation, Message, User from luxx.models.chat import Conversation, Message
from luxx.routes.auth import get_current_user from luxx.models.user import User
from luxx.api.auth import get_current_user
from luxx.services.chat import chat_service from luxx.services.chat import chat_service
from luxx.utils.helpers import generate_id, success_response, error_response from luxx.utils.helpers import generate_id, success_response, error_response
@ -18,15 +18,13 @@ router = APIRouter(prefix="/messages", tags=["Messages"])
class MessageCreate(BaseModel): class MessageCreate(BaseModel):
"""Create message model"""
conversation_id: str conversation_id: str
content: str content: str
thinking_enabled: bool = False thinking_enabled: bool = False
enabled_tools: List[str] = [] # 启用的工具名称列表 enabled_tools: List[str] = []
class MessageResponse(BaseModel): class MessageResponse(BaseModel):
"""Message response model"""
id: str id: str
role: str role: str
content: str content: str
@ -44,14 +42,14 @@ def list_messages(
Conversation.id == conversation_id, Conversation.id == conversation_id,
Conversation.user_id == current_user.id Conversation.user_id == current_user.id
).first() ).first()
if not conversation: if not conversation:
return error_response("Conversation not found", 404) return error_response("Conversation not found", 404)
messages = db.query(Message).filter( messages = db.query(Message).filter(
Message.conversation_id == conversation_id Message.conversation_id == conversation_id
).order_by(Message.created_at).all() ).order_by(Message.created_at).all()
return success_response(data={ return success_response(data={
"messages": [m.to_dict() for m in messages], "messages": [m.to_dict() for m in messages],
"title": conversation.title, "title": conversation.title,
@ -70,10 +68,10 @@ def send_message(
Conversation.id == data.conversation_id, Conversation.id == data.conversation_id,
Conversation.user_id == current_user.id Conversation.user_id == current_user.id
).first() ).first()
if not conversation: if not conversation:
return error_response("Conversation not found", 404) return error_response("Conversation not found", 404)
user_message = Message( user_message = Message(
id=generate_id("msg"), id=generate_id("msg"),
conversation_id=data.conversation_id, conversation_id=data.conversation_id,
@ -81,21 +79,22 @@ def send_message(
content=data.content, content=data.content,
token_count=len(data.content) // 4 token_count=len(data.content) // 4
) )
db.add(user_message) db.add(user_message)
conversation.updated_at = datetime.now() conversation.updated_at = datetime.now()
response = chat_service.non_stream_response( response = chat_service.non_stream_response(
conversation=conversation, conversation=conversation,
user_message=data.content, user_message=data.content,
tools_enabled=False tools_enabled=False
) )
if not response.get("success"): if not response.get("success"):
return error_response(response.get("error", "Failed to generate response"), 500) return error_response(response.get("error", "Failed to generate response"), 500)
ai_content = response.get("content", "") ai_content = response.get("content", "")
ai_message = Message( ai_message = Message(
id=generate_id("msg"), id=generate_id("msg"),
conversation_id=data.conversation_id, conversation_id=data.conversation_id,
@ -103,9 +102,10 @@ def send_message(
content=ai_content, content=ai_content,
token_count=len(ai_content) // 4 token_count=len(ai_content) // 4
) )
db.add(ai_message) db.add(ai_message)
db.commit() db.commit()
return success_response(data={ return success_response(data={
"user_message": user_message.to_dict(), "user_message": user_message.to_dict(),
"assistant_message": ai_message.to_dict() "assistant_message": ai_message.to_dict()
@ -123,10 +123,10 @@ async def stream_message(
Conversation.id == data.conversation_id, Conversation.id == data.conversation_id,
Conversation.user_id == current_user.id Conversation.user_id == current_user.id
).first() ).first()
if not conversation: if not conversation:
return error_response("Conversation not found", 404) return error_response("Conversation not found", 404)
user_message = Message( user_message = Message(
id=generate_id("msg"), id=generate_id("msg"),
conversation_id=data.conversation_id, conversation_id=data.conversation_id,
@ -134,12 +134,13 @@ async def stream_message(
content=data.content, content=data.content,
token_count=len(data.content) // 4 token_count=len(data.content) // 4
) )
db.add(user_message) db.add(user_message)
conversation.updated_at = datetime.now() conversation.updated_at = datetime.now()
db.commit() db.commit()
workspace = current_user.workspace_path if current_user.workspace_path else None workspace = current_user.workspace_path if current_user.workspace_path else None
async def event_generator(): async def event_generator():
async for sse_str in chat_service.stream_response( async for sse_str in chat_service.stream_response(
conversation=conversation, conversation=conversation,
@ -151,9 +152,8 @@ async def stream_message(
workspace=workspace, workspace=workspace,
user_permission_level=current_user.permission_level user_permission_level=current_user.permission_level
): ):
# Chat service returns raw SSE strings (including done event)
yield sse_str yield sse_str
return StreamingResponse( return StreamingResponse(
event_generator(), event_generator(),
media_type="text/event-stream", media_type="text/event-stream",
@ -176,11 +176,11 @@ def delete_message(
Message.id == message_id, Message.id == message_id,
Conversation.user_id == current_user.id Conversation.user_id == current_user.id
).first() ).first()
if not message: if not message:
return error_response("Message not found", 404) return error_response("Message not found", 404)
db.delete(message) db.delete(message)
db.commit() db.commit()
return success_response(message="Message deleted successfully") return success_response(message="Message deleted successfully")

View File

@ -3,9 +3,9 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from luxx.database import get_db, SessionLocal from luxx.core.database import SessionLocal
from luxx.models import User, LLMProvider from luxx.models.user import User, LLMProvider
from luxx.routes.auth import get_current_user from luxx.api.auth import get_current_user
from luxx.utils.helpers import success_response from luxx.utils.helpers import success_response
import httpx import httpx
import asyncio import asyncio
@ -45,7 +45,7 @@ def list_providers(
providers = db.query(LLMProvider).filter( providers = db.query(LLMProvider).filter(
LLMProvider.user_id == current_user.id LLMProvider.user_id == current_user.id
).order_by(LLMProvider.is_default.desc(), LLMProvider.created_at.desc()).all() ).order_by(LLMProvider.is_default.desc(), LLMProvider.created_at.desc()).all()
return success_response(data={ return success_response(data={
"providers": [p.to_dict() for p in providers], "providers": [p.to_dict() for p in providers],
"total": len(providers) "total": len(providers)
@ -62,7 +62,6 @@ def create_provider(
"""Create a new LLM provider""" """Create a new LLM provider"""
db = SessionLocal() db = SessionLocal()
try: try:
db_provider = LLMProvider( db_provider = LLMProvider(
user_id=current_user.id, user_id=current_user.id,
name=provider.name, name=provider.name,
@ -75,7 +74,7 @@ def create_provider(
db.add(db_provider) db.add(db_provider)
db.commit() db.commit()
db.refresh(db_provider) db.refresh(db_provider)
return success_response(data=db_provider.to_dict(include_key=True)) return success_response(data=db_provider.to_dict(include_key=True))
except Exception as e: except Exception as e:
db.rollback() db.rollback()
@ -96,10 +95,10 @@ def get_provider(
LLMProvider.id == provider_id, LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id LLMProvider.user_id == current_user.id
).first() ).first()
if not provider: if not provider:
raise HTTPException(status_code=404, detail="Provider not found") raise HTTPException(status_code=404, detail="Provider not found")
return success_response(data=provider.to_dict(include_key=True)) return success_response(data=provider.to_dict(include_key=True))
finally: finally:
db.close() db.close()
@ -118,28 +117,25 @@ def update_provider(
LLMProvider.id == provider_id, LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id LLMProvider.user_id == current_user.id
).first() ).first()
if not provider: if not provider:
raise HTTPException(status_code=404, detail="Provider not found") raise HTTPException(status_code=404, detail="Provider not found")
# If setting as default, unset others
if update.is_default: if update.is_default:
db.query(LLMProvider).filter( db.query(LLMProvider).filter(
LLMProvider.user_id == current_user.id, LLMProvider.user_id == current_user.id,
LLMProvider.id != provider_id LLMProvider.id != provider_id
).update({"is_default": False}) ).update({"is_default": False})
# Update fields
update_data = update.dict(exclude_unset=True) update_data = update.dict(exclude_unset=True)
# Keep existing API key if the new one is empty
if update_data.get('api_key') == '': if update_data.get('api_key') == '':
update_data.pop('api_key') update_data.pop('api_key')
for key, value in update_data.items(): for key, value in update_data.items():
setattr(provider, key, value) setattr(provider, key, value)
db.commit() db.commit()
db.refresh(provider) db.refresh(provider)
return success_response(data=provider.to_dict(include_key=True)) return success_response(data=provider.to_dict(include_key=True))
except HTTPException: except HTTPException:
raise raise
@ -162,13 +158,13 @@ def delete_provider(
LLMProvider.id == provider_id, LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id LLMProvider.user_id == current_user.id
).first() ).first()
if not provider: if not provider:
raise HTTPException(status_code=404, detail="Provider not found") raise HTTPException(status_code=404, detail="Provider not found")
db.delete(provider) db.delete(provider)
db.commit() db.commit()
return success_response(message="Provider deleted") return success_response(message="Provider deleted")
finally: finally:
db.close() db.close()
@ -180,7 +176,6 @@ def test_provider(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Test provider connection""" """Test provider connection"""
try: try:
db = SessionLocal() db = SessionLocal()
try: try:
@ -188,11 +183,10 @@ def test_provider(
LLMProvider.id == provider_id, LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id LLMProvider.user_id == current_user.id
).first() ).first()
if not provider: if not provider:
return {"success": False, "message": "Provider not found"} return {"success": False, "message": "Provider not found"}
# Test the connection
async def test(): async def test():
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post( response = await client.post(
@ -213,7 +207,7 @@ def test_provider(
"success": True, "success": True,
"response_body": response.text[:500] if response.text else None "response_body": response.text[:500] if response.text else None
} }
result = asyncio.run(test()) result = asyncio.run(test())
return { return {
"success": result.get("success", False), "success": result.get("success", False),

156
luxx/api/rooms.py Normal file
View File

@ -0,0 +1,156 @@
"""Chat Room API Routes"""
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from luxx.services.room import chat_room_service
router = APIRouter(prefix="/chat-rooms", tags=["chat-rooms"])
class CreateChatRoomRequest(BaseModel):
name: str
description: Optional[str] = None
agent_ids: Optional[List[str]] = None
class UpdateChatRoomRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class SendMessageRequest(BaseModel):
content: str
class AddAgentRequest(BaseModel):
agent_id: str
def get_current_user_id() -> int:
"""Get current user ID from auth context"""
return 1
@router.get("")
async def list_chat_rooms():
"""List all chat rooms"""
user_id = get_current_user_id()
rooms = chat_room_service.list_rooms(user_id=user_id)
return {"rooms": rooms}
@router.post("")
async def create_chat_room(request: CreateChatRoomRequest):
"""Create a new chat room"""
user_id = get_current_user_id()
room = chat_room_service.create_room(
name=request.name,
owner_id=user_id,
description=request.description,
agent_ids=request.agent_ids
)
return {"room": room}
@router.get("/{room_id}")
async def get_chat_room(room_id: str):
"""Get a chat room by ID"""
room = chat_room_service.get_room(room_id)
if not room:
raise HTTPException(status_code=404, detail="Chat room not found")
return {"room": room.to_dict(include_agents=True)}
@router.put("/{room_id}")
async def update_chat_room(room_id: str, request: UpdateChatRoomRequest):
"""Update a chat room"""
room = chat_room_service.update_room(
room_id=room_id,
name=request.name,
description=request.description,
is_active=request.is_active
)
if not room:
raise HTTPException(status_code=404, detail="Chat room not found")
return {"room": room}
@router.delete("/{room_id}")
async def delete_chat_room(room_id: str):
"""Delete a chat room"""
success = chat_room_service.delete_room(room_id)
if not success:
raise HTTPException(status_code=404, detail="Chat room not found")
return {"success": True}
@router.get("/{room_id}/agents")
async def get_room_agents(room_id: str):
"""Get all agents in a chat room"""
agents = chat_room_service.get_room_agents(room_id)
return {"agents": [a.to_dict() for a in agents]}
@router.post("/{room_id}/agents")
async def add_agent_to_room(room_id: str, request: AddAgentRequest):
"""Add an agent to a chat room"""
success = chat_room_service.add_agent_to_room(room_id, request.agent_id)
if not success:
raise HTTPException(status_code=400, detail="Failed to add agent")
return {"success": True}
@router.delete("/{room_id}/agents/{agent_id}")
async def remove_agent_from_room(room_id: str, agent_id: str):
"""Remove an agent from a chat room"""
success = chat_room_service.remove_agent_from_room(room_id, agent_id)
if not success:
raise HTTPException(status_code=404, detail="Agent not found in room")
return {"success": True}
@router.get("/{room_id}/messages")
async def get_room_messages(room_id: str, limit: int = 50, before_id: str = None):
"""Get messages from a chat room"""
messages = chat_room_service.get_messages(room_id, limit=limit, before_id=before_id)
return {"messages": messages}
@router.post("/{room_id}/messages")
async def send_message(room_id: str, request: SendMessageRequest):
"""Send a message to a chat room. Returns a streaming response via SSE."""
from fastapi.responses import StreamingResponse
import json
user_id = str(get_current_user_id())
user_name = "User"
async def generate():
async for event in chat_room_service.process_message(
room_id=room_id,
user_message=request.content,
user_id=user_id,
user_name=user_name
):
if event.get("event") in ["process_step", "done"]:
chat_room_service.save_message(
room_id=room_id,
sender_type="user",
sender_id=user_id,
sender_name=user_name,
content=request.content
)
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)

View File

@ -1,11 +1,10 @@
"""Tool routes""" """Tool routes"""
from typing import Optional, List, Dict, Any from typing import Optional, Dict, Any
from fastapi import APIRouter, Depends, Body from fastapi import APIRouter, Depends, Body
from pydantic import BaseModel from pydantic import BaseModel
from luxx.database import get_db from luxx.models.user import User
from luxx.models import User from luxx.api.auth import get_current_user
from luxx.routes.auth import get_current_user
from luxx.tools.core import registry from luxx.tools.core import registry
from luxx.utils.helpers import success_response from luxx.utils.helpers import success_response
@ -19,24 +18,22 @@ def list_tools(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get available tools list""" """Get available tools list"""
# Get tool definitions directly from registry to access category
if category: if category:
all_tools = [t for t in registry._tools.values() if t.category == category] all_tools = [t for t in registry._tools.values() if t.category == category]
tools = [t.to_openai_format() for t in all_tools] tools = [t.to_openai_format() for t in all_tools]
categorized_tools = [t for t in registry._tools.values() if t.category == category] categorized_tools = all_tools
else: else:
all_tools = list(registry._tools.values()) all_tools = list(registry._tools.values())
tools = registry.list_all() tools = registry.list_all()
categorized_tools = all_tools categorized_tools = all_tools
categorized = {} categorized = {}
for tool in categorized_tools: for tool in categorized_tools:
cat = tool.category cat = tool.category
if cat not in categorized: if cat not in categorized:
categorized[cat] = [] categorized[cat] = []
categorized[cat].append(tool.to_openai_format()) categorized[cat].append(tool.to_openai_format())
return success_response(data={ return success_response(data={
"tools": tools, "tools": tools,
"categorized": categorized, "categorized": categorized,
@ -51,10 +48,10 @@ def get_tool(
): ):
"""Get tool details""" """Get tool details"""
tool = registry.get(name) tool = registry.get(name)
if not tool: if not tool:
return {"success": False, "message": "Tool not found", "code": 404} return {"success": False, "message": "Tool not found", "code": 404}
return success_response(data=tool.to_openai_format()) return success_response(data=tool.to_openai_format())

5
luxx/core/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""Core package - configuration and database"""
from luxx.core.config import config
from luxx.core.database import Base, SessionLocal, get_db, init_db, engine
__all__ = ["config", "Base", "SessionLocal", "get_db", "init_db", "engine"]

View File

@ -7,16 +7,16 @@ from typing import Any, Dict, Optional
class Config: class Config:
"""Configuration class (singleton pattern)""" """Configuration class (singleton pattern)"""
_instance: Optional["Config"] = None _instance: Optional["Config"] = None
_config: Dict[str, Any] = {} _config: Dict[str, Any] = {}
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
cls._instance._load_config() cls._instance._load_config()
return cls._instance return cls._instance
def _load_config(self) -> None: def _load_config(self) -> None:
"""Load configuration from YAML file""" """Load configuration from YAML file"""
yaml_paths = [ yaml_paths = [
@ -24,16 +24,16 @@ class Config:
Path(__file__).parent.parent / "config.yaml", Path(__file__).parent.parent / "config.yaml",
Path.cwd() / "config.yaml", Path.cwd() / "config.yaml",
] ]
for path in yaml_paths: for path in yaml_paths:
if path.exists(): if path.exists():
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
self._config = yaml.safe_load(f) or {} self._config = yaml.safe_load(f) or {}
self._resolve_env_vars() self._resolve_env_vars()
return return
self._config = {} self._config = {}
def _resolve_env_vars(self) -> None: def _resolve_env_vars(self) -> None:
"""Resolve environment variable references""" """Resolve environment variable references"""
def resolve(value: Any) -> Any: def resolve(value: Any) -> Any:
@ -44,9 +44,9 @@ class Config:
elif isinstance(value, list): elif isinstance(value, list):
return [resolve(item) for item in value] return [resolve(item) for item in value]
return value return value
self._config = resolve(self._config) self._config = resolve(self._config)
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
"""Get configuration value, supports dot-separated keys""" """Get configuration value, supports dot-separated keys"""
keys = key.split(".") keys = key.split(".")
@ -59,77 +59,77 @@ class Config:
if value is None: if value is None:
return default return default
return value return value
# App configuration # App configuration
@property @property
def secret_key(self) -> str: def secret_key(self) -> str:
return self.get("app.secret_key", "change-me-in-production") return self.get("app.secret_key", "change-me-in-production")
@property @property
def debug(self) -> bool: def debug(self) -> bool:
return self.get("app.debug", True) return self.get("app.debug", True)
@property @property
def app_host(self) -> str: def app_host(self) -> str:
return self.get("app.host", "0.0.0.0") return self.get("app.host", "0.0.0.0")
@property @property
def app_port(self) -> int: def app_port(self) -> int:
return self.get("app.port", 8000) return self.get("app.port", 8000)
# Database configuration # Database configuration
@property @property
def database_url(self) -> str: def database_url(self) -> str:
return self.get("database.url", "sqlite:///./chat.db") return self.get("database.url", "sqlite:///./chat.db")
# LLM configuration # LLM configuration
@property @property
def llm_api_key(self) -> str: def llm_api_key(self) -> str:
return self.get("llm.api_key", "") or os.environ.get("DEEPSEEK_API_KEY", "") return self.get("llm.api_key", "") or os.environ.get("DEEPSEEK_API_KEY", "")
@property @property
def llm_api_url(self) -> str: def llm_api_url(self) -> str:
return self.get("llm.api_url", "https://api.deepseek.com/v1") return self.get("llm.api_url", "https://api.deepseek.com/v1")
@property @property
def llm_provider(self) -> str: def llm_provider(self) -> str:
return self.get("llm.provider", "deepseek") return self.get("llm.provider", "deepseek")
# Tools configuration # Tools configuration
@property @property
def tools_enable_cache(self) -> bool: def tools_enable_cache(self) -> bool:
return self.get("tools.enable_cache", True) return self.get("tools.enable_cache", True)
@property @property
def tools_cache_ttl(self) -> int: def tools_cache_ttl(self) -> int:
return self.get("tools.cache_ttl", 300) return self.get("tools.cache_ttl", 300)
@property @property
def tools_max_workers(self) -> int: def tools_max_workers(self) -> int:
return self.get("tools.max_workers", 4) return self.get("tools.max_workers", 4)
@property @property
def tools_max_iterations(self) -> int: def tools_max_iterations(self) -> int:
return self.get("tools.max_iterations", 10) return self.get("tools.max_iterations", 10)
# Logging configuration # Logging configuration
@property @property
def log_level(self) -> str: def log_level(self) -> str:
return self.get("logging.level", "INFO") return self.get("logging.level", "INFO")
@property @property
def log_format(self) -> str: def log_format(self) -> str:
return self.get("logging.format", "%(asctime)s | %(levelname)-8s | %(message)s") return self.get("logging.format", "%(asctime)s | %(levelname)-8s | %(message)s")
@property @property
def log_date_format(self) -> str: def log_date_format(self) -> str:
return self.get("logging.date_format", "%Y-%m-%d %H:%M:%S") return self.get("logging.date_format", "%Y-%m-%d %H:%M:%S")
# Workspace configuration # Workspace configuration
@property @property
def workspace_root(self) -> str: def workspace_root(self) -> str:
return self.get("workspace.root", "./workspaces") return self.get("workspace.root", "./workspaces")
@property @property
def workspace_auto_create(self) -> bool: def workspace_auto_create(self) -> bool:
return self.get("workspace.auto_create", True) return self.get("workspace.auto_create", True)

View File

@ -1,9 +1,9 @@
"""Database connection module""" """Database connection module"""
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base, Mapped from sqlalchemy.orm import sessionmaker, declarative_base
from typing import Generator from typing import Generator
from luxx.config import config from luxx.core.config import config
# Create database engine # Create database engine
@ -13,9 +13,11 @@ engine = create_engine(
echo=False echo=False
) )
# Create session factory # Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create base class # Create base class
Base = declarative_base() Base = declarative_base()

View File

@ -1,223 +0,0 @@
"""ORM model definitions"""
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Integer, Boolean, Float, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from luxx.database import Base
def local_now():
return datetime.now()
class LLMProvider(Base):
"""LLM Provider configuration model"""
__tablename__ = "llm_providers"
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.
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
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,
"name": self.name,
"provider_type": self.provider_type,
"base_url": self.base_url,
"default_model": self.default_model,
"max_tokens": self.max_tokens,
"is_default": self.is_default,
"enabled": self.enabled,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
if include_key:
result["api_key"] = self.api_key
return result
class Project(Base):
"""Project model"""
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=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="projects")
class User(Base):
"""User model"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
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) # 用户工作空间路径
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"
)
def to_dict(self):
return {
"id": self.id,
"username": self.username,
"email": self.email,
"role": self.role,
"permission_level": self.permission_level,
"workspace_path": self.workspace_path,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None
}
class Conversation(Base):
"""Conversation model"""
__tablename__ = "conversations"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
provider_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("llm_providers.id"), nullable=True)
project_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("projects.id"), nullable=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
model: Mapped[str] = mapped_column(String(64), nullable=False, default="deepseek-chat")
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="You are a helpful assistant.")
temperature: Mapped[float] = mapped_column(Float, default=0.7)
max_tokens: Mapped[int] = mapped_column(Integer, default=2000)
thinking_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
# 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"
)
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"provider_id": self.provider_id,
"project_id": self.project_id,
"title": self.title,
"model": self.model,
"system_prompt": self.system_prompt,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"thinking_enabled": self.thinking_enabled,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
class Message(Base):
"""Message model
content 字段统一使用 JSON 格式存储
**User 消息**
{
"text": "用户输入的文本内容",
"attachments": [
{"name": "utils.py", "extension": "py", "content": "..."}
]
}
**Assistant 消息**
{
"text": "AI 回复的文本内容",
"tool_calls": [...], // 遗留的扁平结构
"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": "..."}
]
}
"""
__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
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
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
# Relationships
conversation: Mapped["Conversation"] = relationship("Conversation", 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,
"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
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["tool_calls"] = []
result["process_steps"] = []
return result
# Extract common fields
result["text"] = content_obj.get("text", "")
result["attachments"] = content_obj.get("attachments", [])
result["tool_calls"] = content_obj.get("tool_calls", [])
# Extract steps as process_steps for frontend rendering
result["process_steps"] = content_obj.get("steps", [])
# For backward compatibility
if "content" not in result:
result["content"] = result["text"]
return result

10
luxx/models/__init__.py Normal file
View File

@ -0,0 +1,10 @@
"""Models package"""
from luxx.models.user import User, LLMProvider, Project
from luxx.models.chat import Conversation, Message
from luxx.models.room import ChatRoom, Agent, ChatRoomAgent, ChatRoomMessage
__all__ = [
"User", "LLMProvider", "Project",
"Conversation", "Message",
"ChatRoom", "Agent", "ChatRoomAgent", "ChatRoomMessage"
]

111
luxx/models/chat.py Normal file
View File

@ -0,0 +1,111 @@
"""Chat-related models"""
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Integer, Boolean, Float, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from luxx.core.database import Base
def local_now():
return datetime.now()
class Conversation(Base):
"""Conversation model"""
__tablename__ = "conversations"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
provider_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("llm_providers.id"), nullable=True)
project_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("projects.id"), nullable=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
model: Mapped[str] = mapped_column(String(64), nullable=False, default="deepseek-chat")
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="You are a helpful assistant.")
temperature: Mapped[float] = mapped_column(Float, default=0.7)
max_tokens: Mapped[int] = mapped_column(Integer, default=2000)
thinking_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
# 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"
)
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"provider_id": self.provider_id,
"project_id": self.project_id,
"title": self.title,
"model": self.model,
"system_prompt": self.system_prompt,
"temperature": self.temperature,
"max_tokens": self.max_tokens,
"thinking_enabled": self.thinking_enabled,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
class Message(Base):
"""Message model.
content 字段统一使用 JSON 格式存储
"""
__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)
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)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
# Relationships
conversation: Mapped["Conversation"] = relationship("Conversation", 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,
"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
try:
content_obj = json.loads(self.content) if self.content else {}
except json.JSONDecodeError:
result["content"] = self.content
result["text"] = self.content
result["attachments"] = []
result["tool_calls"] = []
result["process_steps"] = []
return result
result["text"] = content_obj.get("text", "")
result["attachments"] = content_obj.get("attachments", [])
result["tool_calls"] = content_obj.get("tool_calls", [])
result["process_steps"] = content_obj.get("steps", [])
if "content" not in result:
result["content"] = result["text"]
return result

172
luxx/models/room.py Normal file
View File

@ -0,0 +1,172 @@
"""ChatRoom models"""
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Integer, Boolean, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from luxx.core.database import Base
def local_now():
return datetime.now()
class ChatRoom(Base):
"""Chat Room model - like a group chat for multiple agents"""
__tablename__ = "chat_rooms"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
is_active: 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
owner: Mapped["User"] = relationship("User", backref="chat_rooms")
agents: Mapped[List["ChatRoomAgent"]] = relationship(
"ChatRoomAgent", back_populates="chat_room", cascade="all, delete-orphan"
)
messages: Mapped[List["ChatRoomMessage"]] = relationship(
"ChatRoomMessage", back_populates="chat_room", cascade="all, delete-orphan"
)
def to_dict(self, include_agents: bool = False):
result = {
"id": self.id,
"name": self.name,
"description": self.description,
"owner_id": self.owner_id,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
if include_agents and self.agents:
result["agents"] = [ca.to_dict() for ca in self.agents]
return result
class Agent(Base):
"""Agent model - defines an AI agent with specific role"""
__tablename__ = "agents"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(50), nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="helper")
avatar: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="You are a helpful assistant.")
provider_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("llm_providers.id"), nullable=True)
model: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
tools: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, default=5)
auto_response: Mapped[bool] = mapped_column(Boolean, default=True)
mention_trigger: Mapped[bool] = mapped_column(Boolean, default=False)
temperature: Mapped[float] = mapped_column(Text, default="0.7")
max_tokens: Mapped[int] = mapped_column(Integer, default=2048)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=local_now, onupdate=local_now)
# Relationships
provider: Mapped[Optional["LLMProvider"]] = relationship("LLMProvider")
chat_rooms: Mapped[List["ChatRoomAgent"]] = relationship(
"ChatRoomAgent", back_populates="agent", cascade="all, delete-orphan"
)
def to_dict(self, include_secrets: bool = False):
import json
result = {
"id": self.id,
"name": self.name,
"role": self.role,
"avatar": self.avatar,
"system_prompt": self.system_prompt,
"provider_id": self.provider_id,
"model": self.model,
"is_active": self.is_active,
"priority": self.priority,
"auto_response": self.auto_response,
"mention_trigger": self.mention_trigger,
"temperature": float(self.temperature) if self.temperature else 0.7,
"max_tokens": self.max_tokens,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
if self.tools:
try:
result["tools"] = json.loads(self.tools)
except json.JSONDecodeError:
result["tools"] = []
else:
result["tools"] = []
if include_secrets and self.provider:
result["provider"] = self.provider.to_dict(include_key=True)
return result
class ChatRoomAgent(Base):
"""Association table for ChatRoom and Agent"""
__tablename__ = "chat_room_agents"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
chat_room_id: Mapped[str] = mapped_column(String(64), ForeignKey("chat_rooms.id"), nullable=False)
agent_id: Mapped[str] = mapped_column(String(64), ForeignKey("agents.id"), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
joined_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
# Relationships
chat_room: Mapped["ChatRoom"] = relationship("ChatRoom", back_populates="agents")
agent: Mapped["Agent"] = relationship("Agent", back_populates="chat_rooms")
def to_dict(self):
return {
"id": self.id,
"chat_room_id": self.chat_room_id,
"agent_id": self.agent_id,
"is_active": self.is_active,
"joined_at": self.joined_at.isoformat() if self.joined_at else None,
"agent": self.agent.to_dict() if self.agent else None
}
class ChatRoomMessage(Base):
"""Chat Room Message model"""
__tablename__ = "chat_room_messages"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
room_id: Mapped[str] = mapped_column(String(64), ForeignKey("chat_rooms.id"), nullable=False)
sender_type: Mapped[str] = mapped_column(String(16), nullable=False)
sender_id: Mapped[str] = mapped_column(String(64), nullable=False)
sender_name: Mapped[str] = mapped_column(String(50), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
mentions: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
parent_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
token_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
# Relationships
chat_room: Mapped["ChatRoom"] = relationship("ChatRoom", back_populates="messages")
def to_dict(self):
import json
result = {
"id": self.id,
"room_id": self.room_id,
"sender_type": self.sender_type,
"sender_id": self.sender_id,
"sender_name": self.sender_name,
"content": self.content,
"parent_id": self.parent_id,
"token_count": self.token_count,
"created_at": self.created_at.isoformat() if self.created_at else None
}
if self.mentions:
try:
result["mentions"] = json.loads(self.mentions)
except json.JSONDecodeError:
result["mentions"] = []
else:
result["mentions"] = []
return result

99
luxx/models/user.py Normal file
View File

@ -0,0 +1,99 @@
"""User-related models"""
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Integer, Boolean, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from luxx.core.database import Base
def local_now():
return datetime.now()
class User(Base):
"""User model"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
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)
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"
)
llm_providers: Mapped[List["LLMProvider"]] = relationship("LLMProvider", back_populates="user")
projects: Mapped[List["Project"]] = relationship("Project", back_populates="user")
def to_dict(self):
return {
"id": self.id,
"username": self.username,
"email": self.email,
"role": self.role,
"permission_level": self.permission_level,
"workspace_path": self.workspace_path,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None
}
class LLMProvider(Base):
"""LLM Provider configuration model"""
__tablename__ = "llm_providers"
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")
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)
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", back_populates="llm_providers")
def to_dict(self, include_key: bool = False):
result = {
"id": self.id,
"user_id": self.user_id,
"name": self.name,
"provider_type": self.provider_type,
"base_url": self.base_url,
"default_model": self.default_model,
"max_tokens": self.max_tokens,
"is_default": self.is_default,
"enabled": self.enabled,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}
if include_key:
result["api_key"] = self.api_key
return result
class Project(Base):
"""Project model"""
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=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", back_populates="projects")

View File

@ -1,3 +1,6 @@
"""Services module""" """Services package"""
from luxx.services.llm_client import LLMClient, llm_client, LLMResponse from luxx.services.chat import chat_service
from luxx.services.chat import ChatService, chat_service from luxx.services.room import chat_room_service
from luxx.services.agent import agent_manager
__all__ = ["chat_service", "chat_room_service", "agent_manager"]

125
luxx/services/agent.py Normal file
View File

@ -0,0 +1,125 @@
"""Agent Manager Service"""
import uuid
import json
import logging
from typing import List, Dict, Optional
from luxx.core.database import SessionLocal
from luxx.models.room import Agent
logger = logging.getLogger(__name__)
class AgentManager:
"""Service for managing agents"""
def get_agent(self, agent_id: str) -> Optional[Dict]:
"""Get an agent by ID"""
db = SessionLocal()
try:
agent = db.query(Agent).filter(Agent.id == agent_id).first()
return agent.to_dict() if agent else None
finally:
db.close()
def list_agents(self, include_inactive: bool = False) -> List[Dict]:
"""List all agents"""
db = SessionLocal()
try:
query = db.query(Agent)
if not include_inactive:
query = query.filter(Agent.is_active == True)
agents = query.order_by(Agent.priority).all()
return [a.to_dict() for a in agents]
finally:
db.close()
def create_agent(self, name: str, role: str, system_prompt: str, provider_id: int = None, model: str = None,
tools: List[str] = None, priority: int = 5, auto_response: bool = True, mention_trigger: bool = False,
temperature: float = 0.7, max_tokens: int = 2048, avatar: str = None) -> Dict:
"""Create a new agent"""
if not system_prompt:
system_prompt = "You are a helpful assistant."
db = SessionLocal()
try:
agent = Agent(
id=str(uuid.uuid4()),
name=name,
role=role,
system_prompt=system_prompt,
provider_id=provider_id,
model=model,
tools=json.dumps(tools) if tools else None,
priority=priority,
auto_response=auto_response,
mention_trigger=mention_trigger,
temperature=str(temperature),
max_tokens=max_tokens,
avatar=avatar
)
db.add(agent)
db.commit()
return agent.to_dict()
finally:
db.close()
def update_agent(self, agent_id: str, name: str = None, role: str = None, system_prompt: str = None,
provider_id: int = None, model: str = None, tools: List[str] = None, priority: int = None,
auto_response: bool = None, mention_trigger: bool = None, temperature: float = None,
max_tokens: int = None, is_active: bool = None, avatar: str = None) -> Optional[Dict]:
"""Update an agent"""
db = SessionLocal()
try:
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
return None
if name is not None:
agent.name = name
if role is not None:
agent.role = role
if system_prompt is not None:
agent.system_prompt = system_prompt
if provider_id is not None:
agent.provider_id = provider_id
if model is not None:
agent.model = model
if tools is not None:
agent.tools = json.dumps(tools)
if priority is not None:
agent.priority = priority
if auto_response is not None:
agent.auto_response = auto_response
if mention_trigger is not None:
agent.mention_trigger = mention_trigger
if temperature is not None:
agent.temperature = str(temperature)
if max_tokens is not None:
agent.max_tokens = max_tokens
if is_active is not None:
agent.is_active = is_active
if avatar is not None:
agent.avatar = avatar
db.commit()
return agent.to_dict()
finally:
db.close()
def delete_agent(self, agent_id: str) -> bool:
"""Delete an agent"""
db = SessionLocal()
try:
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
return False
db.delete(agent)
db.commit()
return True
finally:
db.close()
# Global manager instance
agent_manager = AgentManager()

View File

@ -8,7 +8,7 @@ from luxx.models import Conversation, Message
from luxx.tools.executor import ToolExecutor from luxx.tools.executor import ToolExecutor
from luxx.tools.core import registry from luxx.tools.core import registry
from luxx.services.llm_client import LLMClient from luxx.services.llm_client import LLMClient
from luxx.config import config from luxx.core.config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Maximum iterations to prevent infinite loops # Maximum iterations to prevent infinite loops
@ -25,7 +25,7 @@ def get_llm_client(conversation: Conversation = None):
max_tokens = None max_tokens = None
if conversation and conversation.provider_id: if conversation and conversation.provider_id:
from luxx.models import LLMProvider from luxx.models import LLMProvider
from luxx.database import SessionLocal from luxx.core.database import SessionLocal
db = SessionLocal() db = SessionLocal()
try: try:
provider = db.query(LLMProvider).filter(LLMProvider.id == conversation.provider_id).first() provider = db.query(LLMProvider).filter(LLMProvider.id == conversation.provider_id).first()
@ -208,7 +208,7 @@ class ChatService:
include_system: bool = True include_system: bool = True
) -> List[Dict[str, str]]: ) -> List[Dict[str, str]]:
"""Build message list""" """Build message list"""
from luxx.database import SessionLocal from luxx.core.database import SessionLocal
from luxx.models import Message from luxx.models import Message
messages = [] messages = []
@ -498,7 +498,7 @@ class ChatService:
usage: dict = None usage: dict = None
): ):
"""Save the assistant message to database.""" """Save the assistant message to database."""
from luxx.database import SessionLocal from luxx.core.database import SessionLocal
from luxx.models import Message from luxx.models import Message
content_json = { content_json = {

View File

@ -4,7 +4,7 @@ import httpx
import logging import logging
from typing import Dict, Any, Optional, List, AsyncGenerator from typing import Dict, Any, Optional, List, AsyncGenerator
from luxx.config import config from luxx.core.config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

361
luxx/services/room.py Normal file
View File

@ -0,0 +1,361 @@
"""Chat Room Service - orchestrates multi-agent chat"""
import json
import re
import uuid
import asyncio
import logging
from typing import List, Dict, Any, Optional, AsyncGenerator
from dataclasses import dataclass
from luxx.core.database import SessionLocal
from luxx.models.room import ChatRoom, Agent, ChatRoomAgent, ChatRoomMessage
from luxx.agents.base import BaseAgent
logger = logging.getLogger(__name__)
# ==================== Dispatcher ====================
@dataclass
class DispatchResult:
"""Result of message dispatch"""
triggered_agents: List[BaseAgent]
mentions: List[str]
should_respond: bool
class MessageDispatcher:
"""Dispatcher for routing messages to agents"""
@staticmethod
def parse_mentions(content: str) -> List[str]:
"""Parse @mentions from message content"""
pattern = r'@(\w+)'
return re.findall(pattern, content)
@staticmethod
def get_agents_by_names(names: List[str], room_agents: List[BaseAgent]) -> List[BaseAgent]:
"""Get agents by their names (case-insensitive)"""
name_lower_map = {a.name.lower(): a for a in room_agents}
matched = []
for name in names:
agent = name_lower_map.get(name.lower())
if agent:
matched.append(agent)
return matched
@staticmethod
def get_agents_by_ids(agent_ids: List[str], room_agents: List[BaseAgent]) -> List[BaseAgent]:
"""Get agents by their IDs"""
id_set = set(agent_ids)
return [a for a in room_agents if a.agent_id in id_set]
def dispatch(self, content: str, room_agents: List[BaseAgent], sender_id: str, sender_type: str = "user") -> DispatchResult:
"""Dispatch a message to appropriate agents."""
available_agents = [a for a in room_agents if a.agent_id != sender_id]
mentions = self.parse_mentions(content)
if mentions:
triggered = self.get_agents_by_names(mentions, available_agents)
logger.info(f"Message with mentions: {mentions} -> triggered: {[a.name for a in triggered]}")
return DispatchResult(triggered_agents=triggered, mentions=mentions, should_respond=len(triggered) > 0)
auto_agents = [a for a in available_agents if a.auto_response]
auto_agents.sort(key=lambda a: a.priority)
logger.info(f"Auto-response agents triggered: {[a.name for a in auto_agents]}")
return DispatchResult(triggered_agents=auto_agents, mentions=[], should_respond=len(auto_agents) > 0)
# ==================== Aggregator ====================
class ResponseAggregator:
"""Aggregates responses from multiple agents"""
def __init__(self, room_id: str):
self.room_id = room_id
self._agent_responses: Dict[str, Dict[str, Any]] = {}
async def aggregate_stream(self, agent_streams: Dict[str, AsyncGenerator]) -> AsyncGenerator[Dict[str, Any], None]:
"""Aggregate streaming responses from multiple agents."""
if not agent_streams:
return
async def collect_agent_stream(agent_id: str, stream):
try:
async for event in stream:
event["agent_id"] = agent_id
yield event
except Exception as e:
logger.error(f"Agent {agent_id} stream error: {e}")
yield {"event": "error", "agent_id": agent_id, "data": {"content": str(e)}}
tasks = [collect_agent_stream(agent_id, stream) for agent_id, stream in agent_streams.items()]
async for event in asyncio.merge(*tasks):
yield event
def aggregate_final(self, responses: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Aggregate final responses from agents."""
results = []
for agent_id, response in responses.items():
if response.get("event") == "done":
results.append({
"agent_id": agent_id,
"agent_name": response.get("agent_name"),
"message_id": response.get("message_id"),
"content": response.get("content"),
"token_count": response.get("token_count", 0)
})
return results
# ==================== Chat Room Service ====================
class ChatRoomService:
"""Service for managing chat rooms with multi-agent support"""
def __init__(self):
self.dispatcher = MessageDispatcher()
def get_room(self, room_id: str) -> Optional[ChatRoom]:
"""Get a chat room by ID"""
db = SessionLocal()
try:
return db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
finally:
db.close()
def get_room_agents(self, room_id: str) -> List[BaseAgent]:
"""Get all active agents in a chat room"""
db = SessionLocal()
try:
room_agents = db.query(ChatRoomAgent).filter(
ChatRoomAgent.chat_room_id == room_id,
ChatRoomAgent.is_active == True
).all()
agents = []
for ra in room_agents:
agent_db = db.query(Agent).filter(Agent.id == ra.agent_id, Agent.is_active == True).first()
if agent_db:
agents.append(BaseAgent.from_model(agent_db))
agents.sort(key=lambda a: a.priority)
return agents
finally:
db.close()
def get_agent(self, agent_id: str) -> Optional[BaseAgent]:
"""Get an agent by ID"""
db = SessionLocal()
try:
agent_db = db.query(Agent).filter(Agent.id == agent_id).first()
if agent_db:
return BaseAgent.from_model(agent_db)
return None
finally:
db.close()
def list_rooms(self, user_id: int = None, include_agents: bool = True) -> List[Dict]:
"""List all chat rooms"""
db = SessionLocal()
try:
query = db.query(ChatRoom)
if user_id:
query = query.filter(ChatRoom.owner_id == user_id)
rooms = query.order_by(ChatRoom.updated_at.desc()).all()
return [r.to_dict(include_agents=include_agents) for r in rooms]
finally:
db.close()
def create_room(self, name: str, owner_id: int, description: str = None, agent_ids: List[str] = None) -> Dict:
"""Create a new chat room"""
db = SessionLocal()
try:
room = ChatRoom(
id=str(uuid.uuid4()),
name=name,
description=description,
owner_id=owner_id
)
db.add(room)
if agent_ids:
for agent_id in agent_ids:
room_agent = ChatRoomAgent(
id=str(uuid.uuid4()),
chat_room_id=room.id,
agent_id=agent_id
)
db.add(room_agent)
db.commit()
return room.to_dict(include_agents=True)
finally:
db.close()
def update_room(self, room_id: str, name: str = None, description: str = None, is_active: bool = None) -> Optional[Dict]:
"""Update a chat room"""
db = SessionLocal()
try:
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
if not room:
return None
if name is not None:
room.name = name
if description is not None:
room.description = description
if is_active is not None:
room.is_active = is_active
db.commit()
return room.to_dict(include_agents=True)
finally:
db.close()
def delete_room(self, room_id: str) -> bool:
"""Delete a chat room"""
db = SessionLocal()
try:
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
if not room:
return False
db.delete(room)
db.commit()
return True
finally:
db.close()
def add_agent_to_room(self, room_id: str, agent_id: str) -> bool:
"""Add an agent to a chat room"""
db = SessionLocal()
try:
existing = db.query(ChatRoomAgent).filter(
ChatRoomAgent.chat_room_id == room_id,
ChatRoomAgent.agent_id == agent_id
).first()
if existing:
existing.is_active = True
else:
room_agent = ChatRoomAgent(
id=str(uuid.uuid4()),
chat_room_id=room_id,
agent_id=agent_id
)
db.add(room_agent)
db.commit()
return True
finally:
db.close()
def remove_agent_from_room(self, room_id: str, agent_id: str) -> bool:
"""Remove an agent from a chat room"""
db = SessionLocal()
try:
room_agent = db.query(ChatRoomAgent).filter(
ChatRoomAgent.chat_room_id == room_id,
ChatRoomAgent.agent_id == agent_id
).first()
if room_agent:
room_agent.is_active = False
db.commit()
return True
return False
finally:
db.close()
def get_messages(self, room_id: str, limit: int = 50, before_id: str = None) -> List[Dict]:
"""Get messages from a chat room"""
db = SessionLocal()
try:
query = db.query(ChatRoomMessage).filter(
ChatRoomMessage.room_id == room_id
).order_by(ChatRoomMessage.created_at.desc())
if before_id:
before_msg = db.query(ChatRoomMessage).filter(
ChatRoomMessage.id == before_id
).first()
if before_msg:
query = query.filter(ChatRoomMessage.created_at < before_msg.created_at)
messages = query.limit(limit).all()
return [m.to_dict() for m in reversed(messages)]
finally:
db.close()
def save_message(self, room_id: str, sender_type: str, sender_id: str, sender_name: str, content: str,
mentions: List[str] = None, parent_id: str = None, token_count: int = 0) -> Dict:
"""Save a message to a chat room"""
db = SessionLocal()
try:
msg = ChatRoomMessage(
id=str(uuid.uuid4()),
room_id=room_id,
sender_type=sender_type,
sender_id=sender_id,
sender_name=sender_name,
content=content,
mentions=json.dumps(mentions) if mentions else None,
parent_id=parent_id,
token_count=token_count
)
db.add(msg)
room = db.query(ChatRoom).filter(ChatRoom.id == room_id).first()
if room:
from datetime import datetime
room.updated_at = datetime.now()
db.commit()
return msg.to_dict()
finally:
db.close()
async def process_message(self, room_id: str, user_message: str, user_id: str, user_name: str, context: Dict = None) -> AsyncGenerator[Dict[str, Any], None]:
"""Process a user message and dispatch to appropriate agents."""
room = self.get_room(room_id)
if not room:
yield {"event": "error", "data": {"content": "Chat room not found"}}
return
room_agents = self.get_room_agents(room_id)
if not room_agents:
yield {"event": "error", "data": {"content": "No agents available in this room"}}
return
dispatch_result = self.dispatcher.dispatch(
content=user_message,
room_agents=room_agents,
sender_id=user_id,
sender_type="user"
)
if not dispatch_result.should_respond:
yield {"event": "no_response", "data": {"message": "No agents triggered"}}
return
messages = self.get_messages(room_id, limit=20)
agent_streams = {}
for agent in dispatch_result.triggered_agents:
stream = agent.stream_response(
user_message=user_message,
conversation_history=messages,
context=context
)
agent_streams[agent.agent_id] = stream
aggregator = ResponseAggregator(room_id)
async for event in aggregator.aggregate_stream(agent_streams):
yield event
# Global service instance
chat_room_service = ChatRoomService()
# Export for backward compatibility
dispatcher = chat_room_service.dispatcher

170
luxx/services/room_ws.py Normal file
View File

@ -0,0 +1,170 @@
"""WebSocket handler for Chat Rooms"""
import json
import asyncio
import logging
from typing import Dict, Set
from fastapi import WebSocket, WebSocketDisconnect
from luxx.services.room import chat_room_service
logger = logging.getLogger(__name__)
class ChatRoomConnectionManager:
"""Manages WebSocket connections for chat rooms"""
def __init__(self):
self._room_connections: Dict[str, Set[WebSocket]] = {}
self._connection_rooms: Dict[WebSocket, str] = {}
async def connect(self, websocket: WebSocket, room_id: str):
"""Connect a WebSocket to a chat room"""
await websocket.accept()
if room_id not in self._room_connections:
self._room_connections[room_id] = set()
self._room_connections[room_id].add(websocket)
self._connection_rooms[websocket] = room_id
logger.info(f"WebSocket connected to room: {room_id}")
await websocket.send_json({
"event": "connected",
"data": {"room_id": room_id, "message": "Connected to chat room"}
})
def disconnect(self, websocket: WebSocket):
"""Disconnect a WebSocket from its room"""
room_id = self._connection_rooms.pop(websocket, None)
if room_id and room_id in self._room_connections:
self._room_connections[room_id].discard(websocket)
if not self._room_connections[room_id]:
del self._room_connections[room_id]
logger.info(f"WebSocket disconnected from room: {room_id}")
async def broadcast_to_room(self, room_id: str, message: dict, exclude: WebSocket = None):
"""Broadcast a message to all connections in a room"""
if room_id not in self._room_connections:
return
disconnected = []
for connection in self._room_connections[room_id]:
if connection == exclude:
continue
try:
await connection.send_json(message)
except Exception as e:
logger.error(f"Failed to send to connection: {e}")
disconnected.append(connection)
for conn in disconnected:
self.disconnect(conn)
async def send_to_room(self, room_id: str, message: dict):
"""Send a message to all connections in a room"""
await self.broadcast_to_room(room_id, message)
def get_room_size(self, room_id: str) -> int:
"""Get the number of connections in a room"""
return len(self._room_connections.get(room_id, set()))
# Global connection manager
connection_manager = ChatRoomConnectionManager()
async def websocket_handler(websocket: WebSocket, room_id: str):
"""Handle WebSocket connection for a chat room."""
await connection_manager.connect(websocket, room_id)
room = chat_room_service.get_room(room_id)
if not room:
await websocket.send_json({"event": "error", "data": {"content": "Chat room not found"}})
await websocket.close()
return
try:
messages = chat_room_service.get_messages(room_id, limit=50)
await websocket.send_json({"event": "history", "data": {"messages": messages}})
agents = chat_room_service.get_room_agents(room_id)
await websocket.send_json({"event": "agents", "data": {"agents": [a.to_dict() for a in agents]}})
await connection_manager.broadcast_to_room(room_id, {
"event": "system",
"data": {"content": "User joined the room", "type": "join"}
}, exclude=websocket)
while True:
data = await websocket.receive_json()
action = data.get("action")
if action == "send_message":
content = data.get("content", "")
if not content:
continue
user_id = str(data.get("user_id", "anonymous"))
user_name = data.get("user_name", "Anonymous")
msg = chat_room_service.save_message(
room_id=room_id, sender_type="user", sender_id=user_id,
sender_name=user_name, content=content
)
await connection_manager.broadcast_to_room(room_id, {"event": "message", "data": msg})
agents = chat_room_service.get_room_agents(room_id)
for agent in agents:
await connection_manager.broadcast_to_room(room_id, {
"event": "typing",
"data": {"agent_id": agent.agent_id, "agent_name": agent.name, "is_typing": True}
})
context = {"user_id": user_id, "username": user_name}
async for event in chat_room_service.process_message(
room_id=room_id, user_message=content, user_id=user_id,
user_name=user_name, context=context
):
if event.get("event") in ["process_step", "done", "error"]:
await connection_manager.broadcast_to_room(room_id, {
"event": event.get("event"),
"data": event.get("data", {}),
"agent_id": event.get("agent_id"),
"agent_name": event.get("agent_name")
})
if event.get("event") == "done":
chat_room_service.save_message(
room_id=room_id, sender_type="agent",
sender_id=event.get("agent_id"),
sender_name=event.get("agent_name"),
content=event.get("content", ""),
token_count=event.get("token_count", 0)
)
for agent in agents:
await connection_manager.broadcast_to_room(room_id, {
"event": "typing",
"data": {"agent_id": agent.agent_id, "agent_name": agent.name, "is_typing": False}
})
elif action == "ping":
await websocket.send_json({"event": "pong", "data": {}})
except WebSocketDisconnect:
logger.info(f"WebSocket disconnected from room: {room_id}")
connection_manager.disconnect(websocket)
await connection_manager.broadcast_to_room(room_id, {
"event": "system",
"data": {"content": "User left the room", "type": "leave"}
})
except Exception as e:
logger.error(f"WebSocket error in room {room_id}: {e}")
connection_manager.disconnect(websocket)
await connection_manager.broadcast_to_room(room_id, {
"event": "error",
"data": {"content": str(e)}
})

View File

@ -6,7 +6,7 @@ from typing import Dict, Any, Optional
from luxx.tools.factory import tool from luxx.tools.factory import tool
from luxx.tools.core import ToolContext, CommandPermission from luxx.tools.core import ToolContext, CommandPermission
from luxx.config import config from luxx.core.config import config
def get_user_workspace(user_id: int) -> str: def get_user_workspace(user_id: int) -> str:

View File

@ -4,7 +4,7 @@ from typing import Dict, Any
from luxx.tools.factory import tool from luxx.tools.factory import tool
from luxx.tools.core import ToolContext, CommandPermission from luxx.tools.core import ToolContext, CommandPermission
from luxx.config import config from luxx.core.config import config
from pathlib import Path from pathlib import Path
def get_workspace_from_context(context: ToolContext) -> str: def get_workspace_from_context(context: ToolContext) -> str:

View File

@ -4,7 +4,7 @@ import hashlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from luxx.config import config from luxx.core.config import config
def generate_id(prefix: str = "") -> str: def generate_id(prefix: str = "") -> str:

2
run.py
View File

@ -5,7 +5,7 @@ import logging.config
from copy import copy from copy import copy
import uvicorn import uvicorn
from uvicorn.logging import ColourizedFormatter from uvicorn.logging import ColourizedFormatter
from luxx.config import config from luxx.core.config import config
# Custom formatter extending uvicorn's ColourizedFormatter # Custom formatter extending uvicorn's ColourizedFormatter