feat(api): 将万川平台配置迁移至后端存储
- 移除前端localStorage依赖,改用后端SQLite作为唯一数据源 - 新增getWanchuanConfig和saveWanchuanConfig函数用于配置读写 - 添加getBoundKnowledgeBase函数统一获取绑定知识库信息 - 支持桌面应用端口变化时正确读取配置 refactor(settings): 重构万川平台配置管理逻辑 - 移除localStorage配置存储,改为后端API调用 - 实现配置自动恢复和防抖保存机制 - 添加token过期自动重登功能 - 优化知识库选择和连接状态管理 fix(knowledge): 修复知识库上传异步问题 - 将getBoundKnowledgeBase调用改为await异步处理 - 统一各页面的知识库信息获取方式 - 修正上传接口datasetId使用逻辑 feat(electron): 添加chatlog.exe存在性检查 - 新增ensureChatlogExe函数验证执行文件存在 - 防止杀毒软件误删导致的ENONENT错误 - 提供用户友好的错误提示和解决方案
This commit is contained in:
@@ -105,3 +105,50 @@ export async function uploadToKnowledgeBase(baseUrl, datasetId, file, secretLeve
|
||||
export function clearWanchuanToken() {
|
||||
try { localStorage.removeItem(TOKEN_KEY) } catch {}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 万川平台配置:后端为唯一数据源(/api/settings/wanchuan,同源相对路径)
|
||||
//
|
||||
// 配置(平台地址、账号、密码、已选知识库)存在后端 SQLite,与前端 origin 无关,
|
||||
// 桌面应用(exe)端口/origin 变化也能正常读取。用时直接请求后端,不再依赖 localStorage。
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/** 读取万川配置;无数据或失败返回 {} */
|
||||
export async function getWanchuanConfig() {
|
||||
try {
|
||||
const resp = await fetch('/api/settings/wanchuan')
|
||||
if (!resp.ok) return {}
|
||||
const data = await resp.json()
|
||||
return (data && typeof data === 'object') ? data : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存万川配置到后端 */
|
||||
export async function saveWanchuanConfig(config) {
|
||||
try {
|
||||
await fetch('/api/settings/wanchuan', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config || {}),
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前绑定的知识库信息(供报告上传等处调用)。
|
||||
* 直接从后端读取配置,返回归一化结构;未绑定返回 null。
|
||||
*/
|
||||
export async function getBoundKnowledgeBase() {
|
||||
const cfg = await getWanchuanConfig()
|
||||
if (!cfg.selectedKbId) return null
|
||||
const info = cfg.selectedKbInfo || {}
|
||||
return {
|
||||
id: info.kbDatasetId || cfg.selectedKbId,
|
||||
platformUrl: cfg.platformUrl,
|
||||
kbName: info.kbName || cfg.selectedKbId,
|
||||
kbDatasetId: info.kbDatasetId || cfg.selectedKbId,
|
||||
kbType: info.kbType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, RefreshCw, Edit3, Check, X, Download, UploadCloud } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
import { getKnowledge, getKnowledgeDoc, patchKnowledge } from '../api'
|
||||
import { uploadToKnowledgeBase } from '../api/wanchuan'
|
||||
import { getBoundKnowledgeBase } from './SettingsPage'
|
||||
import { uploadToKnowledgeBase, getBoundKnowledgeBase } from '../api/wanchuan'
|
||||
import ReportDocumentView from '../components/ReportDocumentView'
|
||||
import { exportWordDoc } from '../utils/wordExport'
|
||||
|
||||
@@ -87,7 +86,7 @@ export default function KnowledgePage({ onToast }) {
|
||||
onToast?.('暂无可上传的售后报告', 'error')
|
||||
return
|
||||
}
|
||||
const bound = getBoundKnowledgeBase()
|
||||
const bound = await getBoundKnowledgeBase()
|
||||
// 上传接口必须使用 datasetId
|
||||
const datasetId = bound?.kbDatasetId || bound?.id
|
||||
if (!bound?.platformUrl || !datasetId) {
|
||||
@@ -138,7 +137,7 @@ export default function KnowledgePage({ onToast }) {
|
||||
onToast?.('请先勾选要上传的报告', 'error')
|
||||
return
|
||||
}
|
||||
const bound = getBoundKnowledgeBase()
|
||||
const bound = await getBoundKnowledgeBase()
|
||||
const datasetId = bound?.kbDatasetId || bound?.id
|
||||
if (!bound?.platformUrl || !datasetId) {
|
||||
onToast?.('未绑定知识库,请先到「设置」选择万川知识库', 'error')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Copy, Check, Save, Link2, Loader, AlertCircle, ChevronDown, ChevronRight, RefreshCw, BookOpen, ExternalLink, FileText } from 'lucide-react'
|
||||
import { wanchuanLogin, getWanchuanKnowledgeBases, uploadToKnowledgeBase, clearWanchuanToken } from '../api/wanchuan'
|
||||
import { wanchuanLogin, getWanchuanKnowledgeBases, uploadToKnowledgeBase, clearWanchuanToken, getWanchuanConfig, saveWanchuanConfig } from '../api/wanchuan'
|
||||
|
||||
const CONFIG_ITEMS = [
|
||||
{
|
||||
@@ -218,7 +218,6 @@ function AISettingsForm() {
|
||||
* 支持输入平台地址 + 账号密码,测试连接后可展示平台模块内容(预留)
|
||||
*/
|
||||
function WanchuanPlatformForm() {
|
||||
const STORAGE_KEY = 'chatlab_wanchuan_config'
|
||||
const [form, setForm] = useState({ platformUrl: '', username: '', password: '' })
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
@@ -234,59 +233,106 @@ function WanchuanPlatformForm() {
|
||||
const [kbError, setKbError] = useState('')
|
||||
const [kbExpanded, setKbExpanded] = useState(false)
|
||||
|
||||
// 从 localStorage 加载已保存配置
|
||||
// 后端为唯一数据源:打开设置页时拉取已保存配置并填入表单,
|
||||
// 然后按已保存凭证自动恢复会话(拉取知识库列表)。
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) setForm(JSON.parse(raw))
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
// 从 localStorage 加载已选知识库;若有缓存 token + 平台地址则自动拉取列表回显
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const saved = JSON.parse(raw)
|
||||
let cancelled = false
|
||||
getWanchuanConfig().then(saved => {
|
||||
if (cancelled || !saved || Object.keys(saved).length === 0) return
|
||||
const loadedForm = {
|
||||
platformUrl: saved.platformUrl || '',
|
||||
username: saved.username || '',
|
||||
password: saved.password || '',
|
||||
}
|
||||
setForm(loadedForm)
|
||||
// 同步更新 ref,确保紧接着的 fetchKnowledgeBases → handleSave 用到的是
|
||||
// 刚加载的凭证,而不是首次渲染闭包里的空表单(否则会把后端配置覆盖成空)。
|
||||
formRef.current = loadedForm
|
||||
if (saved.selectedKbId) setSelectedKbId(saved.selectedKbId)
|
||||
if (saved.selectedKbInfo) setSavedKbInfo(saved.selectedKbInfo)
|
||||
// 已登录过(有缓存 token)则自动拉取知识库列表,无需手动点连接
|
||||
if (!saved.platformUrl) return
|
||||
const token = localStorage.getItem('chatlab_wanchuan_token')
|
||||
if (token && saved.platformUrl) {
|
||||
fetchKnowledgeBases(saved.platformUrl)
|
||||
if (token) {
|
||||
fetchKnowledgeBases(saved.platformUrl, { allowRelogin: true, savedKbId: saved.selectedKbId, credsForRelogin: saved })
|
||||
} else if (saved.username && saved.password) {
|
||||
autoLogin(saved).then(ok => {
|
||||
if (ok) fetchKnowledgeBases(saved.platformUrl, { savedKbId: saved.selectedKbId })
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
// 防抖保存:把最新待存配置放进 ref,停止输入 500ms 后写一次后端。
|
||||
// 既避免每次按键都发 PUT(并发乱序可能让后端存到中间态),
|
||||
// 也保证切到别的页(组件卸载)前把挂起的修改 flush 掉,不丢数据。
|
||||
const pendingConfigRef = useRef(null)
|
||||
const saveTimerRef = useRef(null)
|
||||
|
||||
// 始终持有最新表单值。persistConfig 不再依赖渲染闭包里的 form:
|
||||
// 挂载时自动恢复会跑首次渲染闭包里的 fetchKnowledgeBases → handleSave,
|
||||
// 那时闭包 form 还是空的,若直接 ...form 会把后端凭证覆盖成空。读 ref 可避免。
|
||||
const formRef = useRef(form)
|
||||
formRef.current = form
|
||||
|
||||
const flushSave = () => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
if (pendingConfigRef.current) {
|
||||
saveWanchuanConfig(pendingConfigRef.current)
|
||||
pendingConfigRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 卸载(切换标签页)时把挂起的保存立即写出
|
||||
return () => flushSave()
|
||||
}, [])
|
||||
|
||||
// 把当前完整配置写入后端(平台地址/账号/密码 + 已选知识库)。
|
||||
// kbIdOverride / listOverride 用于在 React 状态尚未刷新时(setState 异步)传入即时值。
|
||||
// immediate=true 时跳过防抖立即保存(如选择知识库、测试连接成功后)。
|
||||
const persistConfig = (nextForm, kbIdOverride, listOverride, immediate = false) => {
|
||||
const effectiveKbId = kbIdOverride !== undefined ? kbIdOverride : selectedKbId
|
||||
const kbs = listOverride || knowledgeBases
|
||||
const selectedKb = kbs.find(k => kbKey(k) === effectiveKbId) || null
|
||||
const data = {
|
||||
...nextForm,
|
||||
selectedKbId: effectiveKbId || '',
|
||||
selectedKbInfo: selectedKb ? {
|
||||
selectedKbId: kbKey(selectedKb),
|
||||
kbName: selectedKb.relationName || selectedKb.id,
|
||||
kbDatasetId: selectedKb.datasetId || selectedKb.id,
|
||||
kbType: selectedKb.kbType,
|
||||
} : (savedKbInfo || null),
|
||||
}
|
||||
pendingConfigRef.current = data
|
||||
if (immediate) {
|
||||
flushSave()
|
||||
return
|
||||
}
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(flushSave, 500)
|
||||
}
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
setForm(prev => ({ ...prev, [key]: value }))
|
||||
const next = { ...form, [key]: value }
|
||||
setForm(next)
|
||||
if (connected) setConnected(false)
|
||||
// 改字段防抖存回后端,避免未点「测试连接」就离开导致凭证丢失。
|
||||
persistConfig(next)
|
||||
}
|
||||
|
||||
// 知识库的唯一标识:上传接口需要的是 datasetId,没有则回退到 id。
|
||||
// 全程以此作为 selectedKbId,保证选择、回显、持久化、上传四处一致。
|
||||
const kbKey = (kb) => String(kb?.datasetId ?? kb?.id ?? '')
|
||||
|
||||
// 持久化整个知识库配置。
|
||||
// kbIdOverride / listOverride 用于在 React 状态尚未刷新时(setState 异步)
|
||||
// 传入即时值,避免读到旧的 selectedKbId 或旧的知识库列表。
|
||||
// 知识库选择 / 测试连接后的保存走立即写,确保关键操作不被防抖延迟。
|
||||
// 用 formRef.current 而非闭包 form:挂载恢复时跑的是首次渲染闭包,form 还是空的。
|
||||
const handleSave = (kbIdOverride, listOverride) => {
|
||||
try {
|
||||
const effectiveKbId = kbIdOverride !== undefined ? kbIdOverride : selectedKbId
|
||||
const kbs = listOverride || knowledgeBases
|
||||
const selectedKb = kbs.find(k => kbKey(k) === effectiveKbId) || null
|
||||
const data = {
|
||||
...form,
|
||||
selectedKbId: effectiveKbId,
|
||||
selectedKbInfo: selectedKb ? {
|
||||
selectedKbId: kbKey(selectedKb),
|
||||
kbName: selectedKb.relationName || selectedKb.id,
|
||||
kbDatasetId: selectedKb.datasetId || selectedKb.id,
|
||||
kbType: selectedKb.kbType,
|
||||
} : null,
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||
} catch {}
|
||||
persistConfig(formRef.current, kbIdOverride, listOverride, true)
|
||||
}
|
||||
|
||||
const handleSelectKb = (key) => {
|
||||
@@ -318,11 +364,6 @@ function WanchuanPlatformForm() {
|
||||
return uploadToKnowledgeBase(form.platformUrl, targetDatasetId, file)
|
||||
}
|
||||
|
||||
// 获取当前绑定的知识库信息(供外部调用)
|
||||
const getBoundKnowledgeBase = () => {
|
||||
return selectedKb || null
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!form.platformUrl) {
|
||||
setConnectError('请输入平台地址')
|
||||
@@ -357,20 +398,53 @@ function WanchuanPlatformForm() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchKnowledgeBases = async (baseUrl) => {
|
||||
// 用已保存的账号密码静默重新登录,刷新缓存 token。
|
||||
// saved 缺省用当前表单(重登时表单已填好);首次加载时由调用方传入后端配置。
|
||||
// 成功返回 true;失败返回 false(不抛出,由调用方决定后续)。
|
||||
const autoLogin = async (saved = form) => {
|
||||
try {
|
||||
if (!saved.platformUrl || !saved.username || !saved.password) return false
|
||||
const { token, data } = await wanchuanLogin(saved.platformUrl, saved.username, saved.password)
|
||||
if (!token) return false
|
||||
setModules(data.modules || [])
|
||||
setConnected(true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchKnowledgeBases = async (baseUrl, { allowRelogin = false, savedKbId, credsForRelogin } = {}) => {
|
||||
// 首次加载时 selectedKbId 状态可能还没刷新到,用传入的 savedKbId 兜底
|
||||
const targetKbId = savedKbId !== undefined ? savedKbId : selectedKbId
|
||||
setKbLoading(true)
|
||||
setKbError('')
|
||||
try {
|
||||
const { list } = await getWanchuanKnowledgeBases(baseUrl, { relationType: 'position', page: 1, pageSize: 100 })
|
||||
let list
|
||||
try {
|
||||
({ list } = await getWanchuanKnowledgeBases(baseUrl, { relationType: 'position', page: 1, pageSize: 100 }))
|
||||
} catch (e) {
|
||||
// token 可能已过期:用已保存凭证自动重登一次后重试。
|
||||
// credsForRelogin 用于首次加载场景——此时 form 状态尚未刷新,
|
||||
// 必须显式传入后端读到的凭证,否则 autoLogin 拿到的是空表单。
|
||||
const isAuthErr = /\b401\b|\b403\b/.test(e.message || '')
|
||||
if (allowRelogin && isAuthErr && await autoLogin(credsForRelogin)) {
|
||||
({ list } = await getWanchuanKnowledgeBases(baseUrl, { relationType: 'position', page: 1, pageSize: 100 }))
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
setKnowledgeBases(list)
|
||||
setConnected(true)
|
||||
// 登录后恢复之前保存的已选知识库(按统一的 kbKey 匹配)
|
||||
const keys = new Set(list.map(kb => kbKey(kb)))
|
||||
if (selectedKbId && keys.has(selectedKbId)) {
|
||||
// 已选项仍在列表中:用完整信息重新持久化(登录时曾因列表为空被写成 null)
|
||||
handleSave(selectedKbId, list)
|
||||
} else if (selectedKbId) {
|
||||
if (targetKbId && keys.has(targetKbId)) {
|
||||
// 已选项仍在列表中:用完整信息重新持久化
|
||||
setSelectedKbId(targetKbId)
|
||||
handleSave(targetKbId, list)
|
||||
} else if (targetKbId) {
|
||||
// 兼容旧数据:之前可能保存的是 kb.id,尝试映射到 datasetId
|
||||
const matchById = list.find(kb => String(kb.id) === selectedKbId)
|
||||
const matchById = list.find(kb => String(kb.id) === targetKbId)
|
||||
if (matchById) {
|
||||
const newKey = kbKey(matchById)
|
||||
setSelectedKbId(newKey)
|
||||
@@ -389,7 +463,8 @@ function WanchuanPlatformForm() {
|
||||
}
|
||||
|
||||
const handleRefreshKB = () => {
|
||||
if (form.platformUrl) fetchKnowledgeBases(form.platformUrl)
|
||||
// 手动刷新也允许 token 过期时自动重登,避免必须重新点「测试连接」
|
||||
if (form.platformUrl) fetchKnowledgeBases(form.platformUrl, { allowRelogin: true })
|
||||
}
|
||||
|
||||
const kbTypeLabel = (type) => ({
|
||||
@@ -638,27 +713,6 @@ function WanchuanPlatformForm() {
|
||||
)
|
||||
}
|
||||
|
||||
// 导出供外部调用获取已绑定知识库信息
|
||||
export function getBoundKnowledgeBase() {
|
||||
try {
|
||||
const STORAGE_KEY = 'chatlab_wanchuan_config'
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const saved = JSON.parse(raw)
|
||||
// 返回完整的知识库信息,包括保存的详细信息
|
||||
if (!saved.selectedKbId) return null
|
||||
return {
|
||||
id: saved.selectedKbInfo?.kbDatasetId || saved.selectedKbId,
|
||||
platformUrl: saved.platformUrl,
|
||||
kbName: saved.selectedKbInfo?.kbName || saved.selectedKbId,
|
||||
kbDatasetId: saved.selectedKbInfo?.kbDatasetId || saved.selectedKbId,
|
||||
kbType: saved.selectedKbInfo?.kbType,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '28px 36px' }}>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import MessageBubble from '../components/MessageBubble'
|
||||
import ReportDocumentView from '../components/ReportDocumentView'
|
||||
import { exportWordDoc } from '../utils/wordExport'
|
||||
import { uploadToKnowledgeBase, getWanchuanKnowledgeBases } from '../api/wanchuan'
|
||||
import { uploadToKnowledgeBase, getWanchuanKnowledgeBases, getBoundKnowledgeBase } from '../api/wanchuan'
|
||||
|
||||
const TYPE_MAP = {
|
||||
1: 0,
|
||||
@@ -648,19 +648,13 @@ export default function TopicsPage({ sessions = [], onToast }) {
|
||||
if (uploadingToKb) return
|
||||
setUploadingToKb(true)
|
||||
try {
|
||||
const raw = localStorage.getItem('chatlab_wanchuan_config')
|
||||
if (!raw) return
|
||||
const bound = JSON.parse(raw)
|
||||
const kbInfo = bound?.selectedKbInfo
|
||||
// 上传接口必须使用 datasetId。selectedKbId 现已统一存为 datasetId,
|
||||
// selectedKbInfo.kbDatasetId 为冗余备份,两者任一可用。
|
||||
const datasetId = kbInfo?.kbDatasetId || bound?.selectedKbId
|
||||
if (!bound?.platformUrl || !datasetId) return
|
||||
const bound = await getBoundKnowledgeBase()
|
||||
if (!bound) return
|
||||
// 上传接口必须使用 datasetId
|
||||
const datasetId = bound.kbDatasetId || bound.id
|
||||
if (!bound.platformUrl || !datasetId) return
|
||||
|
||||
const safeTitle = (reportTitle || 'report').replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_').slice(0, 50)
|
||||
// 上传接口必须使用 datasetId,而不是知识库对象的 id
|
||||
console.log('[上传KB] datasetId:', datasetId, 'kbName:', kbInfo?.kbName || 'N/A')
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
|
||||
const file = new File([blob], `${safeTitle}.md`, { type: 'text/markdown' })
|
||||
|
||||
// 上传接口使用 datasetId
|
||||
|
||||
Reference in New Issue
Block a user