feat: 增加提供商设置

This commit is contained in:
ViperEkura 2026-04-12 18:05:56 +08:00
parent a5be5b1fdc
commit e774b84753
13 changed files with 799 additions and 24 deletions

View File

@ -42,6 +42,7 @@ const navItems = [
{ path: '/', icon: '🏠', label: '首页' }, { path: '/', icon: '🏠', label: '首页' },
{ path: '/conversations', icon: '💬', label: '会话' }, { path: '/conversations', icon: '💬', label: '会话' },
{ path: '/tools', icon: '🛠️', label: '工具' }, { path: '/tools', icon: '🛠️', label: '工具' },
{ path: '/settings', icon: '⚙️', label: '设置' },
{ path: '/about', icon: '', label: '关于' } { path: '/about', icon: '', label: '关于' }
] ]

View File

@ -14,6 +14,12 @@ const routes = [
component: () => import('../views/AboutView.vue'), component: () => import('../views/AboutView.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/settings',
name: 'Settings',
component: () => import('../views/SettingsView.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/auth', path: '/auth',
name: 'Auth', name: 'Auth',
@ -26,6 +32,12 @@ const routes = [
component: () => import('../views/ConversationsView.vue'), component: () => import('../views/ConversationsView.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/conversations/:id',
name: 'ConversationDetail',
component: () => import('../views/ConversationDetailView.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/tools', path: '/tools',
name: 'Tools', name: 'Tools',

View File

@ -75,7 +75,7 @@ export const conversationsAPI = {
export const messagesAPI = { export const messagesAPI = {
// 获取消息列表 // 获取消息列表
list: (conversationId, params) => api.get(`/messages/${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),
@ -110,5 +110,27 @@ export const toolsAPI = {
execute: (name, data) => api.post(`/tools/${name}/execute`, data) execute: (name, data) => api.post(`/tools/${name}/execute`, data)
} }
// ============ LLM Provider 接口 ============
export const providersAPI = {
// 获取提供商列表
list: () => api.get('/providers/'),
// 创建提供商
create: (data) => api.post('/providers/', data),
// 获取提供商详情
get: (id) => api.get(`/providers/${id}`),
// 更新提供商
update: (id, data) => api.put(`/providers/${id}`, data),
// 删除提供商
delete: (id) => api.delete(`/providers/${id}`),
// 测试连接
test: (id) => api.post(`/providers/${id}/test`)
}
// 默认导出 // 默认导出
export default api export default api

View File

@ -0,0 +1,175 @@
<template>
<div class="chat-view">
<div class="chat-container">
<div class="messages" ref="messagesContainer">
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="!messages.length" class="empty">
<p>开始对话吧</p>
</div>
<div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]">
<div class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
<div class="message-content">
<div class="message-text">{{ msg.content }}</div>
<div class="message-time">{{ formatTime(msg.created_at) }}</div>
</div>
</div>
<div v-if="streaming" class="message assistant streaming">
<div class="message-avatar">🤖</div>
<div class="message-content">
<div class="message-text">{{ streamContent }}<span class="cursor"></span></div>
</div>
</div>
</div>
<div class="input-area">
<textarea
v-model="inputMessage"
@keydown.enter.exact.prevent="sendMessage"
placeholder="输入消息..."
rows="1"
></textarea>
<button @click="sendMessage" :disabled="!inputMessage.trim() || sending" class="send-btn">
{{ sending ? '发送中...' : '发送' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { conversationsAPI, messagesAPI } from '../services/api.js'
const route = useRoute()
const messages = ref([])
const inputMessage = ref('')
const loading = ref(true)
const sending = ref(false)
const streaming = ref(false)
const streamContent = ref('')
const messagesContainer = ref(null)
const conversationId = ref(route.params.id)
const loadMessages = async () => {
loading.value = true
try {
const res = await messagesAPI.list(conversationId.value)
if (res.success) {
messages.value = res.data.messages || []
}
} catch (e) {
console.error(e)
} finally {
loading.value = false
scrollToBottom()
}
}
const sendMessage = async () => {
if (!inputMessage.value.trim() || sending.value) return
const content = inputMessage.value.trim()
inputMessage.value = ''
sending.value = true
//
messages.value.push({
id: Date.now(),
role: 'user',
content: content,
created_at: new Date().toISOString()
})
scrollToBottom()
try {
streaming.value = true
streamContent.value = ''
const response = await messagesAPI.sendStream({
conversation_id: conversationId.value,
content: content,
tools_enabled: true
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
if (parsed.type === 'content_delta') {
streamContent.value += parsed.content
}
} catch (e) {}
}
}
}
//
if (streamContent.value) {
messages.value.push({
id: Date.now() + 1,
role: 'assistant',
content: streamContent.value,
created_at: new Date().toISOString()
})
}
} catch (e) {
console.error('发送失败:', e)
alert('发送失败: ' + e.message)
} finally {
sending.value = false
streaming.value = false
scrollToBottom()
}
}
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
const formatTime = (time) => {
if (!time) return ''
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
onMounted(loadMessages)
</script>
<style scoped>
.chat-view { height: calc(100vh - 70px); display: flex; flex-direction: column; }
.chat-container { flex: 1; display: flex; flex-direction: column; max-width: 900px; margin: 0 auto; width: 100%; }
.messages { flex: 1; overflow-y: auto; padding: 1rem; }
.loading, .empty { text-align: center; padding: 4rem; color: var(--text); }
.message { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
.message.user { flex-direction: row-reverse; }
.message-avatar { width: 40px; height: 40px; background: var(--code-bg); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; flex-shrink: 0; }
.message.user .message-avatar { background: var(--accent-bg); }
.message-content { max-width: 70%; }
.message-text { padding: 1rem; background: var(--code-bg); border-radius: 12px; line-height: 1.6; white-space: pre-wrap; }
.message.user .message-text { background: var(--accent); color: white; }
.message-time { font-size: 0.75rem; color: var(--text); margin-top: 0.25rem; }
.cursor { animation: blink 1s infinite; }
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
.input-area { display: flex; gap: 0.75rem; padding: 1rem; border-top: 1px solid var(--border); }
.input-area textarea { flex: 1; padding: 0.875rem 1rem; border: 1px solid var(--border); border-radius: 12px; resize: none; font-size: 1rem; background: var(--bg); color: var(--text); }
.input-area textarea:focus { outline: none; border-color: var(--accent); }
.send-btn { padding: 0.875rem 1.5rem; background: var(--accent); color: white; border: none; border-radius: 12px; font-size: 1rem; cursor: pointer; white-space: nowrap; }
.send-btn:disabled { opacity: 0.6; cursor: not-allowed; }
</style>

View File

@ -28,11 +28,23 @@
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false"> <div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
<div class="modal"> <div class="modal">
<h2>新建会话</h2> <h2>新建会话</h2>
<div class="form-group"><label>标题</label><input v-model="form.title" placeholder="会话标题" /></div> <div class="form-group"><label>标题</label><input v-model="form.title" placeholder="会话标题(可选)" /></div>
<div class="form-group"><label>模型</label><select v-model="form.model"><option value="glm-5">GLM-5</option><option value="glm-4">GLM-4</option></select></div> <div class="form-group">
<label>Provider</label>
<select v-model="form.provider_id" @change="onProviderChange">
<option :value="null" disabled>选择 Provider</option>
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.provider_type }})
</option>
</select>
</div>
<div class="form-group">
<label>模型</label>
<input v-model="form.model" placeholder="如: gpt-4, deepseek-chat" />
</div>
<div class="modal-actions"> <div class="modal-actions">
<button @click="showModal = false" class="btn-secondary">取消</button> <button @click="showModal = false" class="btn-secondary">取消</button>
<button @click="createConv" :disabled="creating" class="btn-primary">{{ creating ? '创建中...' : '创建' }}</button> <button @click="createConv" :disabled="creating || !form.provider_id" class="btn-primary">{{ creating ? '创建中...' : '创建' }}</button>
</div> </div>
</div> </div>
</div> </div>
@ -42,10 +54,11 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { conversationsAPI } from '../services/api.js' import { conversationsAPI, providersAPI } from '../services/api.js'
const router = useRouter() const router = useRouter()
const list = ref([]) const list = ref([])
const providers = ref([])
const page = ref(1) const page = ref(1)
const pageSize = 20 const pageSize = 20
const total = ref(0) const total = ref(0)
@ -53,7 +66,7 @@ const loading = ref(true)
const error = ref('') const error = ref('')
const showModal = ref(false) const showModal = ref(false)
const creating = ref(false) const creating = ref(false)
const form = ref({ title: '', model: 'glm-5' }) const form = ref({ title: '', provider_id: null, model: '' })
const totalPages = computed(() => Math.ceil(total.value / pageSize)) const totalPages = computed(() => Math.ceil(total.value / pageSize))
@ -61,11 +74,25 @@ const fetchData = async () => {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const res = await conversationsAPI.list({ page: page.value, page_size: pageSize }) const [convRes, provRes] = await Promise.allSettled([
if (res.success) { conversationsAPI.list({ page: page.value, page_size: pageSize }),
list.value = res.data.items || [] providersAPI.list()
total.value = res.data.total || 0 ])
} else throw new Error(res.message)
if (convRes.status === 'fulfilled' && convRes.value.success) {
list.value = convRes.value.data.items || []
total.value = convRes.value.data.total || 0
}
if (provRes.status === 'fulfilled' && provRes.value.success) {
providers.value = provRes.value.data.providers || []
// Set default provider
const defaultProvider = providers.value.find(p => p.is_default)
if (defaultProvider) {
form.value.provider_id = defaultProvider.id
form.value.model = defaultProvider.default_model
}
}
} catch (e) { } catch (e) {
error.value = e.message || '加载失败' error.value = e.message || '加载失败'
} finally { } finally {
@ -79,7 +106,7 @@ const createConv = async () => {
const res = await conversationsAPI.create(form.value) const res = await conversationsAPI.create(form.value)
if (res.success && res.data?.id) { if (res.success && res.data?.id) {
showModal.value = false showModal.value = false
form.value = { title: '', model: 'glm-5' } form.value = { title: '', provider_id: null, model: '' }
router.push(`/conversations/${res.data.id}`) router.push(`/conversations/${res.data.id}`)
} }
} catch (e) { alert(e.message) } } catch (e) { alert(e.message) }
@ -92,6 +119,13 @@ const deleteConv = async (c) => {
fetchData() fetchData()
} }
const onProviderChange = () => {
const provider = providers.value.find(p => p.id === form.value.provider_id)
if (provider) {
form.value.model = provider.default_model
}
}
const formatDate = (d) => { const formatDate = (d) => {
if (!d) return '' if (!d) return ''
const diff = Date.now() - new Date(d) const diff = Date.now() - new Date(d)

View File

@ -0,0 +1,224 @@
<template>
<div class="settings">
<h1>LLM Provider 设置</h1>
<p class="subtitle">配置您的 AI 模型提供商</p>
<div class="actions">
<button @click="showModal = true" class="btn-primary">+ 添加 Provider</button>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="!providers.length" class="empty">暂无 Provider点击上方按钮添加</div>
<div v-else class="grid">
<div v-for="p in providers" :key="p.id" class="card">
<div class="card-header">
<div class="card-title">
<h3>{{ p.name }}</h3>
<span v-if="p.is_default" class="badge default"> 默认</span>
</div>
<label class="switch">
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" />
<span class="slider"></span>
</label>
</div>
<div class="card-info">
<div class="info-row"><span class="label">类型:</span> {{ p.provider_type }}</div>
<div class="info-row"><span class="label">API:</span> {{ p.base_url }}</div>
<div class="info-row"><span class="label">模型:</span> {{ p.default_model }}</div>
</div>
<div class="card-actions">
<button @click="testProvider(p)" :disabled="testing === p.id">🔗 测试</button>
<button @click="editProvider(p)"> 编辑</button>
<button @click="deleteProvider(p)" class="btn-danger">🗑 删除</button>
</div>
</div>
</div>
<!-- 添加/编辑模态框 -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<h2>{{ editing ? '编辑 Provider' : '添加 Provider' }}</h2>
<div class="form-group"><label>名称</label><input v-model="form.name" placeholder="如: 我的 DeepSeek" /></div>
<div class="form-group">
<label>类型</label>
<select v-model="form.provider_type">
<option value="openai">OpenAI 兼容</option>
<option value="deepseek">DeepSeek</option>
<option value="glm">智谱 GLM</option>
<option value="anthropic">Anthropic</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-group"><label>Base URL</label><input v-model="form.base_url" placeholder="https://api.deepseek.com/v1" /></div>
<div class="form-group"><label>API Key</label><input v-model="form.api_key" type="password" placeholder="sk-..." /></div>
<div class="form-group"><label>默认模型</label><input v-model="form.default_model" placeholder="gpt-4 / deepseek-chat" /></div>
<div class="form-group">
<label class="radio-card" :class="{ active: form.is_default }">
<input v-model="form.is_default" type="checkbox" />
<div class="radio-content">
<span class="radio-icon"></span>
<div class="radio-text">
<span class="radio-title">设为默认 Provider</span>
<span class="radio-desc">新会话将默认使用此 Provider</span>
</div>
</div>
</label>
</div>
<div v-if="formError" class="error">{{ formError }}</div>
<div class="modal-actions">
<button @click="closeModal" class="btn-secondary">取消</button>
<button @click="saveProvider" :disabled="saving" class="btn-primary">{{ saving ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { providersAPI } from '../services/api.js'
const providers = ref([])
const loading = ref(true)
const error = ref('')
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const testing = ref(null)
const formError = ref('')
const form = ref({
name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', is_default: false
})
const fetchProviders = async () => {
loading.value = true
error.value = ''
try {
const res = await providersAPI.list()
if (res.success) providers.value = res.data.providers || []
else throw new Error(res.message)
} catch (e) { error.value = e.message }
finally { loading.value = false }
}
const closeModal = () => {
showModal.value = false
editing.value = null
form.value = { name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', is_default: false }
formError.value = ''
}
const editProvider = (p) => {
editing.value = p.id
form.value = {
name: p.name, provider_type: p.provider_type, base_url: p.base_url,
api_key: p.api_key, default_model: p.default_model, is_default: p.is_default
}
showModal.value = true
}
const saveProvider = async () => {
if (!form.value.name || !form.value.base_url || !form.value.api_key) {
formError.value = '请填写所有必填项'
return
}
saving.value = true
formError.value = ''
try {
const data = { ...form.value }
let res
if (editing.value) res = await providersAPI.update(editing.value, data)
else res = await providersAPI.create(data)
if (res.success) { closeModal(); fetchProviders() }
else throw new Error(res.message)
} catch (e) { formError.value = e.message }
finally { saving.value = false }
}
const testProvider = async (p) => {
testing.value = p.id
try {
const res = await providersAPI.test(p.id)
alert(res.data?.message || (res.data?.success ? '连接成功' : '连接失败'))
} catch (e) { alert('测试失败: ' + e.message) }
finally { testing.value = null }
}
const deleteProvider = async (p) => {
if (!confirm(`删除「${p.name}」?`)) return
try {
const res = await providersAPI.delete(p.id)
if (res.success) fetchProviders()
else alert(res.message)
} catch (e) { alert('删除失败: ' + e.message) }
}
const toggleEnabled = async (p) => {
try {
const res = await providersAPI.update(p.id, { enabled: !p.enabled })
if (res.success) fetchProviders()
else alert(res.message)
} catch (e) { alert('更新失败: ' + e.message) }
}
onMounted(fetchProviders)
</script>
<style scoped>
.settings { padding: 0; }
.settings h1 { font-size: 2rem; margin: 0 0 0.5rem; color: var(--text-h); }
.subtitle { color: var(--text); margin: 0 0 2rem; }
.actions { margin-bottom: 2rem; }
.btn-primary { padding: 0.75rem 1.5rem; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; }
.btn-secondary { padding: 0.75rem 1.5rem; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
.btn-danger { background: #dc2626; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; }
.loading, .empty, .error { text-align: center; padding: 4rem; color: var(--text); }
.error { background: #fef2f2; border-radius: 12px; color: #dc2626; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; }
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
.card-title { display: flex; align-items: center; gap: 0.5rem; }
.card-title h3 { margin: 0; color: var(--text-h); }
.badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.75rem; background: var(--code-bg); color: var(--text); }
.badge.default { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; }
.switch { position: relative; display: inline-block; width: 48px; height: 26px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 26px; }
.slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
input:checked + .slider { background-color: #16a34a; }
input:checked + .slider:before { transform: translateX(22px); }
.card-info { margin-bottom: 1rem; }
.info-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem; }
.label { color: var(--text); min-width: 50px; }
.card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.card-actions button { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: var(--bg); border-radius: 16px; padding: 2rem; width: 100%; max-width: 500px; }
.modal h2 { margin: 0 0 1.5rem; color: var(--text-h); }
.form-group { margin-bottom: 1.25rem; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-h); }
.form-group input, .form-group select { width: 100%; padding: 0.75rem; border: 1px solid var(--border); border-radius: 8px; font-size: 1rem; background: var(--bg); box-sizing: border-box; }
.form-group.checkbox label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
.radio-card { display: flex; align-items: center; padding: 1rem; border: 2px solid var(--border); border-radius: 12px; cursor: pointer; transition: all 0.2s; }
.radio-card:hover { border-color: var(--accent); }
.radio-card.active { border-color: var(--accent); background: var(--accent-bg); }
.radio-card input { display: none; }
.radio-content { display: flex; align-items: center; gap: 1rem; width: 100%; }
.radio-icon { font-size: 1.5rem; }
.radio-text { display: flex; flex-direction: column; }
.radio-title { font-weight: 600; color: var(--text-h); }
.radio-desc { font-size: 0.85rem; color: var(--text); }
.modal-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1.5rem; }
.spinner { width: 48px; height: 48px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@ -12,7 +12,7 @@ from luxx.routes import api_router
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 import models # noqa from luxx.models import User, Conversation, Message, Project, LLMProvider # noqa
init_db() init_db()
# Create default test user if not exists # Create default test user if not exists

View File

@ -7,6 +7,44 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from luxx.database import Base from luxx.database import Base
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")
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=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 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,
"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): class Project(Base):
"""Project model""" """Project model"""
__tablename__ = "projects" __tablename__ = "projects"
@ -56,6 +94,7 @@ class Conversation(Base):
id: Mapped[str] = mapped_column(String(64), primary_key=True) id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
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) project_id: Mapped[Optional[str]] = mapped_column(String(64), ForeignKey("projects.id"), nullable=True)
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
model: Mapped[str] = mapped_column(String(64), nullable=False, default="deepseek-chat") model: Mapped[str] = mapped_column(String(64), nullable=False, default="deepseek-chat")
@ -68,6 +107,7 @@ class Conversation(Base):
# Relationships # Relationships
user: Mapped["User"] = relationship("User", back_populates="conversations") user: Mapped["User"] = relationship("User", back_populates="conversations")
provider: Mapped[Optional["LLMProvider"]] = relationship("LLMProvider")
messages: Mapped[List["Message"]] = relationship( messages: Mapped[List["Message"]] = relationship(
"Message", back_populates="conversation", cascade="all, delete-orphan" "Message", back_populates="conversation", cascade="all, delete-orphan"
) )
@ -76,6 +116,7 @@ class Conversation(Base):
return { return {
"id": self.id, "id": self.id,
"user_id": self.user_id, "user_id": self.user_id,
"provider_id": self.provider_id,
"project_id": self.project_id, "project_id": self.project_id,
"title": self.title, "title": self.title,
"model": self.model, "model": self.model,

View File

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

View File

@ -16,8 +16,9 @@ router = APIRouter(prefix="/conversations", tags=["Conversations"])
class ConversationCreate(BaseModel): class ConversationCreate(BaseModel):
"""Create conversation model""" """Create conversation model"""
project_id: Optional[str] = None project_id: Optional[str] = None
provider_id: Optional[int] = None
title: Optional[str] = None title: Optional[str] = None
model: str = "deepseek-chat" model: Optional[str] = None
system_prompt: str = "You are a helpful assistant." system_prompt: str = "You are a helpful assistant."
temperature: float = 0.7 temperature: float = 0.7
max_tokens: int = 2000 max_tokens: int = 2000
@ -45,7 +46,7 @@ def list_conversations(
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)
return success_response(data={ return success_response(data={
"conversations": [c.to_dict() for c in result["items"]], "items": [c.to_dict() for c in result["items"]],
"total": result["total"], "total": result["total"],
"page": result["page"], "page": result["page"],
"page_size": result["page_size"] "page_size": result["page_size"]
@ -59,12 +60,28 @@ def create_conversation(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Create conversation""" """Create conversation"""
# Get provider info if provider_id is specified
model = data.model
if data.provider_id and not model:
from luxx.models import LLMProvider
provider = db.query(LLMProvider).filter(
LLMProvider.id == data.provider_id,
LLMProvider.user_id == current_user.id
).first()
if provider:
model = provider.default_model
else:
model = "gpt-4"
elif not model:
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,
project_id=data.project_id, project_id=data.project_id,
provider_id=data.provider_id,
title=data.title or "New Conversation", title=data.title or "New Conversation",
model=data.model, model=model,
system_prompt=data.system_prompt, system_prompt=data.system_prompt,
temperature=data.temperature, temperature=data.temperature,
max_tokens=data.max_tokens, max_tokens=data.max_tokens,

217
luxx/routes/providers.py Normal file
View File

@ -0,0 +1,217 @@
"""LLM Provider routes"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from luxx.database import get_db, SessionLocal
from luxx.models import User, LLMProvider
from luxx.routes.auth import get_current_user
from luxx.utils.helpers import success_response
import httpx
import asyncio
router = APIRouter(prefix="/providers", tags=["LLM Providers"])
class ProviderCreate(BaseModel):
name: str
provider_type: str = "openai"
base_url: str
api_key: str
default_model: str = "gpt-4"
is_default: bool = False
class ProviderUpdate(BaseModel):
name: Optional[str] = None
provider_type: Optional[str] = None
base_url: Optional[str] = None
api_key: Optional[str] = None
default_model: Optional[str] = None
is_default: Optional[bool] = None
enabled: Optional[bool] = None
@router.get("/", response_model=dict)
def list_providers(
current_user: User = Depends(get_current_user)
):
"""Get user's LLM providers"""
db = SessionLocal()
try:
providers = db.query(LLMProvider).filter(
LLMProvider.user_id == current_user.id
).order_by(LLMProvider.is_default.desc(), LLMProvider.created_at.desc()).all()
return success_response(data={
"providers": [p.to_dict() for p in providers],
"total": len(providers)
})
finally:
db.close()
@router.post("/", response_model=dict)
def create_provider(
provider: ProviderCreate,
current_user: User = Depends(get_current_user)
):
"""Create a new LLM provider"""
db = SessionLocal()
try:
# If this is set as default, unset other defaults
if provider.is_default:
db.query(LLMProvider).filter(
LLMProvider.user_id == current_user.id
).update({"is_default": False})
db_provider = LLMProvider(
user_id=current_user.id,
name=provider.name,
provider_type=provider.provider_type,
base_url=provider.base_url,
api_key=provider.api_key,
default_model=provider.default_model,
is_default=provider.is_default
)
db.add(db_provider)
db.commit()
db.refresh(db_provider)
return success_response(data=db_provider.to_dict(include_key=True))
except Exception as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
finally:
db.close()
@router.get("/{provider_id}", response_model=dict)
def get_provider(
provider_id: int,
current_user: User = Depends(get_current_user)
):
"""Get provider details"""
db = SessionLocal()
try:
provider = db.query(LLMProvider).filter(
LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id
).first()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
return success_response(data=provider.to_dict(include_key=True))
finally:
db.close()
@router.put("/{provider_id}", response_model=dict)
def update_provider(
provider_id: int,
update: ProviderUpdate,
current_user: User = Depends(get_current_user)
):
"""Update provider"""
db = SessionLocal()
try:
provider = db.query(LLMProvider).filter(
LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id
).first()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# If setting as default, unset others
if update.is_default:
db.query(LLMProvider).filter(
LLMProvider.user_id == current_user.id,
LLMProvider.id != provider_id
).update({"is_default": False})
# Update fields
update_data = update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(provider, key, value)
db.commit()
db.refresh(provider)
return success_response(data=provider.to_dict(include_key=True))
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
finally:
db.close()
@router.delete("/{provider_id}", response_model=dict)
def delete_provider(
provider_id: int,
current_user: User = Depends(get_current_user)
):
"""Delete provider"""
db = SessionLocal()
try:
provider = db.query(LLMProvider).filter(
LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id
).first()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
db.delete(provider)
db.commit()
return success_response(message="Provider deleted")
finally:
db.close()
@router.post("/{provider_id}/test", response_model=dict)
def test_provider(
provider_id: int,
current_user: User = Depends(get_current_user)
):
"""Test provider connection"""
db = SessionLocal()
try:
provider = db.query(LLMProvider).filter(
LLMProvider.id == provider_id,
LLMProvider.user_id == current_user.id
).first()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# Test the connection
try:
async def test():
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{provider.base_url}/chat/completions",
headers={
"Authorization": f"Bearer {provider.api_key}",
"Content-Type": "application/json"
},
json={
"model": provider.default_model,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 10
}
)
return response.status_code == 200
success = asyncio.run(test())
return success_response(data={"success": success, "message": "连接成功" if success else "连接失败"})
except Exception as e:
return success_response(data={"success": False, "message": f"连接失败: {str(e)}"})
finally:
db.close()

View File

@ -5,13 +5,37 @@ from typing import List, Dict, Any, AsyncGenerator
from luxx.models import Conversation, Message 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 llm_client from luxx.services.llm_client import LLMClient
from luxx.config import config
# Maximum iterations to prevent infinite loops # Maximum iterations to prevent infinite loops
MAX_ITERATIONS = 10 MAX_ITERATIONS = 10
def get_llm_client(conversation: Conversation = None):
"""Get LLM client, optionally using conversation's provider"""
if conversation and conversation.provider_id:
from luxx.models import LLMProvider
from luxx.database import SessionLocal
db = SessionLocal()
try:
provider = db.query(LLMProvider).filter(LLMProvider.id == conversation.provider_id).first()
if provider:
client = LLMClient(
api_key=provider.api_key,
api_url=provider.base_url,
model=provider.default_model
)
return client
finally:
db.close()
# Fallback to global config
client = LLMClient()
return client
class ChatService: class ChatService:
"""Chat service""" """Chat service"""
@ -66,13 +90,16 @@ class ChatService:
iteration = 0 iteration = 0
llm = get_llm_client(conversation)
model = conversation.model or llm.default_model or "gpt-4"
while iteration < MAX_ITERATIONS: while iteration < MAX_ITERATIONS:
iteration += 1 iteration += 1
tool_calls_this_round = None tool_calls_this_round = None
async for event in llm_client.stream_call( async for event in llm.stream_call(
model=conversation.model, model=model,
messages=messages, messages=messages,
tools=tools, tools=tools,
temperature=conversation.temperature, temperature=conversation.temperature,
@ -144,11 +171,14 @@ class ChatService:
iteration = 0 iteration = 0
llm_client = get_llm_client(conversation)
model = conversation.model or llm_client.default_model or "gpt-4"
while iteration < MAX_ITERATIONS: while iteration < MAX_ITERATIONS:
iteration += 1 iteration += 1
response = llm_client.sync_call( response = llm_client.sync_call(
model=conversation.model, model=model,
messages=messages, messages=messages,
tools=tools, tools=tools,
temperature=conversation.temperature, temperature=conversation.temperature,

View File

@ -26,9 +26,10 @@ class LLMResponse:
class LLMClient: class LLMClient:
"""LLM API client with multi-provider support""" """LLM API client with multi-provider support"""
def __init__(self): def __init__(self, api_key: str = None, api_url: str = None, model: str = None):
self.api_key = config.llm_api_key self.api_key = api_key or config.llm_api_key
self.api_url = config.llm_api_url self.api_url = api_url or config.llm_api_url
self.default_model = model
self.provider = self._detect_provider() self.provider = self._detect_provider()
self._client: Optional[httpx.AsyncClient] = None self._client: Optional[httpx.AsyncClient] = None