feat: 完成全量功能开发,包括前端管理后台与后端服务优化
此提交实现了完整的知识库管理系统: 1. 新增Vue3 + Antd Vue前端管理后台,包含登录、文档管理、检索、类目设置等完整页面 2. 重构后端LLM调用抽象层,支持Ollama与OpenAI兼容服务动态切换 3. 调整默认嵌入模型配置为本地bge-m3模式 4. 优化入库任务去重逻辑与缓存清理机制 5. 完善Docker镜像构建与docker-compose部署配置 6. 修复多项测试用例与兼容性问题 7. 新增运行时配置API,支持动态调整系统参数
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
<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="例如 qwen2.5:1.5b"
|
||||
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>
|
||||
Reference in New Issue
Block a user