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:
2026-06-24 10:13:20 +08:00
parent f9f8a7b13d
commit eecbe4172e
8 changed files with 13596 additions and 13278 deletions

View File

@@ -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,
}
}

View File

@@ -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')

View File

@@ -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' }}>

View File

@@ -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

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional
from typing import Optional, Any
import json
import aiosqlite
from database import get_db
@@ -11,6 +12,11 @@ EDITABLE_KEYS = [
"vision_model", "voice_model", "topic_analysis_prompt",
]
# 万川 AI 平台对接配置整体作为一条 JSON 存储,独立于上面的 AI 模型配置。
# 存到后端 SQLiteapp_settings 表)后,配置不再依赖前端 localStorage 的 origin
# 桌面应用exe即便后端端口/origin 变化也能跨次启动恢复平台地址、账号、密码与已选知识库。
WANCHUAN_KEY = "wanchuan_config"
def _mask_key(value: str) -> str:
if not value or len(value) <= 8:
@@ -62,3 +68,32 @@ async def update_settings(body: SettingsUpdate, db: aiosqlite.Connection = Depen
from services.runtime_settings import invalidate_cache
invalidate_cache()
return {"status": "ok"}
# ── 万川 AI 平台对接配置(整体 JSON 持久化) ──────────────────
# 前端把 { platformUrl, username, password, selectedKbId, selectedKbInfo } 整体存这里。
# 桌面应用 origin 变化不影响该配置(不依赖 localStorage
@router.get("/wanchuan")
async def get_wanchuan_config(db: aiosqlite.Connection = Depends(get_db)):
async with db.execute(
"SELECT value FROM app_settings WHERE key = ?", (WANCHUAN_KEY,)
) as cur:
row = await cur.fetchone()
if not row or not row["value"]:
return {}
try:
return json.loads(row["value"])
except (ValueError, TypeError):
return {}
@router.put("/wanchuan")
async def update_wanchuan_config(body: dict[str, Any], db: aiosqlite.Connection = Depends(get_db)):
value = json.dumps(body, ensure_ascii=False)
await db.execute(
"INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
(WANCHUAN_KEY, value, value),
)
await db.commit()
return {"status": "ok"}

View File

@@ -44,6 +44,22 @@ function chatlogExePath() {
return resourcePath('chatlog.exe');
}
// 启动 chatlog.exe 前确认文件确实存在。
// 该文件常被杀毒软件(尤其 Windows Defender误判为风险程序而静默隔离/删除,
// 导致安装后“有时候”运行就报 spawn ... ENOENT。这里把底层的 ENOENT
// 提前转成一句用户能看懂、能自救的中文提示。
function ensureChatlogExe() {
const exePath = chatlogExePath();
if (!fs.existsSync(exePath)) {
throw new Error(
`未找到 chatlog.exe应在${exePath})。` +
`该文件可能被杀毒软件误删或隔离。请将安装目录加入杀毒软件白名单,` +
`从隔离区恢复 chatlog.exe或重新安装本应用后再试。`
);
}
return exePath;
}
function backendExePath() {
return resourcePath('backend', 'ChatLabBackend.exe');
}
@@ -688,7 +704,13 @@ function selectMainWeChatProcess(processes) {
function runChatlogKey(args, label) {
return new Promise((resolve, reject) => {
const exePath = chatlogExePath();
let exePath;
try {
exePath = ensureChatlogExe();
} catch (e) {
reject(e);
return;
}
const keyProcess = spawn(exePath, args, {
cwd: projectRoot(),
windowsHide: true,

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,8 @@
[string]$PublisherName,
[string]$TimestampServer = "http://timestamp.digicert.com",
[string[]]$VoiceSmokeKeys = @(),
[switch]$ForceSign
[switch]$ForceSign,
[switch]$CleanBackend
)
$ErrorActionPreference = "Stop"
@@ -160,7 +161,16 @@ if (-not $SkipFrontend) {
if (-not $SkipBackend) {
Push-Location $Backend
Invoke-Python312 @("-m", "PyInstaller", "ChatLabBackend.spec", "--noconfirm", "--clean")
# 默认复用 PyInstaller 的分析缓存build\ 目录),代码未大改时增量打包可从 ~9 分钟降到 1-2 分钟。
# 仅在升级依赖或缓存异常时用 -CleanBackend 做一次全量重建。
$pyArgs = @("-m", "PyInstaller", "ChatLabBackend.spec", "--noconfirm")
if ($CleanBackend) {
$pyArgs += "--clean"
Write-Host "Backend: full clean rebuild (--clean)."
} else {
Write-Host "Backend: incremental build (reusing cache). Use -CleanBackend for a full rebuild."
}
Invoke-Python312 $pyArgs
Pop-Location
}