diff --git a/chatlab-web/frontend/check-wanchuan-config.js b/chatlab-web/frontend/check-wanchuan-config.js new file mode 100644 index 0000000..939a49c --- /dev/null +++ b/chatlab-web/frontend/check-wanchuan-config.js @@ -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,需要先在设置页面绑定知识库'); +} diff --git a/chatlab-web/frontend/src/api/wanchuan.js b/chatlab-web/frontend/src/api/wanchuan.js new file mode 100644 index 0000000..5af72ab --- /dev/null +++ b/chatlab-web/frontend/src/api/wanchuan.js @@ -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 {} +} diff --git a/chatlab-web/frontend/src/pages/KnowledgePage.jsx b/chatlab-web/frontend/src/pages/KnowledgePage.jsx index 44de653..7139797 100644 --- a/chatlab-web/frontend/src/pages/KnowledgePage.jsx +++ b/chatlab-web/frontend/src/pages/KnowledgePage.jsx @@ -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 (
{/* 左栏:售后报告列表 */}
-
售后报告库
+
+
售后报告库
+ +
+ {selectMode && ( +
+ + +
+ )}
@@ -136,19 +277,33 @@ export default function KnowledgePage({ onToast }) { {items.map((doc) => (
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, }} > -
- {doc.title || `文档 #${doc.id}`} -
-
- 更新于 {dayjs(doc.updated_at).format('MM-DD HH:mm')} + {selectMode && ( + toggleChecked(doc.id)} + onClick={(e) => e.stopPropagation()} + style={{ flexShrink: 0 }} + /> + )} +
+
+ {doc.title || `文档 #${doc.id}`} +
+
+ 更新于 {dayjs(doc.updated_at).format('MM-DD HH:mm')} +
))} @@ -181,6 +336,9 @@ export default function KnowledgePage({ onToast }) { ) : ( <> + diff --git a/chatlab-web/frontend/src/pages/SettingsPage.jsx b/chatlab-web/frontend/src/pages/SettingsPage.jsx index c7c5d5f..6ce33c1 100644 --- a/chatlab-web/frontend/src/pages/SettingsPage.jsx +++ b/chatlab-web/frontend/src/pages/SettingsPage.jsx @@ -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 ( +
+
+ 万川 AI 平台对接 +
+
+ 配置第三方平台地址和登录凭证,用于推送数据。测试连接后可查看平台模块。 +
+ +
+ {/* 平台地址 */} +
+
+
平台地址
+
万川 AI 平台 Base URL
+
+ 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' }} + /> +
+
+ + {/* 账号 */} +
+
+
账号
+
平台登录账号
+
+ 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' }} + /> +
+
+ + {/* 密码 */} +
+
+
密码
+
平台登录密码
+
+ 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' }} + /> +
+
+
+ + {/* 操作按钮 + 状态 */} +
+ + {connectError && ( + + {connectError} + + )} + {kbExpanded && !kbLoading && knowledgeBases.length === 0 && ( +
+ 暂无岗位知识库,请先点击「测试连接」登录平台 +
+ )} +
+ + {/* 平台模块列表(预留) */} + {modules.length > 0 && ( +
+
setModulesExpanded(!modulesExpanded)} + style={{ + display: 'flex', alignItems: 'center', gap: 8, + padding: '10px 16px', + background: 'var(--surface-2)', + cursor: 'pointer', + fontSize: 13, + fontWeight: 500, + }} + > + {modulesExpanded ? : } + 平台模块({modules.length}) +
+ {modulesExpanded && ( +
+ {modules.map((mod, i) => ( +
+ {mod.name || mod.label || mod.id || '未命名模块'} + {mod.desc && - {mod.desc}} +
+ ))} +
+ )} +
+ )} + + {/* 岗位知识库列表 - 始终显示 */} +
+
setKbExpanded(!kbExpanded)} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + padding: '10px 16px', + background: 'var(--surface-2)', + cursor: 'pointer', + fontSize: 13, + fontWeight: 500, + }} + > +
+ + 岗位知识库列表 + {selectedKbName && ( + · 已选: {selectedKbName} + )} + {!selectedKbName && knowledgeBases.length > 0 && ( + ({knowledgeBases.length}) + )} +
+
+ {kbLoading && } + + {kbExpanded ? : } +
+
+ + {kbExpanded && knowledgeBases.length > 0 && ( +
+ {knowledgeBases.map((kb, i) => { + const badge = statusBadge(kb.status) + const docCount = kb.content?.document_count ?? 0 + const isSelected = kbKey(kb) === selectedKbId + return ( +
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', + }} + > + {/* 单选指示器 */} +
+ {isSelected && } +
+
+
+ + + {kb.relationName || `知识库 #${kb.id}`} + + {isSelected && 已选} +
+
+ ID: {kb.id} + 类型: {kbTypeLabel(kb.kbType)} + 文档数: {docCount} + ● {badge.label} + {kb.datasetId && Dataset: {kb.datasetId}} +
+
+ 创建人: {kb.createBy} · {kb.createTime} +
+
+
+ ) + })} +
+ )} +
+ + {/* 已选知识库摘要 */} + {selectedKb ? ( +
+ 当前推送目标知识库: + {selectedKb.relationName} + (ID: {selectedKb.id}, Dataset: {selectedKb.datasetId}, {kbTypeLabel(selectedKb.kbType)}, {selectedKb.content?.document_count ?? 0} 文档) +
+ ) : selectedKbName ? ( +
+ 当前推送目标知识库: + {selectedKbName} + {savedKbInfo?.kbDatasetId && (Dataset: {savedKbInfo.kbDatasetId}{savedKbInfo.kbType ? `, ${kbTypeLabel(savedKbInfo.kbType)}` : ''})} + · 连接后显示完整信息 +
+ ) : null} +
+ ) +} + +// 导出供外部调用获取已绑定知识库信息 +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 (
@@ -224,6 +671,9 @@ export default function SettingsPage() { {/* AI 配置表单 */} + {/* 万川 AI 平台对接 */} + + {CONFIG_ITEMS.map((group) => (
diff --git a/chatlab-web/frontend/src/pages/TopicsPage.jsx b/chatlab-web/frontend/src/pages/TopicsPage.jsx index 14dc6fd..4e6b36d 100644 --- a/chatlab-web/frontend/src/pages/TopicsPage.jsx +++ b/chatlab-web/frontend/src/pages/TopicsPage.jsx @@ -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'