Files
QMDSearch/frontend/src/views/Settings.vue
T
kplam a1b80566bb feat: 升级 Ollama 本地模型 qwen2.5:1.5b → qwen3:1.7b
- config.py / .env.example / docker-compose.yml:默认模型改为 qwen3:1.7b(备选 qwen3:4b),compose 同步拉取
- 经 runtime_settings 注入 summarize/query/classify 三用途,改默认值即全局生效
- README / CLAUDE.md / 前端 Settings.vue 占位符同步更新
2026-08-01 00:28:50 +08:00

490 lines
14 KiB
Vue

<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { storeToRefs } from 'pinia'
import {
RobotOutlined,
FileTextOutlined,
FilterOutlined,
ReloadOutlined,
SaveOutlined,
UndoOutlined,
LockOutlined
} from '@ant-design/icons-vue'
import {
get as getSettings,
update as updateSettings,
schema as getSettingsSchema,
reset as resetSettings
} from '@/api/settings'
import { useAuthStore } from '@/stores/useAuthStore'
const authStore = useAuthStore()
const { isAdmin } = storeToRefs(authStore)
const activeTab = ref('models')
const isLoading = ref(false)
const isSaving = ref(false)
const isResetting = ref(false)
const schemaData = ref(null)
const DEFAULT_MODEL_CONFIG = {
provider: 'ollama',
base_url: '',
api_key: '',
model: '',
timeout: 120,
temperature: 0.3
}
const form = reactive({
models: {
summarize: { ...DEFAULT_MODEL_CONFIG },
query: { ...DEFAULT_MODEL_CONFIG },
classify: { ...DEFAULT_MODEL_CONFIG }
},
parsers: {
ocr: { plugin: '', params: {} },
pdf: { plugin: '', params: {} },
docx: { plugin: '', params: {} }
},
dedup: {
strategy: 'sha256',
simhash_threshold: 3,
ttl_seconds: 86400
}
})
const modelKeys = ['summarize', 'query', 'classify']
const modelMeta = {
summarize: {
label: '总结模型',
key: 'summarize',
desc: '用于文档三级总结',
icon: RobotOutlined,
color: '#1677ff'
},
query: {
label: '查询模型',
key: 'query',
desc: '用于 query 解析与重写',
icon: FilterOutlined,
color: '#722ed1'
},
classify: {
label: '分类模型',
key: 'classify',
desc: '用于文档分类判定',
icon: FileTextOutlined,
color: '#13c2c2'
}
}
const llmProviderOptions = computed(() => {
return (schemaData.value?.llm_providers || ['ollama', 'openai_compatible']).map(
(p) => ({ label: p, value: p })
)
})
const ocrPluginOptions = computed(() =>
(schemaData.value?.ocr_plugins || []).map((p) => ({ label: p, value: p }))
)
const pdfPluginOptions = computed(() =>
(schemaData.value?.pdf_plugins || []).map((p) => ({ label: p, value: p }))
)
const docxPluginOptions = computed(() =>
(schemaData.value?.docx_plugins || []).map((p) => ({ label: p, value: p }))
)
const dedupStrategyOptions = computed(() =>
(schemaData.value?.dedup_strategies || ['none', 'sha256', 'simhash']).map((s) => ({
label: s,
value: s
}))
)
const isSimhash = computed(() => form.dedup.strategy === 'simhash')
function applySettingsToForm(cfg) {
if (!cfg || typeof cfg !== 'object') return
if (cfg.models && typeof cfg.models === 'object') {
for (const key of modelKeys) {
const src = cfg.models[key] || {}
form.models[key] = {
provider: src.provider ?? DEFAULT_MODEL_CONFIG.provider,
base_url: src.base_url ?? '',
api_key: src.api_key ?? '',
model: src.model ?? '',
timeout: src.timeout ?? 120,
temperature: src.temperature ?? 0.3
}
}
}
if (cfg.parsers && typeof cfg.parsers === 'object') {
const ocr = cfg.parsers.ocr || {}
const pdf = cfg.parsers.pdf || {}
const docx = cfg.parsers.docx || {}
form.parsers.ocr = { plugin: ocr.plugin ?? '', params: ocr.params || {} }
form.parsers.pdf = { plugin: pdf.plugin ?? '', params: pdf.params || {} }
form.parsers.docx = { plugin: docx.plugin ?? '', params: docx.params || {} }
}
if (cfg.dedup && typeof cfg.dedup === 'object') {
form.dedup.strategy = cfg.dedup.strategy ?? 'sha256'
form.dedup.simhash_threshold = cfg.dedup.simhash_threshold ?? 3
form.dedup.ttl_seconds = cfg.dedup.ttl_seconds ?? 86400
}
}
function buildPayload() {
return {
models: {
summarize: { ...form.models.summarize },
query: { ...form.models.query },
classify: { ...form.models.classify }
},
parsers: {
ocr: { plugin: form.parsers.ocr.plugin, params: { ...form.parsers.ocr.params } },
pdf: { plugin: form.parsers.pdf.plugin, params: { ...form.parsers.pdf.params } },
docx: { plugin: form.parsers.docx.plugin, params: { ...form.parsers.docx.params } }
},
dedup: {
strategy: form.dedup.strategy,
simhash_threshold: form.dedup.simhash_threshold,
ttl_seconds: form.dedup.ttl_seconds
}
}
}
async function loadAll() {
isLoading.value = true
try {
const [cfg, schema] = await Promise.all([getSettings(), getSettingsSchema()])
schemaData.value = schema
applySettingsToForm(cfg)
} catch (err) {
message.error(err?.message || '加载设置失败')
} finally {
isLoading.value = false
}
}
async function handleSave() {
if (!isAdmin.value) {
message.error('仅管理员可修改设置')
return
}
isSaving.value = true
try {
const payload = buildPayload()
const cfg = await updateSettings(payload)
applySettingsToForm(cfg)
message.success('设置已保存')
} catch (err) {
message.error(err?.message || '保存设置失败')
} finally {
isSaving.value = false
}
}
function handleReset() {
if (!isAdmin.value) {
message.error('仅管理员可重置设置')
return
}
Modal.confirm({
title: '确认重置',
content: '确定要将所有运行时设置重置为默认值吗?此操作不可撤销。',
okText: '重置',
okType: 'danger',
cancelText: '取消',
async onOk() {
isResetting.value = true
try {
const cfg = await resetSettings()
applySettingsToForm(cfg)
message.success('已重置为默认值')
} catch (err) {
message.error(err?.message || '重置失败')
} finally {
isResetting.value = false
}
}
})
}
onMounted(() => {
loadAll()
})
</script>
<template>
<div class="settings page-section">
<div class="settings__header">
<h2 class="page-title">设置</h2>
<a-space>
<a-button :loading="isLoading" @click="loadAll">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button
type="primary"
:loading="isSaving"
:disabled="!isAdmin"
@click="handleSave"
>
<template #icon><SaveOutlined /></template>
保存
</a-button>
<a-button
danger
:loading="isResetting"
:disabled="!isAdmin"
@click="handleReset"
>
<template #icon><UndoOutlined /></template>
重置默认
</a-button>
</a-space>
</div>
<a-alert
v-if="!isAdmin"
class="settings__alert"
type="info"
show-icon
message="当前用户非管理员,仅可查看设置;保存与重置需 admin 权限。"
/>
<a-spin :spinning="isLoading">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="models" tab="模型配置">
<div class="settings__cards">
<a-card
v-for="key in modelKeys"
:key="key"
class="settings__card"
:body-style="{ padding: '16px 18px' }"
>
<template #title>
<div class="settings__card-title">
<span
class="settings__card-icon"
:style="{ background: modelMeta[key].color }"
>
<component :is="modelMeta[key].icon" />
</span>
<div class="settings__card-head">
<div class="settings__card-name">{{ modelMeta[key].label }}</div>
<div class="settings__card-desc">{{ modelMeta[key].desc }}</div>
</div>
</div>
</template>
<a-form layout="vertical" :colon="false">
<a-form-item label="provider">
<a-radio-group v-model:value="form.models[key].provider" button-style="solid">
<a-radio
v-for="opt in llmProviderOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-radio>
</a-radio-group>
</a-form-item>
<a-row :gutter="12">
<a-col :span="24">
<a-form-item label="base_url">
<a-input
v-model:value="form.models[key].base_url"
placeholder="例如 http://localhost:11434"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item
v-if="form.models[key].provider !== 'ollama'"
label="api_key"
>
<a-input-password
v-model:value="form.models[key].api_key"
placeholder="无则留空"
allow-clear
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-row :gutter="12">
<a-col :span="14">
<a-form-item label="model">
<a-input
v-model:value="form.models[key].model"
placeholder="例如 qwen3:1.7b"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item label="timeout (秒)">
<a-input-number
v-model:value="form.models[key].timeout"
:min="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="temperature">
<a-input-number
v-model:value="form.models[key].temperature"
:min="0"
:max="2"
:step="0.1"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-card>
</div>
</a-tab-pane>
<a-tab-pane key="parsers" tab="解析插件">
<a-form layout="vertical" class="settings__parsers">
<a-form-item label="OCR 插件">
<a-select
v-model:value="form.parsers.ocr.plugin"
:options="ocrPluginOptions"
placeholder="选择 OCR 插件"
allow-clear
/>
</a-form-item>
<a-form-item label="PDF 插件">
<a-select
v-model:value="form.parsers.pdf.plugin"
:options="pdfPluginOptions"
placeholder="选择 PDF 插件"
allow-clear
/>
</a-form-item>
<a-form-item label="DOCX 插件">
<a-select
v-model:value="form.parsers.docx.plugin"
:options="docxPluginOptions"
placeholder="选择 DOCX 插件"
allow-clear
/>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="dedup" tab="去重策略">
<a-form layout="vertical" class="settings__dedup">
<a-form-item label="strategy">
<a-select
v-model:value="form.dedup.strategy"
:options="dedupStrategyOptions"
placeholder="选择去重策略"
/>
</a-form-item>
<a-form-item label="simhash_threshold (0~64)">
<a-input-number
v-model:value="form.dedup.simhash_threshold"
:min="0"
:max="64"
:disabled="!isSimhash"
style="width: 100%"
/>
<span v-if="!isSimhash" class="text-muted" style="margin-top: 4px; display: inline-block">
strategy=simhash 时启用
</span>
</a-form-item>
<a-form-item label="ttl_seconds">
<a-input-number
v-model:value="form.dedup.ttl_seconds"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-spin>
</div>
</template>
<style scoped>
.settings__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.settings__alert {
margin-bottom: 12px;
}
.settings__cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.settings__card {
border-radius: 8px;
border: 1px solid #e5e7eb;
transition: box-shadow 0.2s;
}
.settings__card:hover {
box-shadow: 0 4px 12px rgba(0, 21, 41, 0.06);
}
.settings__card :deep(.ant-card-head) {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
min-height: auto;
}
.settings__card-title {
display: flex;
align-items: center;
gap: 10px;
}
.settings__card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 6px;
color: #fff;
font-size: 16px;
flex: 0 0 auto;
}
.settings__card-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings__card-name {
font-size: 14px;
font-weight: 600;
color: #1f2937;
line-height: 1.2;
}
.settings__card-desc {
font-size: 12px;
color: #6b7280;
line-height: 1.2;
}
.settings__parsers,
.settings__dedup {
max-width: 480px;
}
</style>