feat: 添加万川AI平台对接功能
- 新增万川AI平台API接口层,支持登录、获取知识库列表、上传文件等操作 - 实现万川平台配置界面,支持平台地址、账号密码配置和知识库绑定 - 在知识库页面添加上传功能,支持单个或批量上传售后报告到万川知识库 - 提供检查配置的工具脚本便于调试
This commit is contained in:
16
chatlab-web/frontend/check-wanchuan-config.js
Normal file
16
chatlab-web/frontend/check-wanchuan-config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// 在浏览器控制台(F12)粘贴运行以下代码:
|
||||
console.log('=== 检查万川知识库配置 ===');
|
||||
const raw = localStorage.getItem('chatlab_wanchuan_config');
|
||||
console.log('原始配置:', raw);
|
||||
if (raw) {
|
||||
try {
|
||||
const config = JSON.parse(raw);
|
||||
console.log('解析后配置:', config);
|
||||
console.log('platformUrl:', config.platformUrl);
|
||||
console.log('selectedKbId:', config.selectedKbId);
|
||||
} catch (e) {
|
||||
console.error('配置解析失败:', e);
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到 chatlab_wanchuan_config,需要先在设置页面绑定知识库');
|
||||
}
|
||||
107
chatlab-web/frontend/src/api/wanchuan.js
Normal file
107
chatlab-web/frontend/src/api/wanchuan.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 万川 AI 平台 API 接口层
|
||||
*
|
||||
* 接口说明:
|
||||
* POST /api/login 登录获取 token
|
||||
* GET /api/system/kb/relation/query 获取岗位知识库列表
|
||||
* POST /api/system/kb/file/upload/async/{kbId} 上传文件到知识库(异步)
|
||||
*
|
||||
* 所有接口使用 fetch 直接调用第三方平台(不走 vite proxy),
|
||||
* 平台地址由用户配置(localStorage),token 登录后自动缓存。
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'chatlab_wanchuan_token'
|
||||
|
||||
function getToken() {
|
||||
try { return localStorage.getItem(TOKEN_KEY) || '' } catch { return '' }
|
||||
}
|
||||
|
||||
function setToken(token) {
|
||||
try { localStorage.setItem(TOKEN_KEY, token) } catch {}
|
||||
}
|
||||
|
||||
function authHeaders(baseUrl) {
|
||||
const token = getToken()
|
||||
return {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'token': `Bearer ${token}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录万川 AI 平台
|
||||
* @param {string} baseUrl - 平台地址(如 http://192.168.2.236:8082)
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @returns {Promise<{token: string, data: object}>}
|
||||
*/
|
||||
export async function wanchuanLogin(baseUrl, username, password) {
|
||||
const resp = await fetch(`${baseUrl}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json;charset=UTF-8' },
|
||||
body: JSON.stringify({ username, password, loginType: 'user' }),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const result = await resp.json()
|
||||
|
||||
// 尝试从不同位置提取 token
|
||||
const token = result.token || result.data?.token || result.data?.access_token || result.access_token || ''
|
||||
if (token) setToken(token)
|
||||
return { token, data: result.data || result }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取岗位知识库列表
|
||||
* @param {string} baseUrl
|
||||
* @param {object} params - { relationType, page, pageSize }
|
||||
* @returns {Promise<{list: Array, total: number}>}
|
||||
*/
|
||||
export async function getWanchuanKnowledgeBases(baseUrl, params = {}) {
|
||||
const { relationType = 'position', page = 1, pageSize = 100, ...rest } = params
|
||||
const qs = new URLSearchParams({ relationType, page: String(page), pageSize: String(pageSize), ...rest })
|
||||
const resp = await fetch(`${baseUrl}/api/system/kb/relation/query?${qs}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(baseUrl),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const result = await resp.json()
|
||||
|
||||
// 兼容不同返回格式
|
||||
const list = result.rows || result.data?.rows || result.data?.list || result.data || result.list || result || []
|
||||
const total = result.total ?? result.data?.total ?? list.length
|
||||
return { list: Array.isArray(list) ? list : [], total }
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到知识库(异步)
|
||||
* @param {string} baseUrl
|
||||
* @param {string} datasetId - 知识库 datasetId
|
||||
* @param {File} file - 要上传的文件
|
||||
* @param {number} secretLevel - 密级(默认 1)
|
||||
* @returns {Promise<{taskId: string}>}
|
||||
*/
|
||||
export async function uploadToKnowledgeBase(baseUrl, datasetId, file, secretLevel = 1) {
|
||||
const token = getToken()
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('secretLevel', String(secretLevel))
|
||||
|
||||
const resp = await fetch(`${baseUrl}/api/system/kb/file/upload/async/${encodeURIComponent(datasetId)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'token': `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存的 token
|
||||
*/
|
||||
export function clearWanchuanToken() {
|
||||
try { localStorage.removeItem(TOKEN_KEY) } catch {}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, RefreshCw, Edit3, Check, X, Download } from 'lucide-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 ReportDocumentView from '../components/ReportDocumentView'
|
||||
import { exportWordDoc } from '../utils/wordExport'
|
||||
|
||||
@@ -14,6 +16,12 @@ export default function KnowledgePage({ onToast }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploadingKb, setUploadingKb] = useState(false)
|
||||
// 批量选择上传
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [checkedIds, setCheckedIds] = useState(() => new Set())
|
||||
const [batchUploading, setBatchUploading] = useState(false)
|
||||
const [batchProgress, setBatchProgress] = useState(null)
|
||||
|
||||
const loadDocs = useCallback(async (kw) => {
|
||||
setLoading(true)
|
||||
@@ -72,13 +80,122 @@ export default function KnowledgePage({ onToast }) {
|
||||
}
|
||||
}
|
||||
|
||||
// 上传当前报告到已绑定的万川知识库
|
||||
const handleUploadToKb = async () => {
|
||||
if (uploadingKb) return
|
||||
if (!selectedDoc || !docDetail?.content) {
|
||||
onToast?.('暂无可上传的售后报告', 'error')
|
||||
return
|
||||
}
|
||||
const bound = getBoundKnowledgeBase()
|
||||
// 上传接口必须使用 datasetId
|
||||
const datasetId = bound?.kbDatasetId || bound?.id
|
||||
if (!bound?.platformUrl || !datasetId) {
|
||||
onToast?.('未绑定知识库,请先到「设置」选择万川知识库', 'error')
|
||||
return
|
||||
}
|
||||
setUploadingKb(true)
|
||||
try {
|
||||
const safeTitle = (selectedDoc.title || `售后报告_${selectedDoc.id}`)
|
||||
.replace(/[^a-zA-Z0-9一-龥_-]/g, '_')
|
||||
.slice(0, 50)
|
||||
const blob = new Blob([docDetail.content], { type: 'text/markdown;charset=utf-8' })
|
||||
const file = new File([blob], `${safeTitle}.md`, { type: 'text/markdown' })
|
||||
await uploadToKnowledgeBase(bound.platformUrl, datasetId, file)
|
||||
onToast?.(`已上传到知识库:${bound.kbName || datasetId}`)
|
||||
} catch (e) {
|
||||
onToast?.(`上传知识库失败:${e?.message || e}`, 'error')
|
||||
} finally {
|
||||
setUploadingKb(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换批量选择模式
|
||||
const toggleSelectMode = () => {
|
||||
setSelectMode((m) => !m)
|
||||
setCheckedIds(new Set())
|
||||
}
|
||||
|
||||
const toggleChecked = (id) => {
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCheckAll = () => {
|
||||
setCheckedIds((prev) => (
|
||||
prev.size === docs.length ? new Set() : new Set(docs.map((d) => d.id))
|
||||
))
|
||||
}
|
||||
|
||||
// 批量上传选中的报告到已绑定知识库
|
||||
const handleBatchUpload = async () => {
|
||||
if (batchUploading) return
|
||||
if (checkedIds.size === 0) {
|
||||
onToast?.('请先勾选要上传的报告', 'error')
|
||||
return
|
||||
}
|
||||
const bound = getBoundKnowledgeBase()
|
||||
const datasetId = bound?.kbDatasetId || bound?.id
|
||||
if (!bound?.platformUrl || !datasetId) {
|
||||
onToast?.('未绑定知识库,请先到「设置」选择万川知识库', 'error')
|
||||
return
|
||||
}
|
||||
const targets = docs.filter((d) => checkedIds.has(d.id))
|
||||
setBatchUploading(true)
|
||||
setBatchProgress({ done: 0, total: targets.length })
|
||||
let ok = 0
|
||||
const failed = []
|
||||
for (const doc of targets) {
|
||||
try {
|
||||
// 列表项不带正文,需要逐个拉取内容
|
||||
const detail = await getKnowledgeDoc(doc.id)
|
||||
const content = detail?.content
|
||||
if (!content) { failed.push(doc.title || `#${doc.id}`); continue }
|
||||
const safeTitle = (doc.title || `售后报告_${doc.id}`)
|
||||
.replace(/[^a-zA-Z0-9一-龥_-]/g, '_')
|
||||
.slice(0, 50)
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
|
||||
const file = new File([blob], `${safeTitle}.md`, { type: 'text/markdown' })
|
||||
await uploadToKnowledgeBase(bound.platformUrl, datasetId, file)
|
||||
ok += 1
|
||||
} catch (e) {
|
||||
failed.push(doc.title || `#${doc.id}`)
|
||||
} finally {
|
||||
setBatchProgress((p) => ({ done: (p?.done || 0) + 1, total: targets.length }))
|
||||
}
|
||||
}
|
||||
setBatchUploading(false)
|
||||
setBatchProgress(null)
|
||||
if (failed.length === 0) {
|
||||
onToast?.(`已上传 ${ok} 篇报告到知识库:${bound.kbName || datasetId}`)
|
||||
setSelectMode(false)
|
||||
setCheckedIds(new Set())
|
||||
} else {
|
||||
onToast?.(`上传完成:成功 ${ok} 篇,失败 ${failed.length} 篇(${failed.slice(0, 3).join('、')}${failed.length > 3 ? '…' : ''})`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden', height: '100%' }}>
|
||||
|
||||
{/* 左栏:售后报告列表 */}
|
||||
<div style={{ width: 280, borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 8 }}>售后报告库</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>售后报告库</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 11, padding: '2px 8px' }}
|
||||
onClick={toggleSelectMode}
|
||||
disabled={docs.length === 0}
|
||||
>
|
||||
{selectMode ? '取消' : '批量上传'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
className="filter-input"
|
||||
@@ -95,6 +212,30 @@ export default function KnowledgePage({ onToast }) {
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{selectMode && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 8, gap: 6 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-muted)', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={docs.length > 0 && checkedIds.size === docs.length}
|
||||
ref={(el) => { if (el) el.indeterminate = checkedIds.size > 0 && checkedIds.size < docs.length }}
|
||||
onChange={toggleCheckAll}
|
||||
/>
|
||||
全选 ({checkedIds.size}/{docs.length})
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={handleBatchUpload}
|
||||
disabled={batchUploading || checkedIds.size === 0}
|
||||
>
|
||||
<UploadCloud size={13} />
|
||||
{batchUploading
|
||||
? `上传中 ${batchProgress?.done || 0}/${batchProgress?.total || 0}`
|
||||
: `上传 (${checkedIds.size})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
@@ -136,19 +277,33 @@ export default function KnowledgePage({ onToast }) {
|
||||
{items.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
onClick={() => handleSelect(doc)}
|
||||
onClick={() => selectMode ? toggleChecked(doc.id) : handleSelect(doc)}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: selectedDoc?.id === doc.id ? 'var(--bg-overlay)' : 'transparent',
|
||||
background: (selectMode ? checkedIds.has(doc.id) : selectedDoc?.id === doc.id) ? 'var(--bg-overlay)' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{doc.title || `文档 #${doc.id}`}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
更新于 {dayjs(doc.updated_at).format('MM-DD HH:mm')}
|
||||
{selectMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedIds.has(doc.id)}
|
||||
onChange={() => toggleChecked(doc.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{doc.title || `文档 #${doc.id}`}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
更新于 {dayjs(doc.updated_at).format('MM-DD HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -181,6 +336,9 @@ export default function KnowledgePage({ onToast }) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={handleUploadToKb} disabled={!docDetail?.content || uploadingKb}>
|
||||
<UploadCloud size={13} /> {uploadingKb ? '上传中...' : '上传知识库'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={handleExport} disabled={!docDetail?.content}>
|
||||
<Download size={13} /> 导出 Word
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Copy, Check, Save } from 'lucide-react'
|
||||
import { useState, useEffect } 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'
|
||||
|
||||
const CONFIG_ITEMS = [
|
||||
{
|
||||
@@ -212,6 +213,452 @@ function AISettingsForm() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 万川 AI 平台对接配置
|
||||
* 支持输入平台地址 + 账号密码,测试连接后可展示平台模块内容(预留)
|
||||
*/
|
||||
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)
|
||||
const [connectError, setConnectError] = useState('')
|
||||
const [modules, setModules] = useState([])
|
||||
const [modulesExpanded, setModulesExpanded] = useState(false)
|
||||
|
||||
const [knowledgeBases, setKnowledgeBases] = useState([])
|
||||
const [selectedKbId, setSelectedKbId] = useState('')
|
||||
// 本地保存的已选知识库信息:列表尚未拉取时用于即时回显
|
||||
const [savedKbInfo, setSavedKbInfo] = useState(null)
|
||||
const [kbLoading, setKbLoading] = useState(false)
|
||||
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)
|
||||
if (saved.selectedKbId) setSelectedKbId(saved.selectedKbId)
|
||||
if (saved.selectedKbInfo) setSavedKbInfo(saved.selectedKbInfo)
|
||||
// 已登录过(有缓存 token)则自动拉取知识库列表,无需手动点连接
|
||||
const token = localStorage.getItem('chatlab_wanchuan_token')
|
||||
if (token && saved.platformUrl) {
|
||||
fetchKnowledgeBases(saved.platformUrl)
|
||||
}
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
setForm(prev => ({ ...prev, [key]: value }))
|
||||
if (connected) setConnected(false)
|
||||
}
|
||||
|
||||
// 知识库的唯一标识:上传接口需要的是 datasetId,没有则回退到 id。
|
||||
// 全程以此作为 selectedKbId,保证选择、回显、持久化、上传四处一致。
|
||||
const kbKey = (kb) => String(kb?.datasetId ?? kb?.id ?? '')
|
||||
|
||||
// 持久化整个知识库配置。
|
||||
// kbIdOverride / listOverride 用于在 React 状态尚未刷新时(setState 异步)
|
||||
// 传入即时值,避免读到旧的 selectedKbId 或旧的知识库列表。
|
||||
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 {}
|
||||
}
|
||||
|
||||
const handleSelectKb = (key) => {
|
||||
const kb = knowledgeBases.find(k => kbKey(k) === key)
|
||||
if (!kb) return
|
||||
const nextKey = kbKey(kb)
|
||||
setSelectedKbId(nextKey)
|
||||
// 同步兜底回显信息
|
||||
setSavedKbInfo({
|
||||
selectedKbId: nextKey,
|
||||
kbName: kb.relationName || kb.id,
|
||||
kbDatasetId: kb.datasetId || kb.id,
|
||||
kbType: kb.kbType,
|
||||
})
|
||||
// 立即用新值持久化,不依赖尚未刷新的 selectedKbId 状态
|
||||
handleSave(nextKey)
|
||||
}
|
||||
|
||||
const selectedKb = knowledgeBases.find(kb => kbKey(kb) === selectedKbId)
|
||||
// 回显用的已选信息:优先用列表中的完整对象,否则用本地保存的兜底信息
|
||||
const selectedKbName = selectedKb?.relationName || (selectedKbId ? savedKbInfo?.kbName : null)
|
||||
|
||||
// 上传文件到知识库(预留,供后续调用)
|
||||
const handleUploadToKB = async (datasetId, file) => {
|
||||
if (!form.platformUrl || !file) return
|
||||
// 优先使用传入的 datasetId,否则使用已选知识库的 datasetId
|
||||
const targetDatasetId = datasetId || selectedKb?.datasetId
|
||||
if (!targetDatasetId) return
|
||||
return uploadToKnowledgeBase(form.platformUrl, targetDatasetId, file)
|
||||
}
|
||||
|
||||
// 获取当前绑定的知识库信息(供外部调用)
|
||||
const getBoundKnowledgeBase = () => {
|
||||
return selectedKb || null
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!form.platformUrl) {
|
||||
setConnectError('请输入平台地址')
|
||||
return
|
||||
}
|
||||
if (!form.username || !form.password) {
|
||||
setConnectError('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
|
||||
setConnecting(true)
|
||||
setConnectError('')
|
||||
setModules([])
|
||||
setConnected(false)
|
||||
setKnowledgeBases([])
|
||||
// 登录成功后保留已选知识库 ID(如果新列表中包含该 ID)
|
||||
|
||||
try {
|
||||
const { token, data } = await wanchuanLogin(form.platformUrl, form.username, form.password)
|
||||
if (!token) throw new Error('登录成功但未获取到 token')
|
||||
|
||||
setModules(data.modules || [])
|
||||
setConnected(true)
|
||||
handleSave()
|
||||
|
||||
// 登录成功后自动拉取岗位知识库列表
|
||||
fetchKnowledgeBases(form.platformUrl)
|
||||
} catch (e) {
|
||||
setConnectError(e.message || '登录失败')
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchKnowledgeBases = async (baseUrl) => {
|
||||
setKbLoading(true)
|
||||
setKbError('')
|
||||
try {
|
||||
const { list } = await getWanchuanKnowledgeBases(baseUrl, { relationType: 'position', page: 1, pageSize: 100 })
|
||||
setKnowledgeBases(list)
|
||||
// 登录后恢复之前保存的已选知识库(按统一的 kbKey 匹配)
|
||||
const keys = new Set(list.map(kb => kbKey(kb)))
|
||||
if (selectedKbId && keys.has(selectedKbId)) {
|
||||
// 已选项仍在列表中:用完整信息重新持久化(登录时曾因列表为空被写成 null)
|
||||
handleSave(selectedKbId, list)
|
||||
} else if (selectedKbId) {
|
||||
// 兼容旧数据:之前可能保存的是 kb.id,尝试映射到 datasetId
|
||||
const matchById = list.find(kb => String(kb.id) === selectedKbId)
|
||||
if (matchById) {
|
||||
const newKey = kbKey(matchById)
|
||||
setSelectedKbId(newKey)
|
||||
handleSave(newKey, list)
|
||||
} else {
|
||||
setSelectedKbId('')
|
||||
handleSave('', list)
|
||||
}
|
||||
}
|
||||
setKbExpanded(true)
|
||||
} catch (e) {
|
||||
setKbError(e.message || '加载知识库列表失败')
|
||||
} finally {
|
||||
setKbLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshKB = () => {
|
||||
if (form.platformUrl) fetchKnowledgeBases(form.platformUrl)
|
||||
}
|
||||
|
||||
const kbTypeLabel = (type) => ({
|
||||
self_built: '自建',
|
||||
external: '外部',
|
||||
shared: '共享',
|
||||
}[type] || type)
|
||||
|
||||
const statusBadge = (status) => {
|
||||
if (status === 0) return { label: '正常', color: 'var(--success, #10b981)' }
|
||||
if (status === 1) return { label: '停用', color: 'var(--warning, #d97706)' }
|
||||
return { label: String(status), color: 'var(--text-muted)' }
|
||||
}
|
||||
|
||||
const isConfigured = form.platformUrl && form.username && form.password
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
|
||||
万川 AI 平台对接
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
配置第三方平台地址和登录凭证,用于推送数据。测试连接后可查看平台模块。
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{/* 平台地址 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 16px', borderBottom: '1px solid var(--border)', gap: 12 }}>
|
||||
<div style={{ flex: '0 0 130px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>平台地址</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>万川 AI 平台 Base URL</div>
|
||||
</div>
|
||||
<input
|
||||
value={form.platformUrl || ''}
|
||||
placeholder="https://wanchuan.example.com"
|
||||
onChange={(e) => handleChange('platformUrl', e.target.value)}
|
||||
style={{ flex: 1, fontSize: 13, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface-2)', color: 'var(--text)', outline: 'none' }}
|
||||
/>
|
||||
<div title={form.platformUrl ? '已配置' : '未配置'} style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: form.platformUrl ? 'var(--success, #10b981)' : '#ef4444', boxShadow: form.platformUrl ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)' }} />
|
||||
</div>
|
||||
|
||||
{/* 账号 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 16px', borderBottom: '1px solid var(--border)', gap: 12 }}>
|
||||
<div style={{ flex: '0 0 130px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>账号</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>平台登录账号</div>
|
||||
</div>
|
||||
<input
|
||||
value={form.username || ''}
|
||||
placeholder="请输入账号"
|
||||
onChange={(e) => handleChange('username', e.target.value)}
|
||||
style={{ flex: 1, fontSize: 13, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface-2)', color: 'var(--text)', outline: 'none' }}
|
||||
/>
|
||||
<div title={form.username ? '已配置' : '未配置'} style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: form.username ? 'var(--success, #10b981)' : '#ef4444', boxShadow: form.username ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)' }} />
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 16px', gap: 12 }}>
|
||||
<div style={{ flex: '0 0 130px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>密码</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>平台登录密码</div>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password || ''}
|
||||
placeholder="请输入密码"
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
style={{ flex: 1, fontSize: 13, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface-2)', color: 'var(--text)', outline: 'none' }}
|
||||
/>
|
||||
<div title={form.password ? '已配置' : '未配置'} style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: form.password ? 'var(--success, #10b981)' : '#ef4444', boxShadow: form.password ? '0 0 4px rgba(16,185,129,0.5)' : '0 0 4px rgba(239,68,68,0.5)' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 + 状态 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 14 }}>
|
||||
<button
|
||||
onClick={handleTestConnection}
|
||||
disabled={connecting || !isConfigured}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 20px',
|
||||
background: connected ? 'var(--success, #10b981)' : 'var(--accent, #6366f1)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: (connecting || !isConfigured) ? 'not-allowed' : 'pointer',
|
||||
opacity: (connecting || !isConfigured) ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{connecting ? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} /> : connected ? <Check size={14} /> : <Link2 size={14} />}
|
||||
{connecting ? '连接中...' : connected ? '已连接' : '测试连接'}
|
||||
</button>
|
||||
{connectError && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--danger, #dc2626)' }}>
|
||||
<AlertCircle size={12} /> {connectError}
|
||||
</span>
|
||||
)}
|
||||
{kbExpanded && !kbLoading && knowledgeBases.length === 0 && (
|
||||
<div style={{ padding: '20px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
暂无岗位知识库,请先点击「测试连接」登录平台
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 平台模块列表(预留) */}
|
||||
{modules.length > 0 && (
|
||||
<div style={{ marginTop: 16, border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div
|
||||
onClick={() => setModulesExpanded(!modulesExpanded)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 16px',
|
||||
background: 'var(--surface-2)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{modulesExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
平台模块({modules.length})
|
||||
</div>
|
||||
{modulesExpanded && (
|
||||
<div style={{ padding: '8px 16px 12px' }}>
|
||||
{modules.map((mod, i) => (
|
||||
<div key={mod.id || mod.name || i} style={{ padding: '6px 0', fontSize: 12.5, color: 'var(--text-primary)', borderBottom: i < modules.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
{mod.name || mod.label || mod.id || '未命名模块'}
|
||||
{mod.desc && <span style={{ marginLeft: 8, color: 'var(--text-muted)' }}>- {mod.desc}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 岗位知识库列表 - 始终显示 */}
|
||||
<div style={{ marginTop: 16, border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<div
|
||||
onClick={() => setKbExpanded(!kbExpanded)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
background: 'var(--surface-2)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<BookOpen size={14} color="var(--accent-light)" />
|
||||
岗位知识库列表
|
||||
{selectedKbName && (
|
||||
<span style={{ fontSize: 11, color: 'var(--success)', fontWeight: 500 }}>· 已选: {selectedKbName}</span>
|
||||
)}
|
||||
{!selectedKbName && knowledgeBases.length > 0 && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 400 }}>({knowledgeBases.length})</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{kbLoading && <Loader size={13} style={{ animation: 'spin 1s linear infinite' }} />}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRefreshKB() }}
|
||||
disabled={kbLoading}
|
||||
style={{ background: 'none', border: 'none', cursor: kbLoading ? 'not-allowed' : 'pointer', color: 'var(--text-muted)', padding: 2, display: 'flex' }}
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
{kbExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kbExpanded && knowledgeBases.length > 0 && (
|
||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{knowledgeBases.map((kb, i) => {
|
||||
const badge = statusBadge(kb.status)
|
||||
const docCount = kb.content?.document_count ?? 0
|
||||
const isSelected = kbKey(kb) === selectedKbId
|
||||
return (
|
||||
<div
|
||||
key={kbKey(kb) || i}
|
||||
onClick={() => handleSelectKb(kbKey(kb))}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: i < knowledgeBases.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
background: isSelected ? 'var(--accent-dim)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{/* 单选指示器 */}
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', flexShrink: 0, marginTop: 2,
|
||||
border: `2px solid ${isSelected ? 'var(--accent)' : 'var(--border)'}`,
|
||||
background: isSelected ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all var(--transition)',
|
||||
}}>
|
||||
{isSelected && <Check size={10} color="#fff" />}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<FileText size={13} color="var(--accent-light)" />
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>
|
||||
{kb.relationName || `知识库 #${kb.id}`}
|
||||
</span>
|
||||
{isSelected && <span style={{ fontSize: 10, color: 'var(--accent)', background: 'var(--accent-dim)', padding: '1px 6px', borderRadius: 8, fontWeight: 600 }}>已选</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<span>ID: {kb.id}</span>
|
||||
<span>类型: {kbTypeLabel(kb.kbType)}</span>
|
||||
<span>文档数: {docCount}</span>
|
||||
<span style={{ color: badge.color }}>● {badge.label}</span>
|
||||
{kb.datasetId && <span>Dataset: {kb.datasetId}</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 3, fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
创建人: {kb.createBy} · {kb.createTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 已选知识库摘要 */}
|
||||
{selectedKb ? (
|
||||
<div style={{ marginTop: 10, padding: '8px 14px', fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>当前推送目标知识库:</span>
|
||||
<span style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{selectedKb.relationName}</span>
|
||||
<span style={{ fontSize: 11 }}>(ID: {selectedKb.id}, Dataset: {selectedKb.datasetId}, {kbTypeLabel(selectedKb.kbType)}, {selectedKb.content?.document_count ?? 0} 文档)</span>
|
||||
</div>
|
||||
) : selectedKbName ? (
|
||||
<div style={{ marginTop: 10, padding: '8px 14px', fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>当前推送目标知识库:</span>
|
||||
<span style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{selectedKbName}</span>
|
||||
{savedKbInfo?.kbDatasetId && <span style={{ fontSize: 11 }}>(Dataset: {savedKbInfo.kbDatasetId}{savedKbInfo.kbType ? `, ${kbTypeLabel(savedKbInfo.kbType)}` : ''})</span>}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>· 连接后显示完整信息</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 导出供外部调用获取已绑定知识库信息
|
||||
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' }}>
|
||||
@@ -224,6 +671,9 @@ export default function SettingsPage() {
|
||||
{/* AI 配置表单 */}
|
||||
<AISettingsForm />
|
||||
|
||||
{/* 万川 AI 平台对接 */}
|
||||
<WanchuanPlatformForm />
|
||||
|
||||
{CONFIG_ITEMS.map((group) => (
|
||||
<div key={group.group} style={{ marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Plus, Trash2, Sparkles, RefreshCw, ChevronRight, Loader, Settings2, X, Download } from 'lucide-react'
|
||||
import { Plus, Trash2, Sparkles, RefreshCw, ChevronRight, Loader, Settings2, X, Download, Upload } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
getGroups,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import MessageBubble from '../components/MessageBubble'
|
||||
import ReportDocumentView from '../components/ReportDocumentView'
|
||||
import { exportWordDoc } from '../utils/wordExport'
|
||||
import { uploadToKnowledgeBase, getWanchuanKnowledgeBases } from '../api/wanchuan'
|
||||
|
||||
const TYPE_MAP = {
|
||||
1: 0,
|
||||
@@ -139,6 +140,8 @@ export default function TopicsPage({ sessions = [], onToast }) {
|
||||
const [groupPromptDraft, setGroupPromptDraft] = useState('')
|
||||
const [savingGroupPrompt, setSavingGroupPrompt] = useState(false)
|
||||
|
||||
|
||||
const [uploadingToKb, setUploadingToKb] = useState(false)
|
||||
const loadGroups = useCallback(async () => {
|
||||
try {
|
||||
const data = await getGroups()
|
||||
@@ -550,6 +553,7 @@ export default function TopicsPage({ sessions = [], onToast }) {
|
||||
|
||||
const handleSummarize = async () => {
|
||||
if (!selectedTopic) return
|
||||
if (uploadingToKb) return
|
||||
setSummarizing(true)
|
||||
setSummarizeProgress(null)
|
||||
try {
|
||||
@@ -566,7 +570,9 @@ export default function TopicsPage({ sessions = [], onToast }) {
|
||||
}
|
||||
|
||||
let tries = 0
|
||||
let handled = false
|
||||
const poll = setInterval(async () => {
|
||||
if (handled) return
|
||||
tries += 1
|
||||
try {
|
||||
const task = await getTask(taskId)
|
||||
@@ -578,14 +584,26 @@ export default function TopicsPage({ sessions = [], onToast }) {
|
||||
// Ignore malformed progress payloads.
|
||||
}
|
||||
if (taskData.status === 'done') {
|
||||
handled = true
|
||||
clearInterval(poll)
|
||||
setSummarizeProgress(null)
|
||||
setSummarizing(false)
|
||||
handleSelectTopic(selectedTopic)
|
||||
onToast?.('AI 报告生成完成')
|
||||
// 报告生成成功后,如果已绑定知识库则自动上传
|
||||
try {
|
||||
const freshDetail = await getTopic(selectedTopic.id)
|
||||
const reportContent = freshDetail?.knowledge_doc?.content
|
||||
if (reportContent) {
|
||||
await uploadToKnowledgeBaseIfBound(selectedTopic.title, reportContent)
|
||||
}
|
||||
} catch {
|
||||
// 获取报告详情失败不影响主流程
|
||||
}
|
||||
setSummarizeProgress(null)
|
||||
setSummarizing(false)
|
||||
return
|
||||
}
|
||||
if (taskData.status === 'error') {
|
||||
handled = true
|
||||
clearInterval(poll)
|
||||
setSummarizeProgress(null)
|
||||
setSummarizing(false)
|
||||
@@ -594,6 +612,7 @@ export default function TopicsPage({ sessions = [], onToast }) {
|
||||
return
|
||||
}
|
||||
if (tries > 60) {
|
||||
handled = true
|
||||
clearInterval(poll)
|
||||
setSummarizeProgress(null)
|
||||
setSummarizing(false)
|
||||
@@ -602,6 +621,7 @@ export default function TopicsPage({ sessions = [], onToast }) {
|
||||
}
|
||||
} catch (e) {
|
||||
if (tries > 60) {
|
||||
handled = true
|
||||
clearInterval(poll)
|
||||
setSummarizeProgress(null)
|
||||
setSummarizing(false)
|
||||
@@ -631,6 +651,37 @@ export default function TopicsPage({ sessions = [], onToast }) {
|
||||
}
|
||||
}
|
||||
|
||||
// 上传报告到已绑定的万川知识库
|
||||
const uploadToKnowledgeBaseIfBound = async (reportTitle, content) => {
|
||||
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 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
|
||||
await uploadToKnowledgeBase(bound.platformUrl, datasetId, file)
|
||||
onToast?.('报告已同步上传到知识库')
|
||||
} catch (e) {
|
||||
console.error('[上传知识库失败]', e?.message || e)
|
||||
onToast?.(`报告生成成功,但上传到知识库失败: ${e.message}`, 'error')
|
||||
} finally {
|
||||
setUploadingToKb(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedInitState = selectedGroup ? initStateByGroup[selectedGroup.id] : null
|
||||
const analyzing = selectedInitState?.status === 'running'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user