feat: 优化样式

This commit is contained in:
ViperEkura 2026-04-13 17:11:08 +08:00
parent eef23e2e94
commit a65356113e
12 changed files with 829 additions and 329 deletions

View File

@ -18,7 +18,14 @@ const sidebarCollapsed = ref(false)
<style scoped>
#app { display: flex; min-height: 100vh; background: var(--bg); }
.main-content { flex: 1; margin-left: 260px; padding: 2rem; min-height: 100vh; transition: margin-left 0.3s; }
.main-content {
flex: 1;
margin-left: 260px;
padding: 0;
min-height: 100vh;
transition: margin-left 0.3s;
overflow-y: auto;
}
.main-content.no-sidebar { margin-left: 0; }
.sidebar-collapsed .main-content { margin-left: 70px; }
@media (max-width: 768px) {

View File

@ -39,6 +39,18 @@
</div>
</div>
<!-- Error Step -->
<div v-else-if="item.type === 'error'" :key="`error-${item.key}`" class="step-item error">
<div class="step-header">
<span v-html="alertIcon"></span>
<span class="step-label">错误</span>
<span class="step-badge error">错误</span>
</div>
<div class="step-content error-content">
<pre>{{ item.content }}</pre>
</div>
</div>
<!-- Text Step -->
<div v-else-if="item.type === 'text'" :key="`text-${item.key}`" class="text-content md-content" v-html="renderMarkdown(item.content)"></div>
</template>
@ -221,6 +233,8 @@ const toolIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" st
const chevronDown = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`
const sparkleIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"></path></svg>`
const alertIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>`
</script>
<style scoped>
@ -305,6 +319,44 @@ const sparkleIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none"
color: var(--danger-color);
}
.step-item.error {
background: var(--danger-bg);
border: 1px solid var(--danger-color);
border-radius: 8px;
padding: 8px 12px;
margin: 4px 0;
}
.step-item.error .step-header {
display: flex;
align-items: center;
gap: 8px;
}
.step-item.error .step-label {
color: var(--danger-color);
font-weight: 600;
}
.step-item.error svg {
color: var(--danger-color);
}
.error-content {
margin-top: 8px;
padding: 8px;
background: rgba(255, 255, 255, 0.5);
border-radius: 4px;
}
.error-content pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-size: 0.85rem;
color: var(--danger-color);
}
.step-brief {
font-size: 11px;
color: var(--text-tertiary);

View File

@ -157,8 +157,8 @@ body {
/* ============ Scrollbar ============ */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
@ -166,12 +166,12 @@ body {
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
background: var(--border-medium);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-medium);
background: var(--text-tertiary);
}
/* ============ Ghost Button ============ */
@ -457,3 +457,102 @@ body {
color: var(--text-tertiary);
margin-top: 4px;
}
/* ============ Common Page Layout ============ */
.page-container {
padding: 2rem;
overflow-y: auto;
max-height: 100vh;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 1.75rem;
margin: 0 0 0.5rem;
color: var(--text-primary);
}
.page-header .subtitle {
font-size: 0.9rem;
color: var(--text-secondary);
}
.page-section {
margin-bottom: 2.5rem;
}
.page-section .section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--border-light);
}
.page-section .section-header h2 {
font-size: 1.15rem;
margin: 0;
color: var(--text-primary);
font-weight: 700;
}
.page-card {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
[data-theme="dark"] .page-card {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* 增强输入框和选择框样式 */
.page-card input,
.page-card select,
.page-card textarea {
background: var(--bg-input);
border: 1px solid var(--border-input);
border-radius: 8px;
padding: 0.6rem 0.85rem;
color: var(--text-primary);
transition: all 0.2s ease;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04);
}
.page-card input:focus,
.page-card select:focus,
.page-card textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-primary-light), inset 0 1px 2px rgba(0, 0, 0, 0.04);
}
.page-card input:hover,
.page-card select:hover,
.page-card textarea:hover {
border-color: var(--border-medium);
}
[data-theme="dark"] .page-card input,
[data-theme="dark"] .page-card select,
[data-theme="dark"] .page-card textarea {
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
}
[data-theme="dark"] .page-card input:focus,
[data-theme="dark"] .page-card select:focus,
[data-theme="dark"] .page-card textarea:focus {
box-shadow: 0 0 0 3px var(--accent-primary-light), inset 0 1px 2px rgba(0, 0, 0, 0.2);
}
.page-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
}

View File

@ -139,7 +139,7 @@ export const messagesAPI = {
return createSSEStream('/messages/stream', {
conversation_id: data.conversation_id,
content: data.content,
tools_enabled: callbacks.toolsEnabled !== false
thinking_enabled: data.thinking_enabled || false
}, callbacks)
},

View File

@ -93,7 +93,7 @@
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { conversationsAPI, messagesAPI } from '../utils/api.js'
import { conversationsAPI, messagesAPI, toolsAPI } from '../utils/api.js'
import ProcessBlock from '../components/ProcessBlock.vue'
import MessageBubble from '../components/MessageBubble.vue'
import { renderMarkdown } from '../utils/markdown.js'
@ -109,6 +109,7 @@ const textareaRef = ref(null)
const autoScroll = ref(true)
const conversationId = ref(route.params.id)
const conversationTitle = ref('')
const enabledTools = ref([]) //
const canSend = computed(() => inputMessage.value.trim().length > 0)
@ -159,6 +160,29 @@ const loadMessages = async () => {
}
}
//
const loadEnabledTools = async () => {
try {
const res = await toolsAPI.list()
if (res.success) {
const data = res.data?.categorized || {}
const enabled = []
Object.values(data).forEach(arr => {
if (Array.isArray(arr)) {
arr.forEach(t => {
if (t.enabled !== false) {
enabled.push(t.function?.name || t.name)
}
})
}
})
enabledTools.value = enabled
}
} catch (e) {
console.error('Failed to load tools:', e)
}
}
const deleteMessage = async (msgId) => {
try {
await messagesAPI.delete(msgId)
@ -202,7 +226,11 @@ const sendMessage = async () => {
// SSE
messagesAPI.sendStream(
{ conversation_id: conversationId.value, content },
{
conversation_id: conversationId.value,
content,
enabled_tools: enabledTools.value //
},
{
onProcessStep: (step) => {
autoScroll.value = true //
@ -244,8 +272,8 @@ const sendMessage = async () => {
streamingMessage.value.process_steps.push({
id: 'error-' + Date.now(),
index: streamingMessage.value.process_steps.length,
type: 'text',
content: `[错误] ${error}`
type: 'error',
content: `错误: ${error}`
})
}
sending.value = false
@ -289,7 +317,10 @@ const formatTime = (time) => {
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
onMounted(loadMessages)
onMounted(() => {
loadMessages()
loadEnabledTools()
})
</script>
<style scoped>

View File

@ -1,28 +1,44 @@
<template>
<div class="conversations">
<div class="header">
<div class="page-container conversations">
<div class="page-header">
<h1>会话管理</h1>
<button @click="showModal = true" class="btn-primary">+ 新建会话</button>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="error" class="error-msg">{{ error }} <button @click="fetchData">重试</button></div>
<div v-else-if="!list.length" class="empty">暂无会话</div>
<div v-else-if="!list.length" class="empty-card">暂无会话点击上方按钮新建</div>
<div v-else class="list">
<div v-for="c in list" :key="c.id" class="card" @click="$router.push(`/conversations/${c.id}`)">
<div class="card-info">
<h3>{{ c.title || c.first_message || '未命名会话' }}</h3>
<p>{{ formatDate(c.created_at) }} {{ c.model || '默认模型' }}</p>
</div>
<button @click.stop="deleteConv(c)" class="btn-delete">删除</button>
</div>
<div v-else class="table-container">
<table class="conv-table">
<thead>
<tr>
<th>标题</th>
<th>模型</th>
<th>修改时间</th>
<th class="action-col">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="c in list" :key="c.id" @click="$router.push(`/conversations/${c.id}`)">
<td class="title-col">
<div class="conv-title">{{ c.title || c.first_message || '未命名会话' }}</div>
</td>
<td class="model-col">{{ c.model || '-' }}</td>
<td class="time-col">{{ formatDate(c.updated_at) }}</td>
<td class="action-col" @click.stop>
<button @click="editTitle(c)" class="btn-edit">重命名</button>
<button @click="deleteConv(c)" class="btn-delete">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="totalPages > 1" class="pagination">
<button @click="page--; fetchData()" :disabled="page === 1"> 上一页</button>
<button @click="page--; fetchData()" :disabled="page === 1">上一页</button>
<span>{{ page }} / {{ totalPages }}</span>
<button @click="page++; fetchData()" :disabled="page >= totalPages">下一页 </button>
<button @click="page++; fetchData()" :disabled="page >= totalPages">下一页</button>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
@ -48,6 +64,17 @@
</div>
</div>
</div>
<div v-if="editConv" class="modal-overlay" @click.self="editConv = null">
<div class="modal">
<h2>重命名会话</h2>
<div class="form-group"><label>标题</label><input v-model="editConv.title" /></div>
<div class="modal-actions">
<button @click="editConv = null" class="btn-secondary">取消</button>
<button @click="saveTitle" class="btn-primary">保存</button>
</div>
</div>
</div>
</div>
</template>
@ -70,6 +97,11 @@ const form = ref({ title: '', provider_id: null, model: '' })
const totalPages = computed(() => Math.ceil(total.value / pageSize))
const onProviderChange = () => {
const p = providers.value.find(p => p.id === form.value.provider_id)
if (p) form.value.model = p.default_model || ''
}
const fetchData = async () => {
loading.value = true
error.value = ''
@ -78,26 +110,15 @@ const fetchData = async () => {
conversationsAPI.list({ page: page.value, page_size: pageSize }),
providersAPI.list()
])
if (convRes.status === 'fulfilled' && convRes.value.success) {
list.value = convRes.value.data.items || []
total.value = convRes.value.data.total || 0
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) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
providers.value = provRes.value.data?.providers || []
}
} catch (e) { error.value = e.message }
finally { loading.value = false }
}
const createConv = async () => {
@ -109,7 +130,7 @@ const createConv = async () => {
form.value = { title: '', provider_id: null, model: '' }
router.push(`/conversations/${res.data.id}`)
}
} catch (e) { alert(e.message) }
} catch (e) { alert('创建失败: ' + e.message) }
finally { creating.value = false }
}
@ -119,49 +140,56 @@ const deleteConv = async (c) => {
fetchData()
}
const onProviderChange = () => {
const provider = providers.value.find(p => p.id === form.value.provider_id)
if (provider) {
form.value.model = provider.default_model
}
const editConv = ref(null)
const editTitle = (c) => {
editConv.value = { ...c }
}
const formatDate = (d) => {
if (!d) return ''
const diff = Date.now() - new Date(d)
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
return new Date(d).toLocaleDateString('zh-CN')
const saveTitle = async () => {
if (!editConv.value) return
await conversationsAPI.update(editConv.value.id, { title: editConv.value.title })
editConv.value = null
fetchData()
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
onMounted(fetchData)
</script>
<style scoped>
.conversations { padding: 0; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
.header h1 { font-size: 2rem; margin: 0; color: var(--text-h); }
.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; }
.loading, .empty, .error-msg { text-align: center; padding: 4rem; color: var(--text); }
.error-msg { background: #fef2f2; border-radius: 12px; }
.list { display: flex; flex-direction: column; gap: 1rem; }
.card { display: flex; justify-content: space-between; align-items: center; padding: 1.25rem; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; cursor: pointer; transition: all 0.2s; }
.card:hover { border-color: var(--accent); transform: translateY(-2px); }
.card h3 { margin: 0 0 0.5rem; color: var(--text-h); }
.card p { margin: 0; color: var(--text); font-size: 0.875rem; }
.btn-delete { background: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); font-size: 0.875rem; cursor: pointer; padding: 0.25rem 0.75rem; border-radius: 6px; transition: all 0.2s; }
.btn-delete:hover { background: var(--accent); color: white; }
.pagination { display: flex; justify-content: center; gap: 1rem; margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
.pagination button { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
.table-container { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
.conv-table { width: 100%; border-collapse: collapse; }
.conv-table th { text-align: left; padding: 1rem; background: var(--bg-secondary); font-weight: 600; font-size: 0.85rem; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); }
.conv-table td { padding: 1rem; border-bottom: 1px solid var(--border-light); vertical-align: middle; }
.conv-table tr:last-child td { border-bottom: none; }
.conv-table tr:hover td { background: var(--bg-secondary); cursor: pointer; }
.title-col { max-width: 300px; }
.conv-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.model-col { width: 150px; font-size: 0.85rem; color: var(--text-secondary); }
.time-col { width: 150px; font-size: 0.85rem; color: var(--text-secondary); }
.action-col { width: 140px; text-align: center; }
.btn-edit, .btn-delete { padding: 0.3rem 0.5rem; background: transparent; border: 1px solid var(--border-light); border-radius: 4px; cursor: pointer; font-size: 0.75rem; margin: 0 0.15rem; }
.btn-edit { color: var(--text-secondary); }
.btn-edit:hover { color: var(--accent-primary); border-color: var(--accent-primary); }
.btn-delete { color: #dc2626; }
.btn-delete:hover { background: #dc2626; color: white; }
.btn-primary { padding: 0.6rem 1.2rem; background: #2563eb; color: #ffffff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.9rem; font-weight: 500; }
.btn-primary:hover { background: #1d4ed8; }
.btn-secondary { padding: 0.6rem 1.2rem; background: transparent; border: 1px solid var(--border-light); border-radius: 8px; cursor: pointer; font-size: 0.9rem; }
.loading { text-align: center; padding: 4rem; }
.empty-card { text-align: center; padding: 4rem; color: var(--text-secondary); background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; }
.error-msg { text-align: center; padding: 2rem; color: var(--accent-primary); background: var(--accent-primary-light); border-radius: 12px; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 1rem; margin-top: 1.5rem; }
.pagination button { padding: 0.5rem 1rem; background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 6px; cursor: pointer; }
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.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; }
.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; }
.modal-actions { display: flex; justify-content: flex-end; gap: 1rem; }
.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; }
.modal { background: var(--bg-primary); border-radius: 16px; padding: 2rem; width: 100%; max-width: 480px; }
.modal h2 { margin: 0 0 1.5rem; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.5rem; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@ -1,22 +1,29 @@
<template>
<div class="home">
<div class="hero">
<h1>欢迎使用 Luxx</h1>
<div class="page-container home">
<div class="hero-section">
<div class="logo-wrapper">
<div class="logo-text">Luxx</div>
</div>
<p class="subtitle">智能会话管理与工具平台</p>
<div class="hero-actions">
<router-link to="/conversations" class="btn-primary">开始会话</router-link>
<router-link to="/tools" class="btn-secondary">查看工具</router-link>
</div>
</div>
<div class="stats-grid">
<div class="stat-card"><div><div class="stat-value">{{ stats.conversations }}</div><div class="stat-label">会话总数</div></div></div>
<div class="stat-card"><div><div class="stat-value">{{ stats.tools }}</div><div class="stat-label">可用工具</div></div></div>
<div class="stat-card"><div><div class="stat-value">{{ stats.messages }}</div><div class="stat-label">消息总数</div></div></div>
<div class="stat-card"><div><div class="stat-value">{{ stats.models }}</div><div class="stat-label">支持模型</div></div></div>
<div class="stats-section">
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-number">{{ stats.conversations }}</div>
<div class="stat-label">会话</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-number">{{ stats.tools }}</div>
<div class="stat-label">工具</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-number">{{ formatTokens(stats.totalTokens) }}</div>
<div class="stat-label">Tokens</div>
</div>
</div>
<div class="footer-note">正在运行 <strong>Luxx</strong> 智能会话系统</div>
</div>
</template>
@ -24,44 +31,124 @@
import { ref, onMounted } from 'vue'
import { conversationsAPI, toolsAPI } from '../utils/api.js'
const stats = ref({ conversations: 0, tools: 0, messages: 0, models: 1 })
const stats = ref({ conversations: 0, tools: 0, totalTokens: 0 })
const formatTokens = (n) => {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
return n.toString()
}
onMounted(async () => {
try {
const [convs, tools] = await Promise.allSettled([
conversationsAPI.list({ page: 1, page_size: 1 }),
conversationsAPI.list({ page: 1, page_size: 100 }),
toolsAPI.list()
])
if (convs.status === 'fulfilled' && convs.value.success) stats.value.conversations = convs.value.data?.total || 0
if (tools.status === 'fulfilled' && tools.value.success) {
const t = tools.value.data?.tools || tools.value.data || []
stats.value.tools = Array.isArray(t) ? t.length : 0
if (convs.status === 'fulfilled' && convs.value.success) {
stats.value.conversations = convs.value.data?.total || 0
const items = convs.value.data?.items || []
stats.value.totalTokens = items.reduce((sum, c) => sum + (c.token_count || 0), 0)
}
if (tools.status === 'fulfilled' && tools.value.success) {
const data = tools.value.data?.categorized || {}
let total = 0
Object.values(data).forEach(arr => {
if (Array.isArray(arr)) total += arr.length
})
stats.value.tools = total
}
stats.value.messages = stats.value.conversations * 5
} catch (e) { console.error(e) }
})
</script>
<style scoped>
.home { padding: 0; max-width: 1200px; margin: 0 auto; }
.hero { background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%); border-radius: 20px; padding: 3rem 2rem; margin-bottom: 3rem; color: white; text-align: center; }
.hero h1 { font-size: 3rem; margin: 0 0 1rem; color: white; }
.subtitle { font-size: 1.3rem; opacity: 0.9; margin: 0 0 2rem; }
.hero-actions { display: flex; justify-content: center; gap: 1rem; }
.btn-primary { padding: 1rem 2rem; background: white; color: var(--accent); border-radius: 12px; text-decoration: none; font-weight: 500; }
.btn-secondary { padding: 1rem 2rem; background: rgba(255,255,255,0.2); color: white; border: 2px solid rgba(255,255,255,0.3); border-radius: 12px; text-decoration: none; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; margin-bottom: 3rem; }
.stat-card { display: flex; align-items: center; gap: 1rem; padding: 1.5rem; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
.stat-value { font-size: 2.5rem; font-weight: bold; color: var(--text-h); }
.stat-label { color: var(--text); font-size: 0.9rem; }
.features { margin-bottom: 3rem; }
.features h2 { font-size: 1.8rem; margin: 0 0 1.5rem; color: var(--text-h); }
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; }
.feature-card { background: var(--bg); border: 1px solid var(--border); border-radius: 16px; padding: 2rem; transition: all 0.3s; }
.feature-card:hover { border-color: var(--accent); transform: translateY(-5px); }
.feature-card h3 { font-size: 1.3rem; margin: 0 0 0.75rem; color: var(--text-h); }
.feature-card p { color: var(--text); margin: 0; }
.footer-note { background: var(--code-bg); border-radius: 16px; padding: 2rem; text-align: center; color: var(--text); }
.footer-note strong { color: var(--text-h); }
@media (max-width: 768px) { .hero h1 { font-size: 2rem; } .hero-actions { flex-direction: column; } .btn-primary, .btn-secondary { width: 100%; justify-content: center; } }
.home {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
min-height: calc(100vh - 4rem);
}
.hero-section {
text-align: center;
margin-bottom: 3rem;
}
.logo-wrapper {
display: inline-block;
padding: 1.5rem 4rem;
}
.logo-text {
font-size: 6rem;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.1;
color: var(--accent-primary);
text-shadow: 0 2px 10px rgba(37, 99, 235, 0.3);
}
.subtitle {
font-size: 1.25rem;
color: var(--text-secondary);
margin: 1.5rem 0 0;
font-weight: 400;
}
.stats-section {
display: flex;
gap: 2rem;
}
.stat-card {
text-align: center;
padding: 2rem 3rem;
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 16px;
min-width: 140px;
transition: all 0.2s;
}
.stat-card:hover {
border-color: var(--accent-primary);
box-shadow: 0 4px 20px rgba(37, 99, 235, 0.1);
transform: translateY(-2px);
}
.stat-number {
font-size: 3rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-label {
font-size: 0.9rem;
color: var(--text-secondary);
margin-top: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
@media (max-width: 768px) {
.home {
padding: 2rem 1rem;
}
.logo-text {
font-size: 3.5rem;
}
.logo-wrapper {
padding: 1rem 2rem;
}
.subtitle {
font-size: 1rem;
}
.stats-section {
flex-direction: column;
gap: 1rem;
}
.stat-card {
padding: 1.5rem 2rem;
min-width: 100%;
}
.stat-number {
font-size: 2rem;
}
}
</style>

View File

@ -1,19 +1,182 @@
<template>
<div class="settings">
<div class="page-container settings">
<div class="page-header">
<h1>设置</h1>
</div>
<div class="section">
<!-- 用户信息 -->
<div class="page-section">
<div class="section-header">
<h2>用户信息</h2>
<h2>👤 用户信息</h2>
</div>
<div v-if="loadingUser" class="loading-small"><div class="spinner-small"></div>加载中...</div>
<div v-else class="user-info-card">
<div class="info-item"><span class="label">用户名</span><span class="value">{{ userForm.username || '-' }}</span></div>
<div class="info-item"><span class="label">邮箱</span><span class="value">{{ userForm.email || '-' }}</span></div>
<div class="card-actions">
<button @click="openUserModal" class="btn-primary">编辑</button>
<button @click="handleLogout" class="btn-danger">退出登录</button>
<div v-else class="page-card">
<div class="info-grid">
<div class="info-item">
<span class="label">用户名</span>
<span class="value">{{ userForm.username || '-' }}</span>
</div>
<div class="info-item">
<span class="label">邮箱</span>
<span class="value">{{ userForm.email || '-' }}</span>
</div>
</div>
<div class="card-actions">
<button @click="openUserModal" class="btn-primary">编辑资料</button>
<button @click="handleLogout" class="btn-primary">退出登录</button>
</div>
</div>
</div>
<!-- 主题设置 -->
<div class="page-section">
<div class="section-header">
<h2>🎨 主题设置</h2>
</div>
<div class="page-card">
<div class="form-group">
<label class="switch-card">
<div class="switch-content">
<span class="switch-title">暗色模式</span>
<span class="switch-desc">启用深色主题界面</span>
</div>
<label class="switch">
<input v-model="isDarkMode" type="checkbox" @change="toggleTheme" />
<span class="slider"></span>
</label>
</label>
</div>
</div>
</div>
<!-- 默认模型设置 -->
<div class="page-section">
<div class="section-header">
<h2>🤖 模型设置</h2>
</div>
<div class="page-card">
<div class="form-group">
<label>默认 Provider</label>
<select v-model="modelSettings.default_provider">
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.default_model }})
</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>温度 (Temperature)</label>
<input v-model.number="modelSettings.temperature" type="number" min="0" max="2" step="0.1" />
<span class="hint">控制随机性较低值更确定较高值更有创造性</span>
</div>
<div class="form-group">
<label>最大 Tokens</label>
<input v-model.number="modelSettings.max_tokens" type="number" min="100" max="32000" />
<span class="hint">单次回复最大 token </span>
</div>
</div>
<div class="form-group">
<label class="switch-card">
<div class="switch-content">
<span class="switch-title">启用推理模式</span>
<span class="switch-desc">使用 CoT 推理消耗更多 token 但更准确</span>
</div>
<label class="switch">
<input v-model="modelSettings.thinking_enabled" type="checkbox" />
<span class="slider"></span>
</label>
</label>
</div>
<button @click="saveModelSettings" class="btn-primary">保存设置</button>
</div>
</div>
<!-- 系统提示词 -->
<div class="page-section">
<div class="section-header">
<h2>💬 系统提示词</h2>
</div>
<div class="page-card">
<div class="form-group">
<label>默认系统提示词</label>
<textarea v-model="modelSettings.system_prompt" rows="4" placeholder="You are a helpful assistant."></textarea>
<span class="hint">设置默认系统提示词可在新建会话时覆盖</span>
</div>
<button @click="saveSystemPrompt" class="btn-primary">保存提示词</button>
</div>
</div>
<!-- LLM Provider 管理 -->
<div class="page-section">
<div class="section-header">
<h2>🔌 LLM Provider</h2>
<button @click="showModal = true" class="btn-primary">+ 添加</button>
</div>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="!providers.length" class="empty-card">
<p>暂无 Provider点击上方按钮添加</p>
</div>
<div v-else class="page-grid">
<div v-for="p in providers" :key="p.id" class="page-card provider-card">
<div class="card-header">
<div class="card-title">
<h3>{{ p.name }}</h3>
<span v-if="p.is_default" class="badge default">默认</span>
<span v-if="p.enabled" class="badge enabled">启用</span>
<span v-else class="badge disabled">禁用</span>
</div>
<label class="switch">
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" />
<span class="slider"></span>
</label>
</div>
<div class="info-list">
<div class="info-item">
<span class="label">API:</span>
<span class="value url">{{ p.base_url }}</span>
</div>
<div class="info-item">
<span class="label">模型:</span>
<span class="value">{{ p.default_model }}</span>
</div>
<div class="info-item">
<span class="label">最大Tokens:</span>
<span class="value">{{ p.max_tokens || 8192 }}</span>
</div>
</div>
<div class="card-actions">
<button @click="editProvider(p)">编辑</button>
<button @click="testProvider(p)" :disabled="testing === p.id">
{{ testing === p.id ? '测试中...' : '测试连接' }}
</button>
<button @click="deleteProvider(p)" class="btn-danger">删除</button>
</div>
</div>
</div>
<!-- 测试结果弹窗 -->
<div v-if="testResult !== null || testing" class="modal-overlay" @click.self="testResult = null; testing = null">
<div class="modal result-modal" :class="{ success: testResult?.success === true, error: testResult?.success === false, loading: testing }">
<div v-if="testing" class="result-loading">
<div class="spinner-large"></div>
<p>测试连接中...</p>
</div>
<template v-else>
<div class="result-icon">
<span v-if="testResult.success"></span>
<span v-else></span>
</div>
<h2>{{ testResult.success ? '连接成功' : '连接失败' }}</h2>
<pre v-if="testResult.json" class="result-json">{{ testResult.json }}</pre>
<div v-else class="result-message">{{ testResult.message }}</div>
<button @click="testResult = null" class="btn-primary">确定</button>
</template>
</div>
</div>
@ -32,64 +195,7 @@
</div>
</div>
<div class="section">
<div class="section-header">
<h2>LLM Provider</h2>
<button @click="showModal = true" class="btn-primary">+ 添加</button>
</div>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="!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">API:</span><span class="value">{{ p.base_url }}</span></div>
<div class="info-row"><span class="label">模型:</span><span class="value">{{ p.default_model }}</span></div>
</div>
<div class="card-actions">
<button @click="editProvider(p)">编辑</button>
<button @click="testProvider(p)" :disabled="testing === p.id">{{ testing === p.id ? '测试中...' : '测试' }}</button>
<button @click="deleteProvider(p)" class="btn-danger">删除</button>
</div>
</div>
</div>
<!-- 测试结果弹窗 -->
<div v-if="testResult !== null || testing" class="modal-overlay" @click.self="testResult = null; testing = null">
<div class="modal result-modal" :class="{ success: testResult?.success === true, error: testResult?.success === false, loading: testing }">
<div v-if="testing" class="result-loading">
<div class="spinner-large"></div>
<p>测试连接中...</p>
</div>
<template v-else>
<div class="result-icon">
<span v-if="testResult.success">&#10003;</span>
<span v-else>&#10007;</span>
</div>
<h2>{{ testResult.success ? '连接成功' : '连接失败' }}</h2>
<pre v-if="testResult.json" class="result-json">{{ testResult.json }}</pre>
<div v-else class="result-message">{{ testResult.message }}</div>
<button @click="testResult = null" class="btn-primary">确定</button>
</template>
</div>
</div>
<!-- 添加/编辑模态框 -->
<!-- 添加/编辑 Provider 模态框 -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<h2>{{ editing ? '编辑 Provider' : '添加 Provider' }}</h2>
@ -132,8 +238,8 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { providersAPI } from '../utils/api.js'
import { ref, onMounted, watch } from 'vue'
import { providersAPI, conversationsAPI } from '../utils/api.js'
import { useAuth } from '../utils/useAuth.js'
import { authAPI } from '../utils/api.js'
import { useRouter } from 'vue-router'
@ -154,6 +260,32 @@ const savingUser = ref(false)
const userFormError = ref('')
const loadingUser = ref(false)
const modelSettings = ref({
default_provider: null,
temperature: 0.7,
max_tokens: 8192,
thinking_enabled: false,
system_prompt: 'You are a helpful assistant.'
})
//
const isDarkMode = ref(localStorage.getItem('theme') === 'dark')
const toggleTheme = () => {
if (isDarkMode.value) {
document.documentElement.setAttribute('data-theme', 'dark')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.removeAttribute('data-theme')
localStorage.setItem('theme', 'light')
}
}
//
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark')
}
const fetchUserInfo = async () => {
loadingUser.value = true
try {
@ -195,6 +327,17 @@ const updateUser = async () => {
}
}
const saveModelSettings = async () => {
//
localStorage.setItem('modelSettings', JSON.stringify(modelSettings.value))
alert('模型设置已保存')
}
const saveSystemPrompt = async () => {
localStorage.setItem('defaultSystemPrompt', modelSettings.value.system_prompt)
alert('系统提示词已保存')
}
const providers = ref([])
const loading = ref(true)
const error = ref('')
@ -214,7 +357,14 @@ const fetchProviders = async () => {
error.value = ''
try {
const res = await providersAPI.list()
if (res.success) providers.value = res.data.providers || []
if (res.success) {
providers.value = res.data.providers || []
// provider
const defaultProvider = providers.value.find(p => p.is_default)
if (defaultProvider && !modelSettings.value.default_provider) {
modelSettings.value.default_provider = defaultProvider.id
}
}
else throw new Error(res.message)
} catch (e) { error.value = e.message }
finally { loading.value = false }
@ -309,85 +459,105 @@ const toggleEnabled = async (p) => {
onMounted(() => {
fetchUserInfo()
fetchProviders()
//
const savedSettings = localStorage.getItem('modelSettings')
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings)
modelSettings.value = { ...modelSettings.value, ...parsed }
} catch (e) {}
}
const savedPrompt = localStorage.getItem('defaultSystemPrompt')
if (savedPrompt) {
modelSettings.value.system_prompt = savedPrompt
}
})
</script>
<style scoped>
.settings { padding: 0; }
.settings h1 { font-size: 2rem; margin: 0 0 2rem; color: var(--text-h); }
.section { margin-bottom: 2rem; }
.section h2 { font-size: 1.25rem; margin: 0; color: var(--text-h); }
.user-info-card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; margin-top: 1rem; }
.user-info-card .info-item { display: flex; gap: 1rem; margin-bottom: 0.75rem; }
.user-info-card .info-item:last-of-type { margin-bottom: 1rem; }
.user-info-card .label { color: var(--text); min-width: 60px; }
.user-info-card .value { color: var(--text-h); font-weight: 500; }
.user-info-card .card-actions { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.optional { color: var(--text); font-weight: normal; font-size: 0.85rem; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.section-header h2 { margin: 0; }
.section-header .btn-primary { min-width: 80px; text-align: center; }
.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: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; transition: all 0.2s; }
.btn-danger:hover { background: var(--accent); color: white; }
.loading, .empty, .error { text-align: center; padding: 4rem; color: var(--text); }
.error { background: var(--accent-bg); border-radius: 12px; color: var(--accent); }
.loading-small { padding: 2rem; text-align: center; color: var(--text); }
.spinner-small { width: 24px; height: 24px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; }
.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; display: flex; flex-direction: column; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 2px solid var(--border-light); }
.section-header h2 { font-size: 1.1rem; margin: 0; color: var(--text-primary); font-weight: 700; }
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.card-title { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.card-title h3 { margin: 0; color: var(--text-h); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.75rem; background: var(--code-bg); color: var(--text); flex-shrink: 0; }
.badge.default { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; }
.switch { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
.card-title { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.card-title h3 { margin: 0; color: var(--text-h); }
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.info-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.info-item { display: flex; gap: 0.75rem; font-size: 0.9rem; }
.info-item .label { color: var(--text); min-width: 70px; flex-shrink: 0; }
.info-item .value { color: var(--text-h); }
.info-item .value.url { font-size: 0.8rem; word-break: break-all; }
.card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
.form-group { margin-bottom: 1rem; }
.form-group:last-child { margin-bottom: 0; }
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-h); font-size: 0.9rem; }
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 0.65rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.9rem;
background: var(--bg);
box-sizing: border-box;
color: var(--text-h);
}
.form-group textarea { resize: vertical; min-height: 100px; }
.form-group .hint { font-size: 0.75rem; color: var(--text); margin-top: 4px; display: block; }
.switch-card { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); }
.switch-card:hover { border-color: var(--accent); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); }
.switch-card.active { border-color: var(--accent); background: var(--accent-bg); }
.switch-content { display: flex; flex-direction: column; gap: 0.25rem; }
.switch-title { font-weight: 600; color: var(--text-primary); }
.switch-desc { font-size: 0.8rem; color: var(--text-secondary); }
.switch-title { font-weight: 500; color: var(--text-h); font-size: 0.9rem; }
.switch-desc { font-size: 0.75rem; color: var(--text); }
.switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; }
.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; flex: 1; }
.info-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.info-row .label { color: var(--text); min-width: 50px; flex-shrink: 0; }
.info-row .value { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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; min-width: 60px; text-align: center; }
.card-actions button.btn-primary { background: var(--accent); color: white; border: none; }
.card-actions button.btn-danger { background: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); }
.card-actions button.btn-danger:hover { background: var(--accent); color: white; }
.slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 24px; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
input:checked + .slider { background-color: var(--accent); }
input:checked + .slider:before { transform: translateX(20px); }
.badge { padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.7rem; font-weight: 500; }
.badge.default { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; }
.badge.enabled { background: #dcfce7; color: #16a34a; }
.badge.disabled { background: var(--bg); color: var(--text); }
.provider-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.btn-primary { padding: 0.6rem 1.2rem; background: #2563eb; color: #ffffff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.85rem; font-weight: 500; }
.btn-primary:hover { background: #1d4ed8; }
.btn-secondary { padding: 0.6rem 1.2rem; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; font-size: 0.85rem; }
.btn-danger { padding: 0.5rem 1rem; background: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.btn-danger:hover { background: var(--accent); color: white; }
.btn-danger-outline { padding: 0.6rem 1.2rem; background: transparent; color: var(--accent); border: 1px solid var(--accent-border); border-radius: 8px; cursor: pointer; font-size: 0.85rem; }
.btn-danger-outline:hover { background: var(--accent-bg); }
.card-actions button { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.card-actions button.btn-primary, .card-actions button.btn-secondary { all: unset; }
.card-actions button.btn-primary { padding: 0.6rem 1.2rem; background: #2563eb; color: #ffffff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.85rem; font-weight: 500; display: inline-block; }
.card-actions button.btn-primary:hover { background: #1d4ed8; }
.card-actions button:not(.btn-primary):not(.btn-secondary):hover { background: var(--border); }
.loading, .empty-card, .error { text-align: center; padding: 3rem; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
.error { background: var(--accent-bg); color: var(--accent); }
.loading-small { padding: 1.5rem; text-align: center; color: var(--text); }
.spinner-small { width: 24px; height: 24px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 0.5rem; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
.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 { background: var(--bg); border-radius: 16px; padding: 1.5rem; width: 100%; max-width: 480px; }
.modal h2 { margin: 0 0 1.25rem; color: var(--text-h); font-size: 1.1rem; }
.result-modal { text-align: center; }
.result-modal.loading { min-height: 150px; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.result-loading { display: flex; flex-direction: column; align-items: center; gap: 1rem; }
.spinner-large { width: 48px; height: 48px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; }
.result-modal .result-icon { width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 2rem; }
.result-modal.loading { min-height: 120px; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.result-loading { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
.spinner-large { width: 40px; height: 40px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; }
.result-modal .result-icon { width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 1.5rem; }
.result-modal.success .result-icon { background: #dcfce7; color: #16a34a; }
.result-modal.error .result-icon { background: var(--accent-bg); color: var(--accent); }
.result-modal h2 { margin: 0 0 0.5rem; color: var(--text-h); }
.result-modal .result-status { color: var(--accent); font-size: 0.9rem; margin-bottom: 0.5rem; font-weight: 500; }
.result-modal .result-message { color: var(--text); margin-bottom: 1.5rem; }
.result-modal .result-json { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin: 1rem 0; max-height: 200px; overflow: auto; font-size: 0.75rem; text-align: left; white-space: pre-wrap; word-break: break-all; color: var(--text-h); }
.result-modal .btn-primary { width: 100%; }
.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; }
.switch-card { display: flex; align-items: center; justify-content: space-between; }
.switch-card input { display: none; }
.switch-content { display: flex; flex-direction: column; gap: 0.25rem; }
.switch-title { font-weight: 600; color: var(--text-h); }
.switch-desc { font-size: 0.85rem; color: var(--text); }
.switch-card .switch { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
.switch-card .switch input { opacity: 0; width: 0; height: 0; }
.switch-card .slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 26px; }
.switch-card .slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
.switch-card input:checked + .slider { background-color: var(--accent); }
.switch-card input:checked + .slider:before { transform: translateX(22px); }
.modal-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1.5rem; }
.form-group .hint { font-size: 0.85rem; color: var(--text); margin-top: 4px; display: block; }
.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; }
.result-modal h2 { margin: 0 0 0.75rem; }
.result-modal .result-message { color: var(--text); margin-bottom: 1rem; font-size: 0.9rem; }
.result-modal .result-json { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem; margin: 0.75rem 0; max-height: 150px; overflow: auto; font-size: 0.75rem; text-align: left; white-space: pre-wrap; word-break: break-all; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; }
.optional { color: var(--text); font-weight: normal; font-size: 0.8rem; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@ -1,35 +1,46 @@
<template>
<div class="tools">
<div class="page-container tools">
<div class="page-header">
<h1>工具管理</h1>
<div class="stats">
<div class="stat">{{ list.length }} 个工具</div>
<button @click="fetchData" :disabled="loading" class="btn-refresh">刷新</button>
<div class="subtitle">{{ list.length }} 个工具</div>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
<div v-else-if="error" class="error-msg">{{ error }} <button @click="fetchData">重试</button></div>
<div v-else class="grid">
<div v-for="tool in list" :key="tool.name" class="card">
<div class="card-header">
<span :class="['badge', tool.enabled ? 'enabled' : 'disabled']">{{ tool.enabled ? '已启用' : '已禁用' }}</span>
</div>
<h3>{{ tool.name }}</h3>
<p>{{ tool.description || '暂无描述' }}</p>
<button @click="showDetail(tool)" class="btn-info">查看详情</button>
</div>
</div>
<div v-if="detail" class="modal-overlay" @click.self="detail = null">
<div class="modal">
<h2>{{ detail.name }}</h2>
<p class="desc">{{ detail.description }}</p>
<div v-if="detail.parameters" class="params">
<h4>参数</h4>
<pre><code>{{ JSON.stringify(detail.parameters, null, 2) }}</code></pre>
</div>
<button @click="detail = null" class="btn-close">关闭</button>
</div>
<div v-else class="table-container">
<table class="tools-table">
<thead>
<tr>
<th>名称</th>
<th>参数</th>
<th class="action-col">启用</th>
</tr>
</thead>
<tbody>
<tr v-for="tool in list" :key="tool.name">
<td class="name-col">
<div class="tool-name">{{ tool.name }}</div>
<div class="tool-desc">{{ tool.description || '-' }}</div>
<div class="tool-category">{{ tool.category }}</div>
</td>
<td class="params-col">
<template v-if="tool.parameters && tool.parameters.properties">
<span v-for="(val, key) in tool.parameters.properties" :key="key" class="param-tag">
{{ key }}
</span>
</template>
<span v-else>-</span>
</td>
<td class="switch-col">
<label class="switch" @click.prevent="toggleEnabled(tool)">
<input type="checkbox" :checked="tool.enabled" />
<span class="slider"></span>
</label>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@ -41,7 +52,6 @@ import { toolsAPI } from '../utils/api.js'
const list = ref([])
const loading = ref(true)
const error = ref('')
const detail = ref(null)
const fetchData = async () => {
loading.value = true
@ -55,7 +65,7 @@ const fetchData = async () => {
if (Array.isArray(data[cat])) {
all.push(...data[cat].map(t => {
const func = t.function ? t.function : t
return { name: func.name, description: func.description, parameters: func.parameters, category: cat, enabled: true }
return { name: func.name, description: func.description, parameters: func.parameters, category: cat, enabled: t.enabled !== false }
}))
}
})
@ -65,44 +75,34 @@ const fetchData = async () => {
finally { loading.value = false }
}
const showDetail = (tool) => { detail.value = tool }
const toggleEnabled = async (tool) => {
tool.enabled = !tool.enabled
}
onMounted(fetchData)
</script>
<style scoped>
.tools {
padding: 0;
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.tools > * { flex-shrink: 0; }
.grid { flex: 1; min-height: 0; }
.tools h1 { font-size: 2rem; margin: 0 0 1.5rem; color: var(--text-h); }
.stats { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
.stat { font-size: 1.1rem; color: var(--text); }
.btn-refresh { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; color: var(--accent); }
.loading, .error-msg { text-align: center; padding: 4rem; color: var(--text); }
.error-msg { background: var(--accent-bg); border-radius: 12px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.8rem; font-weight: 500; flex-shrink: 0; }
.badge.enabled { background: var(--accent-bg); color: var(--accent); }
.badge.disabled { background: var(--code-bg); color: var(--text); }
.card h3 { margin: 0 0 0.75rem; font-size: 1.25rem; color: var(--text-h); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card p { color: var(--text); font-size: 0.95rem; margin: 0 0 1rem; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; flex: 1; }
.btn-info { background: transparent; border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 1rem; cursor: pointer; color: var(--accent); align-self: flex-start; }
.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: 600px; max-height: 80vh; overflow-y: auto; }
.modal h2 { margin: 0 0 1rem; color: var(--text-h); }
.desc { color: var(--text); margin: 0 0 1.5rem; }
.params h4 { margin: 0 0 0.75rem; color: var(--text-h); }
.params pre { background: var(--code-bg); padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0; }
.params code { font-size: 0.85rem; color: var(--text-h); }
.btn-close { margin-top: 1.5rem; padding: 0.75rem 1.5rem; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
.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; }
.table-container { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
.tools-table { width: 100%; border-collapse: collapse; }
.tools-table th { text-align: left; padding: 1rem; background: var(--bg-secondary); font-weight: 600; font-size: 0.85rem; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); }
.tools-table td { padding: 1rem; border-bottom: 1px solid var(--border-light); vertical-align: middle; }
.tools-table tr:last-child td { border-bottom: none; }
.tools-table tr:hover td { background: var(--bg-secondary); }
.tool-name { font-weight: 600; font-size: 0.95rem; }
.tool-desc { font-size: 0.8rem; color: var(--text-secondary); margin: 0.25rem 0; }
.tool-category { font-size: 0.7rem; color: var(--text-tertiary); }
.params-col { width: 180px; }
.param-tag { display: inline-block; padding: 0.2rem 0.5rem; background: var(--bg-code); border-radius: 4px; font-size: 0.75rem; color: var(--text-secondary); margin: 0.15rem; }
.switch-col { text-align: center; }
.switch { position: relative; display: inline-block; width: 44px; height: 24px; cursor: pointer; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 24px; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; 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(20px); }
.loading { text-align: center; padding: 4rem; }
.error-msg { text-align: center; padding: 2rem; color: var(--accent-primary); background: var(--accent-primary-light); border-radius: 12px; }
.spinner { width: 40px; height: 40px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@ -1,5 +1,6 @@
"""Message routes"""
import json
from typing import List, Optional
from fastapi import APIRouter, Depends, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
@ -20,6 +21,7 @@ class MessageCreate(BaseModel):
"""Create message model"""
conversation_id: str
content: str
thinking_enabled: bool = False
class MessageResponse(BaseModel):
@ -113,7 +115,6 @@ def send_message(
@router.post("/stream")
async def stream_message(
data: MessageCreate,
tools_enabled: bool = True,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
@ -141,7 +142,7 @@ async def stream_message(
async for sse_str in chat_service.stream_response(
conversation=conversation,
user_message=data.content,
tools_enabled=tools_enabled
thinking_enabled=data.thinking_enabled
):
# Chat service returns raw SSE strings (including done event)
yield sse_str

View File

@ -1,7 +1,7 @@
"""Chat service module"""
import json
import uuid
from typing import List, Dict, Any, AsyncGenerator
from typing import List, Dict, Any, AsyncGenerator, Optional
from luxx.models import Conversation, Message
from luxx.tools.executor import ToolExecutor
@ -97,7 +97,7 @@ class ChatService:
self,
conversation: Conversation,
user_message: str,
tools_enabled: bool = True
thinking_enabled: bool = False
) -> AsyncGenerator[Dict[str, str], None]:
"""
Streaming response generator
@ -112,7 +112,8 @@ class ChatService:
"content": json.dumps({"text": user_message, "attachments": []})
})
tools = registry.list_all() if tools_enabled else None
# Get all available tools
tools = registry.list_all()
llm, provider_max_tokens = get_llm_client(conversation)
model = conversation.model or llm.default_model or "gpt-4"
@ -150,7 +151,8 @@ class ChatService:
messages=messages,
tools=tools,
temperature=conversation.temperature,
max_tokens=max_tokens or 8192
max_tokens=max_tokens or 8192,
thinking_enabled=thinking_enabled or conversation.thinking_enabled
):
# Parse SSE line
# Format: "event: xxx\ndata: {...}\n\n"
@ -179,11 +181,31 @@ class ChatService:
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
continue
yield _sse_event("error", {"content": f"Failed to parse response: {data_str}"})
return
# Check for error in response
if "error" in chunk:
error_msg = chunk["error"].get("message", str(chunk["error"]))
yield _sse_event("error", {"content": f"API Error: {error_msg}"})
return
# Get delta
choices = chunk.get("choices", [])
if not choices:
# Check if there's any content in the response
if chunk.get("content") or chunk.get("message"):
content = chunk.get("content") or chunk.get("message", {}).get("content", "")
if content:
yield _sse_event("process_step", {
"step": {
"id": f"step-{step_index}",
"index": step_index,
"type": "text",
"content": content
}
})
step_index += 1
continue
delta = choices[0].get("delta", {})

View File

@ -78,6 +78,9 @@ class LLMClient:
if "max_tokens" in kwargs:
body["max_tokens"] = kwargs["max_tokens"]
if "thinking_enabled" in kwargs and kwargs["thinking_enabled"]:
body["thinking_enabled"] = True
if tools:
body["tools"] = tools