feat: 增强知识库索引与设置页面模块化拆分

扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优
化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件
和 Hermes 员工同步子面板并重构样式,新增日志详情组件和
知识入库日志模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 23:47:28 +08:00
parent 88ff04bef8
commit 5b388d08c0
84 changed files with 10170 additions and 2599 deletions

View File

@@ -2,8 +2,12 @@ const EXPENSE_SCENE_SELECTION_OPTIONS = [
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline' },
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline' },
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline' },
{ key: 'entertainment', label: '业务招待费', description: '客户接待、餐饮招待等费用', icon: 'mdi mdi-food-fork-drink' },
{ key: 'office', label: '办公费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' },
{ key: 'meal', label: '业务招待费', description: '客户接待、工作餐、加班餐、餐饮票据等费用', icon: 'mdi mdi-food-fork-drink' },
{ key: 'meeting', label: '会务费', description: '会议、论坛、会场、参会等费用', icon: 'mdi mdi-account-tie-voice-outline' },
{ key: 'office', label: '办公用品费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' },
{ key: 'training', label: '培训费', description: '培训课程、讲师费、教材认证等费用', icon: 'mdi mdi-school-outline' },
{ key: 'communication', label: '通讯费', description: '话费、流量、宽带、网络等费用', icon: 'mdi mdi-cellphone-message' },
{ key: 'welfare', label: '福利费', description: '团建、体检、慰问、节日福利等费用', icon: 'mdi mdi-gift-outline' },
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
]

View File

@@ -0,0 +1,150 @@
/** 数字员工设置:面向管理员的简明任务列表(频率固定,仅可调执行时间) */
export const HERMES_SIMPLE_TASKS = [
{
id: 'knowledgeAggregation',
label: '知识库同步',
hint: '同步制度文档与知识索引',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'ruleReviewDigest',
label: '规则待审提醒',
hint: '汇总待审规则并推送管理员',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'riskSummary',
label: '风险每日巡检',
hint: '扫描报销、付款等风险信号',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'archiveDigest',
label: '归档周报',
hint: '汇总已归档报销单',
frequency: 'weekly',
frequencyLabel: '每周一',
weekday: 1
},
{
id: 'dailyStats',
label: '日报统计',
hint: '生成昨日报销与审批数据',
frequency: 'daily',
frequencyLabel: '每天'
},
{
id: 'monthlyStats',
label: '月报统计',
hint: '每月 1 号生成上月汇总',
frequency: 'monthly',
frequencyLabel: '每月 1 日',
monthDay: 1
},
{
id: 'yearlyStats',
label: '年报统计',
hint: '每年 1 月 1 号生成上年汇总',
frequency: 'yearly',
frequencyLabel: '每年 1 月 1 日',
month: 1,
monthDay: 1
}
]
function buildDefaultSchedules() {
const defaults = {
knowledgeAggregation: { enabled: true, frequency: 'daily', time: '00:00', weekday: 1, monthDay: 1, month: 1 },
ruleReviewDigest: { enabled: true, frequency: 'daily', time: '18:00', weekday: 5, monthDay: 1, month: 1 },
riskSummary: { enabled: true, frequency: 'daily', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
archiveDigest: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 },
dailyStats: { enabled: true, frequency: 'daily', time: '08:30', weekday: 1, monthDay: 1, month: 1 },
monthlyStats: { enabled: true, frequency: 'monthly', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
yearlyStats: { enabled: false, frequency: 'yearly', time: '10:00', weekday: 1, monthDay: 1, month: 1 }
}
for (const task of HERMES_SIMPLE_TASKS) {
const schedule = defaults[task.id]
if (!schedule) {
continue
}
schedule.frequency = task.frequency
if (task.weekday != null) {
schedule.weekday = task.weekday
}
if (task.monthDay != null) {
schedule.monthDay = task.monthDay
}
if (task.month != null) {
schedule.month = task.month
}
}
return defaults
}
export function buildDefaultHermesEmployeeForm() {
return {
masterEnabled: true,
notifyOnFailure: true,
capabilities: {
knowledgeAggregation: true,
ruleReviewDigest: true,
riskSummary: true,
archiveDigest: false,
dailyStats: true,
monthlyStats: true,
yearlyStats: false
},
schedules: buildDefaultSchedules()
}
}
export function mergeHermesEmployeeForm(override = {}) {
const defaults = buildDefaultHermesEmployeeForm()
const schedules = { ...defaults.schedules }
for (const [key, value] of Object.entries(override?.schedules || {})) {
schedules[key] = { ...defaults.schedules[key], ...value }
}
for (const task of HERMES_SIMPLE_TASKS) {
if (schedules[task.id]) {
schedules[task.id].frequency = task.frequency
if (task.weekday != null) {
schedules[task.id].weekday = task.weekday
}
if (task.monthDay != null) {
schedules[task.id].monthDay = task.monthDay
}
if (task.month != null) {
schedules[task.id].month = task.month
}
}
}
return {
...defaults,
...override,
capabilities: {
...defaults.capabilities,
...(override?.capabilities || {})
},
schedules
}
}
export function isHermesTaskEnabled(form, taskId) {
return Boolean(form?.masterEnabled && form?.capabilities?.[taskId] && form?.schedules?.[taskId]?.enabled)
}
export function countEnabledHermesTasks(form) {
return HERMES_SIMPLE_TASKS.filter((task) => isHermesTaskEnabled(form, task.id)).length
}
export function isHermesEmployeeSettingsReady(form) {
return Boolean(!form?.masterEnabled || countEnabledHermesTasks(form) > 0)
}

View File

@@ -0,0 +1,315 @@
const KNOWLEDGE_INGEST_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync'])
const STATUS_META = {
queued: { label: '等待处理', tone: 'muted' },
running: { label: '处理中', tone: 'warning' },
succeeded: { label: '已完成', tone: 'success' },
failed: { label: '失败', tone: 'danger' },
skipped: { label: '已跳过', tone: 'muted' }
}
const PHASE_LABELS = {
queued: '进入队列',
indexing: '解析与索引',
indexed: '索引完成',
failed: '处理失败',
completed: '任务完成'
}
export function isKnowledgeIngestRun(run) {
const routeJson = asObject(run?.route_json)
return KNOWLEDGE_INGEST_JOB_TYPES.has(String(routeJson.job_type || '').trim())
}
export function buildKnowledgeIngestLogModel(run) {
const routeJson = asObject(run?.route_json)
const ingest = asObject(routeJson.knowledge_ingest)
const toolDocuments = extractToolDocuments(run)
const sourceDocuments = normalizeSourceDocuments(
ingest.documents,
toolDocuments,
routeJson.requested_document_ids
)
const documents = sourceDocuments.map(normalizeDocument)
const graph = normalizeGraph(ingest.graph, documents)
const progress = normalizeProgress(routeJson.progress, documents)
const currentDocumentId = String(ingest.current_document_id || '').trim()
return {
available: isKnowledgeIngestRun(run),
folder: String(routeJson.folder || '').trim(),
phase: String(ingest.phase || routeJson.phase || '').trim(),
phaseLabel: PHASE_LABELS[ingest.phase] || PHASE_LABELS[routeJson.phase] || '运行中',
status: String(ingest.status || run?.status || '').trim(),
statusLabel: resolveStatusMeta(ingest.status || run?.status).label,
statusTone: resolveStatusMeta(ingest.status || run?.status).tone,
progress,
currentDocumentId,
documents,
selectedDocumentId: resolveDefaultDocumentId(documents, currentDocumentId),
graph,
metrics: [
{
label: '文件',
value: `${progress.completedDocuments}/${progress.totalDocuments}`,
hint: `失败 ${progress.failedDocuments}`
},
{
label: 'Chunk',
value: formatNumber(graph.chunkCount),
hint: '已解析块'
},
{
label: '实体',
value: formatNumber(graph.entityCount),
hint: '图谱节点'
},
{
label: '关系',
value: formatNumber(graph.relationCount),
hint: '图谱边'
}
]
}
}
export function formatKnowledgeMetric(value) {
return formatNumber(value)
}
function normalizeSourceDocuments(ingestDocuments, toolDocuments, requestedDocumentIds) {
if (Array.isArray(ingestDocuments) && ingestDocuments.length) {
return ingestDocuments
}
if (Array.isArray(toolDocuments) && toolDocuments.length) {
return toolDocuments
}
if (Array.isArray(requestedDocumentIds)) {
return requestedDocumentIds
.map((documentId) => String(documentId || '').trim())
.filter(Boolean)
.map((documentId) => ({ document_id: documentId, name: documentId, status: 'queued' }))
}
return []
}
function extractToolDocuments(run) {
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
for (const toolCall of [...toolCalls].reverse()) {
const responseJson = asObject(toolCall?.response_json)
if (Array.isArray(responseJson.documents) && responseJson.documents.length) {
return responseJson.documents
}
}
return []
}
function normalizeDocument(rawDocument) {
const document = asObject(rawDocument)
const documentId = String(document.document_id || document.id || '').trim()
const status = String(document.status || 'queued').trim()
const phase = String(document.phase || status).trim()
const chunks = normalizeChunks(document.chunks)
const sections = normalizeSections(document.sections)
const entities = normalizeEntities(document.entities)
const relations = normalizeRelations(document.relations)
return {
documentId,
name: String(document.name || document.original_name || documentId || '未命名文件').trim(),
folder: String(document.folder || '').trim(),
extension: String(document.extension || '').trim(),
mimeType: String(document.mime_type || '').trim(),
status,
statusLabel: resolveStatusMeta(status).label,
statusTone: resolveStatusMeta(status).tone,
phase,
phaseLabel: PHASE_LABELS[phase] || PHASE_LABELS[status] || phase || '未开始',
startedAt: String(document.started_at || '').trim(),
finishedAt: String(document.finished_at || '').trim(),
error: String(document.error || '').trim(),
textChars: toNumber(document.text_chars),
indexedTextChars: toNumber(document.indexed_text_chars),
sectionCount: toNumber(document.section_count || sections.length),
chunkCount: toNumber(document.chunk_count || chunks.length),
chunkIds: normalizeTextList(document.chunk_ids),
chunks,
entityCount: toNumber(document.entity_count || entities.length),
relationCount: toNumber(document.relation_count || relations.length),
entities,
relations,
events: normalizeEvents(document.events)
}
}
function normalizeProgress(rawProgress, documents) {
const progress = asObject(rawProgress)
const totalDocuments = toNumber(progress.total_documents || documents.length)
const completedDocuments = toNumber(
progress.completed_documents || documents.filter((item) => item.status === 'succeeded').length
)
const failedDocuments = toNumber(
progress.failed_documents || documents.filter((item) => item.status === 'failed').length
)
const skippedDocuments = toNumber(progress.skipped_documents)
const percent = clampPercent(
progress.percent ?? calculatePercent(totalDocuments, completedDocuments + failedDocuments)
)
return {
totalDocuments,
completedDocuments,
failedDocuments,
skippedDocuments,
percent
}
}
function normalizeGraph(rawGraph, documents) {
const graph = asObject(rawGraph)
const fallbackEntities = dedupeTextList(documents.flatMap((item) => item.entities))
const fallbackRelations = dedupeRelations(documents.flatMap((item) => item.relations))
return {
chunkCount: toNumber(
graph.chunk_count || documents.reduce((total, item) => total + item.chunkCount, 0)
),
entityCount: toNumber(
graph.entity_count || documents.reduce((total, item) => total + item.entityCount, 0)
),
relationCount: toNumber(
graph.relation_count || documents.reduce((total, item) => total + item.relationCount, 0)
),
entities: normalizeTextList(graph.entities).length
? normalizeTextList(graph.entities)
: fallbackEntities,
relations: normalizeRelations(graph.relations).length
? normalizeRelations(graph.relations)
: fallbackRelations
}
}
function normalizeChunks(rawChunks) {
if (!Array.isArray(rawChunks)) return []
return rawChunks
.map((chunk, index) => {
const item = asObject(chunk)
return {
id: String(item.id || item._id || `chunk-${index + 1}`).trim(),
order: toNumber(item.order ?? item.chunk_order_index ?? index),
tokens: toNumber(item.tokens),
summary: String(item.summary || item.content || '').trim()
}
})
.sort((left, right) => left.order - right.order)
}
function normalizeSections(rawSections) {
if (!Array.isArray(rawSections)) return []
return rawSections.map((section, index) => {
const item = asObject(section)
return {
title: String(item.title || `章节 ${index + 1}`).trim(),
excerpt: String(item.excerpt || '').trim()
}
})
}
function normalizeEvents(rawEvents) {
if (!Array.isArray(rawEvents)) return []
return rawEvents.map((event) => {
const item = asObject(event)
return {
at: String(item.at || '').trim(),
level: String(item.level || 'info').trim(),
message: String(item.message || '').trim()
}
})
}
function normalizeEntities(rawEntities) {
return normalizeTextList(rawEntities)
}
function normalizeRelations(rawRelations) {
if (!Array.isArray(rawRelations)) return []
return rawRelations
.map((relation) => {
const item = asObject(relation)
return {
source: String(item.source || item.from || '').trim(),
target: String(item.target || item.to || '').trim(),
type: String(item.type || '关联').trim()
}
})
.filter((item) => item.source && item.target)
}
function resolveDefaultDocumentId(documents, currentDocumentId) {
if (currentDocumentId && documents.some((item) => item.documentId === currentDocumentId)) {
return currentDocumentId
}
return (
documents.find((item) => item.status === 'running')?.documentId ||
documents.find((item) => item.status === 'failed')?.documentId ||
documents[0]?.documentId ||
''
)
}
function resolveStatusMeta(status) {
return STATUS_META[String(status || '').trim()] || STATUS_META.queued
}
function asObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
}
function normalizeTextList(value) {
if (!Array.isArray(value)) return []
return dedupeTextList(value)
}
function dedupeTextList(items) {
const result = []
const seen = new Set()
for (const item of items) {
const text = String(item || '').trim()
if (!text || seen.has(text)) continue
seen.add(text)
result.push(text)
}
return result
}
function dedupeRelations(items) {
const result = []
const seen = new Set()
for (const item of items) {
const source = String(item?.source || '').trim()
const target = String(item?.target || '').trim()
const type = String(item?.type || '关联').trim()
const key = `${source}::${target}::${type}`
if (!source || !target || seen.has(key)) continue
seen.add(key)
result.push({ source, target, type })
}
return result
}
function calculatePercent(total, done) {
if (!total) return 0
return Math.round((done / total) * 100)
}
function clampPercent(value) {
const numericValue = toNumber(value)
return Math.max(0, Math.min(100, numericValue))
}
function formatNumber(value) {
const numericValue = toNumber(value)
return Number.isFinite(numericValue) ? numericValue.toLocaleString('zh-CN') : '0'
}
function toNumber(value) {
const numericValue = Number(value)
return Number.isFinite(numericValue) ? numericValue : 0
}

