feat: 优化样式
This commit is contained in:
parent
eef23e2e94
commit
a65356113e
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">✓</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>
|
||||
|
||||
<!-- 添加/编辑模态框 -->
|
||||
<!-- 添加/编辑 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue