a1b80566bb
- config.py / .env.example / docker-compose.yml:默认模型改为 qwen3:1.7b(备选 qwen3:4b),compose 同步拉取 - 经 runtime_settings 注入 summarize/query/classify 三用途,改默认值即全局生效 - README / CLAUDE.md / 前端 Settings.vue 占位符同步更新
490 lines
14 KiB
Vue
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>
|