View File

@@ -12,8 +12,32 @@ const defaultParagraphOpen = markdown.renderer.rules.paragraph_open
const defaultLinkOpen = markdown.renderer.rules.link_open
const defaultBlockquoteOpen = markdown.renderer.rules.blockquote_open
const RISK_TEXT_CLASS_BY_LABEL = {
低风险: 'markdown-risk-text-low',
中风险: 'markdown-risk-text-medium',
高风险: 'markdown-risk-text-high'
}
const ACTION_LINK_CLASS_BY_HREF = {
'#confirm-attachment-association': 'markdown-action-link-confirm'
'#confirm-attachment-association': 'markdown-action-link-confirm',
'#review-next-step': 'markdown-action-link-next',
'#review-quick-edit': 'markdown-action-link-edit',
'#review-risk-panel': 'markdown-action-link-risk'
}
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function renderRiskText(text) {
return escapeHtml(text).replace(/低风险|中风险|高风险/g, (label) => {
const className = RISK_TEXT_CLASS_BY_LABEL[label]
return className ? `<span class="${className}">${label}</span>` : label
})
}
function resolveActionLinkClass(href) {
@@ -70,6 +94,8 @@ markdown.renderer.rules.link_open = (tokens, idx, options, env, self) => {
: self.renderToken(tokens, idx, options)
}
markdown.renderer.rules.text = (tokens, idx) => renderRiskText(tokens[idx]?.content)
markdown.renderer.rules.blockquote_open = (tokens, idx, options, env, self) => {
if (blockquoteHasAttachmentHeading(tokens, idx)) {
tokens[idx].attrJoin('class', 'markdown-attachment-card')

View File

@@ -22,17 +22,17 @@ const DEFAULT_EXPENSE_TYPE_LABELS = {
travel: '差旅费',
hotel: '住宿费',
transport: '交通费',
meal: '费',
meal: '业务招待费',
meeting: '会务费',
entertainment: '业务招待费',
office: '办公费',
office: '办公用品费',
training: '培训费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'
}
export const TRANSPORT_KEYWORD_PATTERN = /交通|出行|打车|网约车|出租车|滴滴|车费|乘车|用车|叫车|约车|的士|车票|车资|地铁|公交|停车|过路费|通行费/
export const TRANSPORT_KEYWORD_PATTERN = /交通|市内交通|出行|打车|网约车|出租车|滴滴|车费|乘车|用车|叫车|约车|的士|车票|车资|地铁|公交|停车|过路费|通行费|高速费|油费/
const FLOW_INTENT_KEYWORDS = {
draft: ['报销', '报账', '草稿', '生成', '提交', '申请', '请走报销'],
@@ -104,21 +104,36 @@ export function inferLocalFlowCandidates(rawText) {
let event = ''
let expenseType = ''
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
event = '请客户吃饭'
expenseType = '业务招待费'
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
event = '出差行程'
expenseType = '差旅费'
} else if (TRANSPORT_KEYWORD_PATTERN.test(compact)) {
if (TRANSPORT_KEYWORD_PATTERN.test(compact)) {
event = '交通出行'
expenseType = '交通费'
} else if (/住宿|酒店|宾馆/.test(compact)) {
} else if (/客户.*吃饭|请客户.*吃饭|客户用餐|客户接待|商务接待|招待|宴请|请客/.test(compact)) {
event = '业务招待'
expenseType = '业务招待费'
} else if (/出差|差旅|机票|飞机票|航班|高铁票|高铁|火车票|火车|动车|行程单|铁路客票/.test(compact)) {
event = '出差行程'
expenseType = '差旅费'
} else if (/住宿|住宿费|酒店|酒店发票|宾馆|民宿|房费|客房/.test(compact)) {
event = '住宿报销'
expenseType = '住宿费'
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
event = '餐饮用餐'
expenseType = '费'
} else if (/餐费|工作餐|用餐|午餐|晚餐|早餐|餐饮|伙食|茶歇/.test(compact)) {
event = '业务招待'
expenseType = '业务招待费'
} else if (/会务|会议费|会议|参会|会场|场地费|论坛|展会/.test(compact)) {
event = '会务活动'
expenseType = '会务费'
} else if (/办公用品|办公耗材|办公设备|文具|打印纸|硒鼓|墨盒|键盘|鼠标|白板/.test(compact)) {
event = '办公采购'
expenseType = '办公用品费'
} else if (/培训|讲师费|课程费|教材|认证费|考试费/.test(compact)) {
event = '培训学习'
expenseType = '培训费'
} else if (/通讯费|话费|电话费|手机费|流量费|宽带费|网络费/.test(compact)) {
event = '通讯使用'
expenseType = '通讯费'
} else if (/福利费|团建|慰问|节日福利|体检费|员工关怀/.test(compact)) {
event = '员工福利'
expenseType = '福利费'
}
return {
@@ -232,7 +247,13 @@ export function buildLocalExtractionProgressMessages(rawText, options = {}) {
}
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
const pendingSlots = ['发生时间', '金额', attachmentHint]
if (candidates.expenseType === '业务招待费') {
pendingSlots.splice(2, 0, '客户名称', '参与人员')
} else if (candidates.expenseType === '住宿费') {
pendingSlots.splice(2, 0, '酒店/商户')
}
messages.push(`正在判断待补项:${pendingSlots.join('、')}`)
return messages
}

View File

@@ -54,13 +54,13 @@ const REQUEST_TYPE_META = {
secondaryStatusLabel: '票据状态'
},
meal: {
label: '费',
label: '业务招待费',
detailVariant: 'general',
tone: 'meeting',
secondaryStatusLabel: '票据状态'
},
office: {
label: '办公费',
label: '办公用品费',
detailVariant: 'general',
tone: 'office',
secondaryStatusLabel: '票据状态'

View File

@@ -0,0 +1,461 @@
import {
buildDefaultHermesEmployeeForm,
isHermesEmployeeSettingsReady,
mergeHermesEmployeeForm
} from './hermesEmployeeSettingsModel.js'
export const SETTINGS_STORAGE_KEY = 'x-financial-settings-draft'
export const CURRENT_YEAR = new Date().getFullYear()
export const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
export const MODEL_SECRET_MASK = '********'
export const RENDER_SECRET_MASK = '********'
export const SECTION_DEFINITIONS = [
{
id: 'profile',
label: '企业信息',
title: '系统基本信息',
desc: '公司名称、品牌与版权',
longDesc: '统一维护企业名称、系统显示名称和版权信息,保存后会同步更新当前系统品牌展示。',
actionLabel: '保存企业信息'
},
{
id: 'admin',
label: '管理员安全',
title: '管理员账号与安全策略',
desc: '账号、密码与登录安全',
longDesc: '集中管理管理员账号、邮箱和登录安全策略。密码仅在当前输入时可见,不会写入浏览器草稿。',
actionLabel: '保存安全设置'
},
{
id: 'session',
label: '会话设置',
title: '会话留存设置',
desc: '会话保留天数',
longDesc: '统一配置智能体会话的保留天数。超过保留期的历史会话会在后端清理,避免上下文和管理成本无限增长。',
actionLabel: '保存会话设置'
},
{
id: 'hermes',
label: '数字员工设置',
title: '数字员工设置',
desc: 'Hermes 自动任务',
longDesc: '选择需要自动执行的任务,并设置每天的执行时间。无需了解 Cron 或复杂调度规则。',
actionLabel: '保存数字员工设置'
},
{
id: 'llm',
label: '大语言模型',
title: '模型接入配置',
desc: '主模型、备份模型与检索模型',
longDesc: '集中维护主模型、备份模型、Embedding 模型和 Reranker 模型的接入参数,供 AI 助手和检索链路调用。',
actionLabel: '保存模型配置'
},
{
id: 'rendering',
label: '文件渲染',
title: '文件渲染',
desc: '文档预览服务与访问密钥',
longDesc: '维护文件渲染开关、文档服务对外地址和 JWT 密钥,后端回调地址继续由部署配置管理。',
actionLabel: '保存文件渲染配置'
},
{
id: 'logs',
label: '日志策略',
title: '日志与审计策略',
desc: '日志级别、留存与脱敏',
longDesc: '定义系统日志级别、留存周期和审计策略,保证问题排查和合规审计可追溯。',
actionLabel: '保存日志策略'
},
{
id: 'mail',
label: '邮箱设置',
title: '邮箱通知配置',
desc: 'SMTP 与通知投递策略',
longDesc: '维护系统邮件发送配置和通知投递策略,审批、告警和摘要邮件都会依赖这里的设置。',
actionLabel: '保存邮箱配置'
}
]
export const LOG_LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR']
export const PROVIDER_OPTIONS = [
'MiniMax',
'GLM',
'Kimi',
'Ali',
'Codex',
'Claude',
'Gemini',
CUSTOM_OPENAI_PROVIDER
]
export const PROVIDER_ENDPOINTS = {
MiniMax: 'https://api.minimaxi.com/v1',
GLM: 'https://open.bigmodel.cn/api/paas/v4/',
Kimi: 'https://api.moonshot.ai/v1',
Ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
Codex: 'https://api.openai.com/v1',
Claude: 'https://api.anthropic.com/v1/',
Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
[CUSTOM_OPENAI_PROVIDER]: ''
}
export const RERANKER_PROVIDER_ENDPOINTS = {
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
[CUSTOM_OPENAI_PROVIDER]: ''
}
export const LEGACY_PROVIDER_MAP = {
'OpenAI Compatible': 'Codex',
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
Ollama: CUSTOM_OPENAI_PROVIDER,
'自定义网关': CUSTOM_OPENAI_PROVIDER
}
export const MODEL_TEST_CONFIGS = {
main: {
label: '主模型',
providerKey: 'mainProvider',
modelKey: 'mainModel',
endpointKey: 'mainEndpoint',
apiKeyKey: 'mainApiKey',
capability: 'chat'
},
backup: {
label: '备份模型',
providerKey: 'backupProvider',
modelKey: 'backupModel',
endpointKey: 'backupEndpoint',
apiKeyKey: 'backupApiKey',
capability: 'chat'
},
embedding: {
label: 'Embedding 模型',
providerKey: 'embeddingProvider',
modelKey: 'embeddingModel',
endpointKey: 'embeddingEndpoint',
apiKeyKey: 'embeddingApiKey',
capability: 'embedding'
},
reranker: {
label: 'Reranker 模型',
providerKey: 'rerankerProvider',
modelKey: 'rerankerModel',
endpointKey: 'rerankerEndpoint',
apiKeyKey: 'rerankerApiKey',
capability: 'reranker'
}
}
export const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS)
export const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({
value: index + 1,
label: `${index + 1}`
}))
export function normalizeValue(value) {
return String(value ?? '').trim()
}
export function normalizeProviderValue(value, fallback = 'Codex') {
const normalized = normalizeValue(value)
if (PROVIDER_OPTIONS.includes(normalized)) {
return normalized
}
if (LEGACY_PROVIDER_MAP[normalized]) {
return LEGACY_PROVIDER_MAP[normalized]
}
return fallback
}
export function getProviderEndpoint(provider) {
return PROVIDER_ENDPOINTS[provider] ?? ''
}
export function getRerankerEndpoint(provider) {
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
}
export function buildDefaultState(companyProfile, currentUser) {
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
const companyCode = normalizeValue(companyProfile?.code) || 'XF-001'
const adminEmail =
normalizeValue(companyProfile?.adminEmail) ||
normalizeValue(currentUser?.email) ||
'admin@example.com'
const adminAccount = normalizeValue(currentUser?.username) || 'superadmin'
return {
companyForm: {
companyName,
displayName: companyName,
companyCode,
logo: normalizeValue(companyProfile?.logo) || '',
recordNumber: '',
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
},
adminForm: {
adminAccount,
adminEmail,
newPassword: '',
confirmPassword: '',
sessionTimeout: Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30),
noticeEmail: adminEmail,
mfaEnabled: true,
strongPassword: true,
loginAlertEnabled: true,
adminPasswordConfigured: false
},
sessionForm: {
conversationRetentionDays: 3
},
llmForm: {
mainProvider: 'Codex',
mainModel: 'codex-mini-latest',
mainEndpoint: getProviderEndpoint('Codex'),
mainApiKey: '',
mainApiKeyConfigured: false,
backupProvider: 'GLM',
backupModel: 'glm-5.1',
backupEndpoint: getProviderEndpoint('GLM'),
backupApiKey: '',
backupApiKeyConfigured: false,
embeddingProvider: 'GLM',
embeddingModel: 'Embedding-3',
embeddingEndpoint: getProviderEndpoint('GLM'),
embeddingApiKey: '',
embeddingApiKeyConfigured: false,
rerankerProvider: 'Ali',
rerankerModel: 'gte-rerank-v2',
rerankerEndpoint: getRerankerEndpoint('Ali'),
rerankerApiKey: '',
rerankerApiKeyConfigured: false
},
renderForm: {
enabled: false,
publicUrl: '',
jwtSecret: '',
jwtSecretConfigured: false
},
logForm: {
level: 'INFO',
retentionDays: 180,
archiveCycle: 'weekly',
logPath: 'server/logs/app.log',
alertEmail: adminEmail,
operationAudit: true,
loginAudit: true,
maskSensitive: true
},
hermesForm: buildDefaultHermesEmployeeForm(),
mailForm: {
smtpHost: 'smtp.exmail.qq.com',
port: 465,
encryption: 'SSL/TLS',
senderName: companyName,
senderAddress: adminEmail,
username: adminEmail,
password: '',
passwordConfigured: false,
alertEnabled: true,
digestEnabled: false,
digestTime: '09:00',
defaultReceiver: adminEmail
}
}
}
export function readStoredSettings() {
if (typeof window === 'undefined') {
return null
}
const raw = window.sessionStorage.getItem(SETTINGS_STORAGE_KEY)
if (!raw) {
return null
}
try {
return JSON.parse(raw)
} catch {
return null
}
}
export function mergeState(baseState, overrideState) {
const mergedLlmForm = { ...baseState.llmForm, ...(overrideState?.llmForm || {}) }
mergedLlmForm.mainProvider = normalizeProviderValue(mergedLlmForm.mainProvider, baseState.llmForm.mainProvider)
mergedLlmForm.backupProvider = normalizeProviderValue(mergedLlmForm.backupProvider, baseState.llmForm.backupProvider)
mergedLlmForm.embeddingProvider = normalizeProviderValue(
mergedLlmForm.embeddingProvider,
baseState.llmForm.embeddingProvider
)
mergedLlmForm.rerankerProvider = normalizeProviderValue(
mergedLlmForm.rerankerProvider,
baseState.llmForm.rerankerProvider
)
return {
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) },
sessionForm: { ...baseState.sessionForm, ...(overrideState?.sessionForm || {}) },
hermesForm: mergeHermesEmployeeForm({
...baseState.hermesForm,
...(overrideState?.hermesForm || {})
}),
llmForm: mergedLlmForm,
renderForm: { ...baseState.renderForm, ...(overrideState?.renderForm || {}) },
logForm: { ...baseState.logForm, ...(overrideState?.logForm || {}) },
mailForm: { ...baseState.mailForm, ...(overrideState?.mailForm || {}) }
}
}
export function sanitizeForStorage(state) {
return {
companyForm: { ...state.companyForm },
adminForm: {
...state.adminForm,
newPassword: '',
confirmPassword: ''
},
sessionForm: { ...state.sessionForm },
hermesForm: mergeHermesEmployeeForm(state.hermesForm),
llmForm: {
...state.llmForm,
mainApiKey: '',
backupApiKey: '',
embeddingApiKey: '',
rerankerApiKey: ''
},
renderForm: {
...state.renderForm,
jwtSecret: ''
},
logForm: { ...state.logForm },
mailForm: {
...state.mailForm,
password: ''
}
}
}
export function getModelConfiguredKey(apiKeyKey) {
return `${apiKeyKey}Configured`
}
export function isModelSecretMask(value) {
return value === MODEL_SECRET_MASK
}
export function maskConfiguredModelSecrets(state) {
for (const config of MODEL_API_KEY_CONFIGS) {
const configuredKey = getModelConfiguredKey(config.apiKeyKey)
if (state.llmForm[configuredKey] && !normalizeValue(state.llmForm[config.apiKeyKey])) {
state.llmForm[config.apiKeyKey] = MODEL_SECRET_MASK
}
}
return state
}
export function buildLlmPayload(llmForm) {
const payload = { ...llmForm }
for (const config of MODEL_API_KEY_CONFIGS) {
if (isModelSecretMask(payload[config.apiKeyKey])) {
payload[config.apiKeyKey] = ''
}
}
return payload
}
export function isRenderSecretMask(value) {
return value === RENDER_SECRET_MASK
}
export function maskConfiguredRenderSecret(state) {
if (state.renderForm.jwtSecretConfigured && !normalizeValue(state.renderForm.jwtSecret)) {
state.renderForm.jwtSecret = RENDER_SECRET_MASK
}
return state
}
export function buildRenderPayload(renderForm) {
const payload = { ...renderForm }
if (isRenderSecretMask(payload.jwtSecret)) {
payload.jwtSecret = ''
}
return payload
}
export function persistSettings(state) {
if (typeof window === 'undefined') {
return
}
window.sessionStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(sanitizeForStorage(state)))
}
export function isModelConfigReady(provider, model, endpoint) {
return Boolean(normalizeValue(provider) && normalizeValue(model) && normalizeValue(endpoint))
}
export function computeSectionStatus(state) {
return {
profile: Boolean(
normalizeValue(state.companyForm.companyName) &&
normalizeValue(state.companyForm.displayName) &&
normalizeValue(state.companyForm.copyright)
),
admin: Boolean(
normalizeValue(state.adminForm.adminAccount) &&
normalizeValue(state.adminForm.adminEmail) &&
Number(state.adminForm.sessionTimeout) >= 5
),
session: Boolean(
Number(state.sessionForm.conversationRetentionDays) >= 1 &&
Number(state.sessionForm.conversationRetentionDays) <= 10
),
hermes: isHermesEmployeeSettingsReady(state.hermesForm),
llm: Boolean(
isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) &&
isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) &&
isModelConfigReady(
state.llmForm.embeddingProvider,
state.llmForm.embeddingModel,
state.llmForm.embeddingEndpoint
) &&
isModelConfigReady(
state.llmForm.rerankerProvider,
state.llmForm.rerankerModel,
state.llmForm.rerankerEndpoint
)
),
rendering: Boolean(
!state.renderForm.enabled ||
(normalizeValue(state.renderForm.publicUrl) &&
(normalizeValue(state.renderForm.jwtSecret) || state.renderForm.jwtSecretConfigured))
),
logs: Boolean(
normalizeValue(state.logForm.level) &&
Number(state.logForm.retentionDays) > 0 &&
normalizeValue(state.logForm.logPath)
),
mail: Boolean(
normalizeValue(state.mailForm.smtpHost) &&
Number(state.mailForm.port) > 0 &&
normalizeValue(state.mailForm.senderAddress) &&
normalizeValue(state.mailForm.username)
)
}
}