neural-sync/frontend/src/components/LLMSettings.vue

719 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="settings-page">
<!-- 左侧窄边栏 -->
<aside class="settings-sidebar">
<nav class="sidebar-nav">
<a
v-for="item in navItems"
:key="item.key"
:class="['nav-item', { active: activeTab === item.key }]"
@click="activeTab = item.key"
>
<component :is="item.icon" :size="18" :stroke-width="1.5" />
<span>{{ item.label }}</span>
</a>
</nav>
<!-- 底部状态 -->
<div class="sidebar-footer">
<div class="connection-status">
<span :class="['status-dot', { connected: isConnected }]"></span>
<span class="status-text">{{ statusText }}</span>
</div>
</div>
</aside>
<!-- 主内容区 -->
<main class="settings-main">
<div class="settings-content">
<!-- 页面标题 -->
<header class="page-header">
<h1>设置</h1>
<p class="page-description">配置 AI 模型提供商和连接参数</p>
</header>
<!-- 分割线 -->
<hr class="divider">
<!-- 供应商选择 -->
<section class="settings-section">
<h2 class="section-title">模型提供商</h2>
<!-- Segmented Control 风格 -->
<div class="provider-selector">
<button
v-for="provider in providers"
:key="provider.value"
:class="['provider-btn', { active: config.provider === provider.value }]"
@click="selectProvider(provider.value)"
>
{{ provider.label }}
</button>
</div>
<!-- 选中提供商说明 -->
<div class="provider-info" v-if="selectedProvider">
<span class="provider-desc">{{ selectedProvider.desc }}</span>
<span class="provider-pricing">{{ selectedProvider.pricing }}</span>
</div>
</section>
<!-- 连接配置 -->
<section class="settings-section">
<h2 class="section-title">连接配置</h2>
<!-- API 密钥配置 -->
<div v-if="showApiKey && selectedProvider" class="form-group">
<label class="form-label">API 密钥</label>
<input
v-model="apiKey"
type="password"
class="form-input"
:placeholder="apiKeyPlaceholder"
>
<div class="form-hint">
获取地址:<a :href="selectedProvider.apiUrl" target="_blank" rel="noopener">{{ selectedProvider.apiLabel }}</a>
</div>
</div>
<!-- Ollama 地址配置 -->
<div v-if="config.provider === 'ollama'" class="form-group">
<label class="form-label">服务地址</label>
<input
v-model="config.ollama_host"
type="text"
class="form-input"
placeholder="http://127.0.0.1:11434"
>
<div class="form-actions">
<button class="btn-secondary" @click="testConnection" :disabled="testing">
{{ testing ? '测试中...' : '测试连接' }}
</button>
</div>
</div>
<!-- 高级选项(折叠) -->
<details class="advanced-options">
<summary>高级选项</summary>
<div class="advanced-content">
<div class="form-row">
<div class="form-group">
<label class="form-label">Temperature</label>
<input v-model.number="config.temperature" type="number" step="0.1" min="0" max="2" class="form-input">
</div>
<div class="form-group">
<label class="form-label">Max Tokens</label>
<input v-model.number="config.max_tokens" type="number" step="128" min="128" max="8192" class="form-input">
</div>
</div>
</div>
</details>
</section>
<!-- 底部操作栏 -->
<footer class="settings-footer">
<div class="footer-status">{{ saveStatus }}</div>
<div class="footer-actions">
<button class="btn-ghost" @click="resetConfig">重置</button>
<button class="btn-primary" @click="saveConfig" :disabled="saving">
{{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</footer>
</div>
</main>
<!-- 重启确认弹窗 -->
<div v-if="showRestartModal" class="modal-overlay" @click.self="showRestartModal = false">
<div class="modal">
<div class="modal-header">
<h3>需要重启后端</h3>
<button class="modal-close" @click="showRestartModal = false">×</button>
</div>
<div class="modal-body">
<p>LLM 配置已更改,需要重启后端服务才能生效。</p>
</div>
<div class="modal-footer">
<button class="btn-ghost" @click="showRestartModal = false">取消</button>
<button class="btn-warning" @click="restartBackend">确认重启</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
import axios from 'axios'
// Lucide-style icons (inline SVG)
const SettingsIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.5 }, [
h('path', { d: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z' }),
h('circle', { cx: '12', cy: '12', r: '3' })
])
const CloudIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.5 }, [
h('path', { d: 'M17.5 19c0-1.7-1.3-3-3-3h-11c-1.7 0-3 1.3-3 3s1.3 3 3 3h11c1.7 0 3-1.3 3-3z' }),
h('path', { d: 'M17.5 16c2.5 0 4.5-2 4.5-4.5S20 7 17.5 7c-.4 0-.8.1-1.2.2C15.6 5.7 13.9 5 12 5c-2.8 0-5.1 2-5.7 4.7-.5-.2-1-.2-1.5-.2-2.5 0-4.5 2-4.5 4.5s2 4.5 4.5 4.5h12.2z' })
])
const KeyIcon = () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.5 }, [
h('path', { d: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' })
])
// 侧边栏导航
const navItems = [
{ key: 'provider', label: '模型服务', icon: CloudIcon },
{ key: 'general', label: '通用设置', icon: SettingsIcon },
{ key: 'advanced', label: '高级选项', icon: KeyIcon }
]
const activeTab = ref('provider')
// 配置数据
const config = reactive({
provider: 'deepseek',
deepseek_api_key: '',
openai_api_key: '',
qwen_api_key: '',
ollama_host: 'http://127.0.0.1:11434',
temperature: 0.7,
max_tokens: 2048
})
// 提供商数据
const providers = [
{ value: 'deepseek', label: 'DeepSeek', desc: '高质量、低成本的大语言模型', pricing: '¥1/百万 tokens', apiUrl: 'https://platform.deepseek.com/api_keys', apiLabel: 'platform.deepseek.com' },
{ value: 'openai', label: 'OpenAI', desc: '业界领先的 GPT 系列模型', pricing: '~$15/百万 tokens', apiUrl: 'https://platform.openai.com/api-keys', apiLabel: 'platform.openai.com' },
{ value: 'qwen', label: 'Qwen', desc: '阿里云通义千问系列', pricing: '¥0.5/百万 tokens', apiUrl: 'https://dashscope.console.aliyun.com/apiKey', apiLabel: 'dashscope.console.aliyun.com' },
{ value: 'ollama', label: 'Ollama', desc: '本地运行的开源模型', pricing: '免费', apiUrl: '', apiLabel: '' }
]
// 状态
const saving = ref(false)
const testing = ref(false)
const showRestartModal = ref(false)
const saveStatus = ref('')
const isConnected = ref(false)
const statusText = ref('未连接')
// 计算属性
const selectedProvider = computed(() => providers.find(p => p.value === config.provider) || providers[0])
const showApiKey = computed(() => ['deepseek', 'openai', 'qwen'].includes(config.provider))
const apiKey = computed({
get: () => {
if (config.provider === 'deepseek') return config.deepseek_api_key
if (config.provider === 'openai') return config.openai_api_key
if (config.provider === 'qwen') return config.qwen_api_key
return ''
},
set: (value: string) => {
if (config.provider === 'deepseek') config.deepseek_api_key = value
if (config.provider === 'openai') config.openai_api_key = value
if (config.provider === 'qwen') config.qwen_api_key = value
}
})
const apiKeyPlaceholder = computed(() => {
if (config.provider === 'deepseek') return 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
if (config.provider === 'openai') return 'sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
if (config.provider === 'qwen') return 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
return ''
})
// 方法
const selectProvider = (value: string) => {
config.provider = value
saveStatus.value = `已切换到 ${providers.find(p => p.value === value)?.label}`
}
const testConnection = async () => {
testing.value = true
try {
const response = await axios.get(`${config.ollama_host.replace(':11434', ':8000')}/ai/status`, { timeout: 5000 })
if (response.data.status === 'ready') {
isConnected.value = true
statusText.value = `${response.data.model_count} 个模型`
saveStatus.value = '连接成功'
} else {
isConnected.value = false
statusText.value = '未就绪'
}
} catch {
isConnected.value = false
statusText.value = '连接失败'
} finally {
testing.value = false
}
}
const loadConfig = async () => {
try {
const response = await axios.get('http://127.0.0.1:8000/ai/config')
config.provider = response.data.provider
config.ollama_host = response.data.ollama_host
} catch {}
}
const saveConfig = async () => {
saving.value = true
try {
await axios.post('http://127.0.0.1:8000/ai/config', {
provider: config.provider,
deepseek_api_key: config.deepseek_api_key || undefined,
openai_api_key: config.openai_api_key || undefined,
qwen_api_key: config.qwen_api_key || undefined
})
saveStatus.value = '配置已保存'
showRestartModal.value = true
} catch (error: any) {
saveStatus.value = '保存失败: ' + (error.response?.data?.detail || error.message)
} finally {
saving.value = false
}
}
const resetConfig = () => {
config.provider = 'deepseek'
config.temperature = 0.7
config.max_tokens = 2048
saveStatus.value = '配置已重置'
}
const restartBackend = async () => {
try {
await axios.post('http://127.0.0.1:8000/ai/stop')
saveStatus.value = '后端已停止,请手动启动'
showRestartModal.value = false
} catch {
saveStatus.value = '停止失败'
}
}
onMounted(() => {
loadConfig()
})
</script>
<style scoped>
/* 全局样式 */
.settings-page {
display: flex;
height: 100%;
background: #09090b;
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'PingFang SC', sans-serif;
}
/* 左侧边栏 */
.settings-sidebar {
width: 200px;
border-right: 1px solid #27272a;
display: flex;
flex-direction: column;
}
.sidebar-nav {
flex: 1;
padding: 16px 12px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
font-size: 14px;
color: #a1a1aa;
text-decoration: none;
border-radius: 6px;
margin-bottom: 2px;
cursor: pointer;
transition: all 0.15s ease;
}
.nav-item:hover {
background: #18181b;
color: #fafafa;
}
.nav-item.active {
background: #27272a;
color: #fafafa;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid #27272a;
}
.connection-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #71717a;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ef4444;
}
.status-dot.connected {
background: #22c55e;
}
/* 主内容区 */
.settings-main {
flex: 1;
display: flex;
justify-content: center;
overflow-y: auto;
}
.settings-content {
width: 100%;
max-width: 720px;
padding: 40px 48px 120px;
}
/* 页面标题 */
.page-header h1 {
font-size: 24px;
font-weight: 600;
color: #fafafa;
margin: 0 0 8px 0;
}
.page-description {
font-size: 14px;
color: #71717a;
margin: 0;
}
.divider {
border: none;
border-top: 1px solid #27272a;
margin: 24px 0 32px;
}
/* 设置区块 */
.settings-section {
margin-bottom: 40px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #a1a1aa;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 16px 0;
}
/* 供应商选择器 - Segmented Control 风格 */
.provider-selector {
display: flex;
background: #18181b;
border: 1px solid #27272a;
border-radius: 8px;
padding: 4px;
margin-bottom: 16px;
}
.provider-btn {
flex: 1;
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
color: #a1a1aa;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.provider-btn:hover {
color: #fafafa;
}
.provider-btn.active {
background: #27272a;
color: #fafafa;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.provider-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.provider-desc {
font-size: 13px;
color: #71717a;
}
.provider-pricing {
font-size: 12px;
color: #52525b;
padding: 4px 8px;
background: #18181b;
border-radius: 4px;
}
/* 表单 */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #a1a1aa;
margin-bottom: 8px;
}
.form-input {
width: 100%;
height: 36px;
padding: 0 12px;
font-size: 14px;
color: #fafafa;
background: #18181b;
border: 1px solid #27272a;
border-radius: 6px;
box-sizing: border-box;
transition: border-color 0.15s ease;
}
.form-input:focus {
outline: none;
border-color: #4f46e5;
}
.form-input::placeholder {
color: #52525b;
}
.form-hint {
font-size: 12px;
color: #52525b;
margin-top: 6px;
}
.form-hint a {
color: #52525b;
text-decoration: underline;
}
.form-hint a:hover {
color: #71717a;
}
.form-actions {
margin-top: 12px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* 高级选项 */
.advanced-options {
margin-top: 24px;
}
.advanced-options summary {
font-size: 13px;
font-weight: 500;
color: #71717a;
cursor: pointer;
user-select: none;
list-style: none;
}
.advanced-options summary::-webkit-details-marker {
display: none;
}
.advanced-options summary::before {
content: '▶';
font-size: 10px;
margin-right: 6px;
transition: transform 0.15s ease;
}
.advanced-options[open] summary::before {
transform: rotate(90deg);
}
.advanced-content {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #27272a;
}
/* 底部操作栏 */
.settings-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 24px;
border-top: 1px solid #27272a;
}
.footer-status {
font-size: 13px;
color: #71717a;
}
.footer-actions {
display: flex;
gap: 8px;
}
/* 按钮样式 */
.btn-ghost {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #a1a1aa;
background: transparent;
border: 1px solid #27272a;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-ghost:hover:not(:disabled) {
color: #fafafa;
border-color: #3f3f46;
}
.btn-secondary {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #fafafa;
background: #27272a;
border: 1px solid #3f3f46;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-secondary:hover:not(:disabled) {
background: #3f3f46;
}
.btn-primary {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #fafafa;
background: #4f46e5;
border: 1px solid #4f46e5;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary:hover:not(:disabled) {
background: #4338ca;
}
.btn-primary:disabled,
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-warning {
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: #fafafa;
background: #b91c1c;
border: 1px solid #b91c1c;
border-radius: 6px;
cursor: pointer;
}
.btn-warning:hover {
background: #991b1b;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
width: 100%;
max-width: 420px;
padding: 24px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-header h3 {
font-size: 16px;
font-weight: 600;
color: #fafafa;
margin: 0;
}
.modal-close {
width: 28px;
height: 28px;
font-size: 20px;
color: #71717a;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: #27272a;
}
.modal-body {
font-size: 14px;
color: #a1a1aa;
margin-bottom: 24px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>