mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat: sync knowledge management to desktop
This commit is contained in:
@@ -1100,7 +1100,7 @@
|
||||
|
||||
<!-- Knowledge Action Dialog -->
|
||||
<div id="knowledge-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
||||
<div id="knowledge-dialog-card" class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4 overflow-hidden">
|
||||
<div id="knowledge-dialog-card" class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
|
||||
@@ -1114,8 +1114,13 @@
|
||||
<label id="knowledge-dialog-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
|
||||
<input id="knowledge-dialog-input" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500">
|
||||
<select id="knowledge-dialog-select"
|
||||
class="hidden w-full pl-3 pr-8 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-[#222] text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"></select>
|
||||
<div id="knowledge-dialog-select" class="cfg-dropdown hidden w-full" tabindex="0">
|
||||
<div class="cfg-dropdown-selected">
|
||||
<span class="cfg-dropdown-text">--</span>
|
||||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||||
</div>
|
||||
<div class="cfg-dropdown-menu"></div>
|
||||
</div>
|
||||
<textarea id="knowledge-dialog-textarea" rows="8"
|
||||
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono resize-y"></textarea>
|
||||
<div id="knowledge-document-form" class="hidden space-y-3">
|
||||
|
||||
@@ -8054,7 +8054,10 @@ function openKnowledgeDialog(options) {
|
||||
documentContent.focus();
|
||||
};
|
||||
if (options.type === 'select') {
|
||||
select.innerHTML = (options.choices || []).map(value => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join('');
|
||||
// Use the shared custom dropdown component instead of a native
|
||||
// <select> so the arrow / menu match the rest of the console.
|
||||
const ddOptions = (options.choices || []).map(value => ({ value, label: value }));
|
||||
initDropdown(select, ddOptions, (options.choices || [])[0] || '', null);
|
||||
}
|
||||
submit.textContent = currentLang === 'zh' ? '确定' : 'Confirm';
|
||||
cancel.textContent = currentLang === 'zh' ? '取消' : 'Cancel';
|
||||
@@ -8062,7 +8065,7 @@ function openKnowledgeDialog(options) {
|
||||
|
||||
const close = () => overlay.classList.add('hidden');
|
||||
const submitAction = async () => {
|
||||
const rawValue = options.type === 'select' ? select.value :
|
||||
const rawValue = options.type === 'select' ? getDropdownValue(select) :
|
||||
(options.type === 'textarea' ? textarea.value :
|
||||
(options.type === 'document' ? {
|
||||
filename: documentFilename.value.trim(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
KnowledgeList,
|
||||
KnowledgeGraph,
|
||||
KnowledgeAction,
|
||||
KnowledgeImportPayload,
|
||||
} from '../types'
|
||||
|
||||
interface ApiResult {
|
||||
@@ -315,6 +316,23 @@ class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
// Bulk import: upload .md/.txt files into a target category (multipart).
|
||||
async importKnowledge(
|
||||
files: File[],
|
||||
targetCategory: string
|
||||
): Promise<{ status: string; message?: string; payload?: KnowledgeImportPayload }> {
|
||||
const formData = new FormData()
|
||||
formData.append('target_category', targetCategory)
|
||||
formData.append('conflict_strategy', 'rename')
|
||||
files.forEach((file) => formData.append('files', file, file.name))
|
||||
const res = await fetch(`${this.baseUrl}/api/knowledge/import`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------
|
||||
|
||||
@@ -29,6 +29,43 @@ const translations: Record<string, Record<string, string>> = {
|
||||
knowledge_graph_empty: '暂无关联图谱',
|
||||
knowledge_disabled: '知识库未启用',
|
||||
knowledge_doc_load_error: '文档加载失败',
|
||||
// knowledge management
|
||||
knowledge_new: '新建',
|
||||
knowledge_new_category: '新建分类',
|
||||
knowledge_new_document: '新建文档',
|
||||
knowledge_import_documents: '导入文档',
|
||||
knowledge_working: '处理中...',
|
||||
knowledge_importing: '正在导入...',
|
||||
knowledge_request_failed: '请求失败,请稍后重试',
|
||||
knowledge_import_failed: '导入失败',
|
||||
knowledge_category_created: '分类已创建',
|
||||
knowledge_document_created: '文档已创建',
|
||||
knowledge_dialog_confirm: '确定',
|
||||
knowledge_dialog_cancel: '取消',
|
||||
knowledge_field_required: '此项不能为空',
|
||||
knowledge_category_label: '分类路径',
|
||||
knowledge_category_hint: '支持嵌套路径,例如 research/ai',
|
||||
knowledge_category_subtitle: '分类会创建为 knowledge/ 下的目录',
|
||||
knowledge_need_category: '请先创建分类',
|
||||
knowledge_destination: '目标分类',
|
||||
knowledge_doc_choose_category: '先选择分类,然后输入文件名',
|
||||
knowledge_doc_save_to: '保存到 {category}',
|
||||
knowledge_doc_filename: '文件名',
|
||||
knowledge_doc_filename_required: '文件名不能为空',
|
||||
knowledge_doc_must_md: '新建文档仅支持 .md 文件名',
|
||||
knowledge_doc_content: 'Markdown 内容',
|
||||
knowledge_doc_content_required: '内容不能为空',
|
||||
knowledge_doc_content_too_large: '内容不能超过 10MB',
|
||||
knowledge_doc_insert_template: '插入模板',
|
||||
knowledge_import_selected: '已选择 {count} 个文件',
|
||||
knowledge_import_hint: '支持 Markdown 和 TXT,TXT 会转成 Markdown 文档',
|
||||
knowledge_import_need_category: '请先创建一个分类',
|
||||
knowledge_import_choose_files: '请选择 .md 或 .txt 文件',
|
||||
knowledge_import_too_many: '一次最多导入 {max} 个文件',
|
||||
knowledge_import_file_too_large: '{name} 超过 10MB',
|
||||
knowledge_import_total_too_large: '单次导入总大小不能超过 200MB',
|
||||
knowledge_import_result: '导入 {imported} 个,跳过 {skipped} 个,失败 {failed} 个',
|
||||
knowledge_drop_hint: '拖放 .md / .txt 文件到此导入',
|
||||
nav_expand: '展开侧栏',
|
||||
nav_collapse: '收起侧栏',
|
||||
update_available: '发现新版本',
|
||||
@@ -324,6 +361,43 @@ const translations: Record<string, Record<string, string>> = {
|
||||
knowledge_graph_empty: 'No graph available',
|
||||
knowledge_disabled: 'Knowledge base is disabled',
|
||||
knowledge_doc_load_error: 'Failed to load document',
|
||||
// knowledge management
|
||||
knowledge_new: 'New',
|
||||
knowledge_new_category: 'New category',
|
||||
knowledge_new_document: 'New document',
|
||||
knowledge_import_documents: 'Import documents',
|
||||
knowledge_working: 'Working...',
|
||||
knowledge_importing: 'Importing...',
|
||||
knowledge_request_failed: 'Request failed, please try again',
|
||||
knowledge_import_failed: 'Import failed',
|
||||
knowledge_category_created: 'Category created',
|
||||
knowledge_document_created: 'Document created',
|
||||
knowledge_dialog_confirm: 'Confirm',
|
||||
knowledge_dialog_cancel: 'Cancel',
|
||||
knowledge_field_required: 'This field is required',
|
||||
knowledge_category_label: 'Category path',
|
||||
knowledge_category_hint: 'Nested paths are supported, e.g. research/ai',
|
||||
knowledge_category_subtitle: 'Creates a directory under knowledge/',
|
||||
knowledge_need_category: 'Create a category first',
|
||||
knowledge_destination: 'Destination category',
|
||||
knowledge_doc_choose_category: 'Choose a category, then enter a filename',
|
||||
knowledge_doc_save_to: 'Save to {category}',
|
||||
knowledge_doc_filename: 'Filename',
|
||||
knowledge_doc_filename_required: 'Filename is required',
|
||||
knowledge_doc_must_md: 'New documents must be .md files',
|
||||
knowledge_doc_content: 'Markdown content',
|
||||
knowledge_doc_content_required: 'Content is required',
|
||||
knowledge_doc_content_too_large: 'Content cannot exceed 10MB',
|
||||
knowledge_doc_insert_template: 'Insert template',
|
||||
knowledge_import_selected: '{count} file(s) selected',
|
||||
knowledge_import_hint: 'Markdown and TXT are supported. TXT is converted to Markdown.',
|
||||
knowledge_import_need_category: 'Create a category first',
|
||||
knowledge_import_choose_files: 'Choose .md or .txt files',
|
||||
knowledge_import_too_many: 'Import at most {max} files at a time',
|
||||
knowledge_import_file_too_large: '{name} exceeds 10MB',
|
||||
knowledge_import_total_too_large: 'Total import size cannot exceed 200MB',
|
||||
knowledge_import_result: '{imported} imported · {skipped} skipped · {failed} failed',
|
||||
knowledge_drop_hint: 'Drop .md / .txt files here to import',
|
||||
menu_settings: 'Settings',
|
||||
nav_expand: 'Expand sidebar',
|
||||
nav_collapse: 'Collapse sidebar',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Loader2,
|
||||
Search,
|
||||
@@ -8,12 +8,21 @@ import {
|
||||
MessageSquarePlus,
|
||||
Network,
|
||||
Files,
|
||||
Plus,
|
||||
FolderPlus,
|
||||
FilePlus2,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { t } from '../i18n'
|
||||
import { t, getLang } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
import type { KnowledgeDir, KnowledgeFile, KnowledgeList, KnowledgeGraph as KnowledgeGraphData } from '../types'
|
||||
import type {
|
||||
KnowledgeDir,
|
||||
KnowledgeFile,
|
||||
KnowledgeList,
|
||||
KnowledgeGraph as KnowledgeGraphData,
|
||||
} from '../types'
|
||||
import Markdown from '../components/Markdown'
|
||||
import KnowledgeGraph from '../components/KnowledgeGraph'
|
||||
|
||||
@@ -23,12 +32,58 @@ interface KnowledgePageProps {
|
||||
|
||||
type Tab = 'docs' | 'graph'
|
||||
|
||||
const KNOWLEDGE_IMPORT_MAX_FILES = 100
|
||||
const KNOWLEDGE_IMPORT_MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE = 200 * 1024 * 1024
|
||||
|
||||
// t() with simple {placeholder} interpolation.
|
||||
const tf = (key: string, vars: Record<string, string | number>): string => {
|
||||
let out = t(key)
|
||||
for (const [k, v] of Object.entries(vars)) out = out.replace(`{${k}}`, String(v))
|
||||
return out
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
// Flatten the tree into category paths (for destination selectors).
|
||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||
const paths: string[] = []
|
||||
for (const dir of dirs || []) {
|
||||
const path = parent ? `${parent}/${dir.dir}` : dir.dir
|
||||
paths.push(path, ...categoryPaths(dir.children || [], path))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// Validate a batch of files chosen for import. Returns an error message or ''.
|
||||
function validateImportFiles(files: File[]): string {
|
||||
if (!files.length) return t('knowledge_import_choose_files')
|
||||
if (files.length > KNOWLEDGE_IMPORT_MAX_FILES) {
|
||||
return tf('knowledge_import_too_many', { max: KNOWLEDGE_IMPORT_MAX_FILES })
|
||||
}
|
||||
let total = 0
|
||||
for (const file of files) {
|
||||
total += file.size || 0
|
||||
if ((file.size || 0) > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
|
||||
return tf('knowledge_import_file_too_large', { name: file.name })
|
||||
}
|
||||
}
|
||||
if (total > KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE) return t('knowledge_import_total_too_large')
|
||||
return ''
|
||||
}
|
||||
|
||||
// ---- Dialog model ----------------------------------------------------------
|
||||
|
||||
interface DialogState {
|
||||
kind: 'category' | 'doc-pick-category' | 'document' | 'import'
|
||||
category?: string
|
||||
files?: File[]
|
||||
}
|
||||
|
||||
// Find the first document (root files first, then a DFS over the tree).
|
||||
function firstFile(list: KnowledgeList): { path: string; title: string } | null {
|
||||
const root = list.root_files?.[0]
|
||||
@@ -50,6 +105,30 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve a document's display title from its path, falling back to the stem.
|
||||
function findTitle(list: KnowledgeList, path: string): string {
|
||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||
for (const f of list.root_files || []) {
|
||||
if (f.name === path) return f.title || fallback
|
||||
}
|
||||
const walk = (dir: KnowledgeDir, prefix: string): string | null => {
|
||||
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||
for (const f of dir.files) {
|
||||
if (`${dirPath}/${f.name}` === path) return f.title || fallback
|
||||
}
|
||||
for (const c of dir.children) {
|
||||
const hit = walk(c, dirPath)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
for (const d of list.tree || []) {
|
||||
const hit = walk(d, '')
|
||||
if (hit) return hit
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<Tab>('docs')
|
||||
@@ -65,6 +144,22 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
const [graph, setGraph] = useState<KnowledgeGraphData | null>(null)
|
||||
const [graphLoading, setGraphLoading] = useState(false)
|
||||
|
||||
// Management UI state.
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null)
|
||||
const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const statusTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const showStatus = useCallback((text: string, error = false, sticky = false) => {
|
||||
if (statusTimer.current) clearTimeout(statusTimer.current)
|
||||
setStatus({ text, error })
|
||||
if (!sticky) {
|
||||
statusTimer.current = setTimeout(() => setStatus(null), 4000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openDoc = useCallback(async (path: string, title: string) => {
|
||||
setActivePath(path)
|
||||
setDocTitle(title)
|
||||
@@ -80,16 +175,37 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
||||
// currently open doc (or open the first one on the initial load).
|
||||
const refresh = useCallback(
|
||||
async (targetPath?: string) => {
|
||||
try {
|
||||
const fresh = await apiClient.getKnowledgeList()
|
||||
setData(fresh)
|
||||
if (targetPath) {
|
||||
void openDoc(targetPath, findTitle(fresh, targetPath))
|
||||
} else if (!activePath) {
|
||||
const first = firstFile(fresh)
|
||||
if (first) void openDoc(first.path, first.title)
|
||||
}
|
||||
return fresh
|
||||
} catch (e) {
|
||||
console.error('Failed to load knowledge:', e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[openDoc, activePath]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.setBaseUrl(baseUrl)
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setLoading(true)
|
||||
const fresh = await apiClient.getKnowledgeList()
|
||||
if (cancelled) return
|
||||
setData(fresh)
|
||||
// Auto-open the first document so the viewer isn't empty on entry.
|
||||
const first = firstFile(fresh)
|
||||
if (first) void openDoc(first.path, first.title)
|
||||
} catch (e) {
|
||||
@@ -101,7 +217,9 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [baseUrl, openDoc])
|
||||
// Only run on baseUrl change (initial mount). refresh() handles later reloads.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseUrl])
|
||||
|
||||
const loadGraph = useCallback(async () => {
|
||||
if (graph) return
|
||||
@@ -130,6 +248,115 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
[openDoc]
|
||||
)
|
||||
|
||||
// ---- Management actions --------------------------------------------------
|
||||
|
||||
const categories = useMemo(() => categoryPaths(data?.tree || []), [data])
|
||||
|
||||
const createCategory = useCallback(
|
||||
async (path: string): Promise<string | null> => {
|
||||
showStatus(t('knowledge_working'), false, true)
|
||||
try {
|
||||
const res = await apiClient.knowledgeAction({ action: 'create_category', payload: { path } })
|
||||
if (res.status !== 'success') {
|
||||
showStatus((res.message as string) || t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
showStatus(t('knowledge_category_created'))
|
||||
await refresh()
|
||||
return path
|
||||
} catch {
|
||||
showStatus(t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[refresh, showStatus]
|
||||
)
|
||||
|
||||
const createDocument = useCallback(
|
||||
async (path: string, content: string): Promise<string | null> => {
|
||||
showStatus(t('knowledge_working'), false, true)
|
||||
try {
|
||||
const res = await apiClient.knowledgeAction({
|
||||
action: 'create_document',
|
||||
payload: { path, content, overwrite: false },
|
||||
})
|
||||
if (res.status !== 'success') {
|
||||
showStatus((res.message as string) || t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
const created = ((res.payload as { path?: string })?.path) || path
|
||||
showStatus(t('knowledge_document_created'))
|
||||
await refresh(created)
|
||||
return created
|
||||
} catch {
|
||||
showStatus(t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[refresh, showStatus]
|
||||
)
|
||||
|
||||
const importDocuments = useCallback(
|
||||
async (files: File[], targetCategory: string): Promise<boolean> => {
|
||||
const err = validateImportFiles(files)
|
||||
if (err) {
|
||||
showStatus(err, true)
|
||||
return false
|
||||
}
|
||||
const supported = files.filter((f) => /\.(md|txt)$/i.test(f.name || ''))
|
||||
if (!supported.length) {
|
||||
showStatus(t('knowledge_import_choose_files'), true)
|
||||
return false
|
||||
}
|
||||
showStatus(t('knowledge_importing'), false, true)
|
||||
try {
|
||||
const res = await apiClient.importKnowledge(supported, targetCategory)
|
||||
if (res.status !== 'success') {
|
||||
showStatus(res.message || t('knowledge_import_failed'), true)
|
||||
await refresh()
|
||||
return false
|
||||
}
|
||||
const p = res.payload
|
||||
showStatus(
|
||||
tf('knowledge_import_result', {
|
||||
imported: p?.imported ?? 0,
|
||||
skipped: p?.skipped ?? 0,
|
||||
failed: p?.failed ?? 0,
|
||||
})
|
||||
)
|
||||
const first = (p?.results || []).find((r) => r.status === 'imported')
|
||||
await refresh(first?.path)
|
||||
return true
|
||||
} catch {
|
||||
showStatus(t('knowledge_import_failed'), true)
|
||||
return false
|
||||
}
|
||||
},
|
||||
[refresh, showStatus]
|
||||
)
|
||||
|
||||
// Open the import dialog after validating the chosen files.
|
||||
const startImport = useCallback(
|
||||
(files: File[]) => {
|
||||
const err = validateImportFiles(files)
|
||||
if (err) {
|
||||
showStatus(err, true)
|
||||
return
|
||||
}
|
||||
setDialog({ kind: 'import', files })
|
||||
},
|
||||
[showStatus]
|
||||
)
|
||||
|
||||
const onFilesPicked = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
e.target.value = ''
|
||||
if (files.length) startImport(files)
|
||||
},
|
||||
[startImport]
|
||||
)
|
||||
|
||||
const totalPages = data?.stats?.pages ?? 0
|
||||
const statsLabel = useMemo(() => {
|
||||
if (!data) return ''
|
||||
@@ -178,19 +405,81 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
<h2 className="text-xl font-bold text-content">{t('knowledge_title')}</h2>
|
||||
<p className="text-xs text-content-tertiary mt-1">{statsLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
|
||||
<TabBtn icon={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
|
||||
<TabBtn
|
||||
icon={Network}
|
||||
label={t('knowledge_tab_graph')}
|
||||
active={tab === 'graph'}
|
||||
onClick={() => switchTab('graph')}
|
||||
<div className="flex items-center gap-2">
|
||||
{status && (
|
||||
<span
|
||||
className={`text-xs max-w-[260px] truncate ${
|
||||
status.error ? 'text-danger' : 'text-content-tertiary'
|
||||
}`}
|
||||
title={status.text}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
|
||||
<TabBtn icon={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
|
||||
<TabBtn
|
||||
icon={Network}
|
||||
label={t('knowledge_tab_graph')}
|
||||
active={tab === 'graph'}
|
||||
onClick={() => switchTab('graph')}
|
||||
/>
|
||||
</div>
|
||||
<NewMenu
|
||||
open={menuOpen}
|
||||
setOpen={setMenuOpen}
|
||||
onCreateCategory={() => setDialog({ kind: 'category' })}
|
||||
onCreateDocument={() => {
|
||||
if (!categories.length) {
|
||||
showStatus(t('knowledge_need_category'), true)
|
||||
return
|
||||
}
|
||||
setDialog({ kind: 'doc-pick-category' })
|
||||
}}
|
||||
onImport={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".md,.txt,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
onChange={onFilesPicked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === 'docs' ? (
|
||||
<div className="flex-1 flex min-h-0 border-t border-default">
|
||||
<div
|
||||
className="flex-1 flex min-h-0 border-t border-default relative"
|
||||
onDragEnter={(e) => {
|
||||
if (e.dataTransfer?.types?.includes('Files')) {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer?.types?.includes('Files')) e.preventDefault()
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// Only clear when leaving the panel, not its children.
|
||||
if (e.currentTarget === e.target) setDragOver(false)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const files = Array.from(e.dataTransfer?.files || [])
|
||||
if (files.length) startImport(files)
|
||||
}}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-accent-soft/80 border-2 border-dashed border-accent rounded-lg m-2 pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-2 text-accent">
|
||||
<Upload size={28} />
|
||||
<p className="text-sm font-medium">{t('knowledge_drop_hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Tree sidebar */}
|
||||
<div className="w-72 flex-shrink-0 flex flex-col border-r border-default min-h-0">
|
||||
<div className="p-3 flex-shrink-0">
|
||||
@@ -251,6 +540,313 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<KnowledgeDialog
|
||||
state={dialog}
|
||||
categories={categories}
|
||||
onClose={() => setDialog(null)}
|
||||
onCreateCategory={createCategory}
|
||||
onPickDocCategory={(category) => setDialog({ kind: 'document', category })}
|
||||
onCreateDocument={createDocument}
|
||||
onImport={importDocuments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- New menu --------------------------------------------------------------
|
||||
|
||||
const NewMenu: React.FC<{
|
||||
open: boolean
|
||||
setOpen: (v: boolean) => void
|
||||
onCreateCategory: () => void
|
||||
onCreateDocument: () => void
|
||||
onImport: () => void
|
||||
}> = ({ open, setOpen, onCreateCategory, onCreateDocument, onImport }) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open, setOpen])
|
||||
|
||||
const pick = (fn: () => void) => {
|
||||
setOpen(false)
|
||||
fn()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('knowledge_new')}
|
||||
<ChevronDown size={12} className="opacity-80" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1.5 w-44 z-50 bg-surface border border-default rounded-lg shadow-lg py-1">
|
||||
<MenuItem icon={FolderPlus} label={t('knowledge_new_category')} onClick={() => pick(onCreateCategory)} />
|
||||
<MenuItem icon={FilePlus2} label={t('knowledge_new_document')} onClick={() => pick(onCreateDocument)} />
|
||||
<MenuItem icon={Upload} label={t('knowledge_import_documents')} onClick={() => pick(onImport)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<{ icon: LucideIcon; label: string; onClick: () => void }> = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-content-secondary hover:bg-surface-2 cursor-pointer transition-colors text-left"
|
||||
>
|
||||
<Icon size={14} className="opacity-70 flex-shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
// ---- Dialog ----------------------------------------------------------------
|
||||
|
||||
function templateFor(filename: string): string {
|
||||
const title = (filename || 'untitled').replace(/\.md$/i, '')
|
||||
return getLang() === 'zh'
|
||||
? `# ${title}\n\n## 摘要\n\n\n## 关键点\n\n- \n\n## 参考\n\n`
|
||||
: `# ${title}\n\n## Summary\n\n\n## Key points\n\n- \n\n## References\n\n`
|
||||
}
|
||||
|
||||
const KnowledgeDialog: React.FC<{
|
||||
state: DialogState
|
||||
categories: string[]
|
||||
onClose: () => void
|
||||
onCreateCategory: (path: string) => Promise<string | null>
|
||||
onPickDocCategory: (category: string) => void
|
||||
onCreateDocument: (path: string, content: string) => Promise<string | null>
|
||||
onImport: (files: File[], target: string) => Promise<boolean>
|
||||
}> = ({ state, categories, onClose, onCreateCategory, onPickDocCategory, onCreateDocument, onImport }) => {
|
||||
const [categoryInput, setCategoryInput] = useState('')
|
||||
const [selected, setSelected] = useState(categories[0] || '')
|
||||
const [filename, setFilename] = useState('')
|
||||
const [contentInput, setContentInput] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setError('')
|
||||
if (state.kind === 'category') {
|
||||
const path = categoryInput.trim()
|
||||
if (!path) return setError(t('knowledge_field_required'))
|
||||
setBusy(true)
|
||||
const ok = await onCreateCategory(path)
|
||||
setBusy(false)
|
||||
if (ok !== null) onClose()
|
||||
return
|
||||
}
|
||||
if (state.kind === 'doc-pick-category') {
|
||||
if (!selected) return setError(t('knowledge_field_required'))
|
||||
onPickDocCategory(selected)
|
||||
return
|
||||
}
|
||||
if (state.kind === 'document') {
|
||||
const name = filename.trim()
|
||||
if (!name) return setError(t('knowledge_doc_filename_required'))
|
||||
if (/\.[^.]+$/i.test(name) && !/\.md$/i.test(name)) return setError(t('knowledge_doc_must_md'))
|
||||
if (!contentInput.trim()) return setError(t('knowledge_doc_content_required'))
|
||||
if (new Blob([contentInput]).size > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
|
||||
return setError(t('knowledge_doc_content_too_large'))
|
||||
}
|
||||
const safeName = name.endsWith('.md') ? name : `${name}.md`
|
||||
setBusy(true)
|
||||
const ok = await onCreateDocument(`${state.category}/${safeName}`, contentInput)
|
||||
setBusy(false)
|
||||
if (ok !== null) onClose()
|
||||
return
|
||||
}
|
||||
if (state.kind === 'import') {
|
||||
if (!selected) return setError(t('knowledge_field_required'))
|
||||
setBusy(true)
|
||||
const ok = await onImport(state.files || [], selected)
|
||||
setBusy(false)
|
||||
if (ok) onClose()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const titleMap: Record<DialogState['kind'], string> = {
|
||||
category: t('knowledge_new_category'),
|
||||
'doc-pick-category': t('knowledge_new_document'),
|
||||
document: t('knowledge_new_document'),
|
||||
import: t('knowledge_import_documents'),
|
||||
}
|
||||
|
||||
const noCategory = (state.kind === 'doc-pick-category' || state.kind === 'import') && !categories.length
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 p-4" onMouseDown={onClose}>
|
||||
<div
|
||||
className="w-full max-w-lg bg-surface border border-default rounded-xl shadow-xl p-5"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-base font-semibold text-content mb-1">{titleMap[state.kind]}</h3>
|
||||
|
||||
{state.kind === 'category' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">{t('knowledge_category_subtitle')}</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_category_label')}</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
placeholder="research/ai"
|
||||
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-content-tertiary mt-1.5">{t('knowledge_category_hint')}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === 'doc-pick-category' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">{t('knowledge_doc_choose_category')}</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_destination')}</label>
|
||||
<CategorySelect value={selected} options={categories} onChange={setSelected} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === 'document' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">
|
||||
{tf('knowledge_doc_save_to', { category: state.category || '' })}
|
||||
</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_doc_filename')}</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="my-note.md"
|
||||
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors mb-3"
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm text-content-secondary">{t('knowledge_doc_content')}</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!contentInput.trim()) setContentInput(templateFor(filename))
|
||||
}}
|
||||
className="text-xs text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
{t('knowledge_doc_insert_template')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={contentInput}
|
||||
onChange={(e) => setContentInput(e.target.value)}
|
||||
rows={10}
|
||||
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors font-mono resize-y"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === 'import' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">
|
||||
{tf('knowledge_import_selected', { count: state.files?.length ?? 0 })}
|
||||
</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_destination')}</label>
|
||||
<CategorySelect value={selected} options={categories} onChange={setSelected} />
|
||||
<p className="text-xs text-content-tertiary mt-1.5">
|
||||
{categories.length ? t('knowledge_import_hint') : t('knowledge_import_need_category')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-danger mt-3">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-btn border border-strong text-content-secondary hover:bg-surface-2 text-sm font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
{t('knowledge_dialog_cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || noCategory}
|
||||
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
|
||||
>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('knowledge_dialog_confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Custom dropdown: keeps the arrow / menu styling consistent with the rest of
|
||||
// the desktop UI (a native <select> renders an OS arrow we can't space out).
|
||||
const CategorySelect: React.FC<{ value: string; options: string[]; onChange: (v: string) => void }> = ({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open])
|
||||
|
||||
const disabled = !options.length
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-2 rounded-btn border text-sm text-content transition-colors cursor-pointer ${
|
||||
open ? 'border-accent' : 'border-strong'
|
||||
} bg-inset hover:border-accent/70 disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<span className="truncate">{value || '--'}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`flex-shrink-0 text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-60 overflow-y-auto bg-surface border border-default rounded-lg shadow-lg p-1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-md text-sm cursor-pointer transition-colors truncate ${
|
||||
opt === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -422,11 +422,27 @@ export interface KnowledgeGraph {
|
||||
|
||||
export type KnowledgeAction =
|
||||
| { action: 'create_category'; payload: { path: string } }
|
||||
| { action: 'create_document'; payload: { path: string; content: string; overwrite?: boolean } }
|
||||
| { action: 'rename_category'; payload: { path: string; new_path: string } }
|
||||
| { action: 'delete_category'; payload: { path: string; confirm?: boolean } }
|
||||
| { action: 'delete_documents'; payload: { paths: string[] } }
|
||||
| { action: 'move_documents'; payload: { paths: string[]; target_category: string } }
|
||||
|
||||
// Result row from a bulk import (one per uploaded file).
|
||||
export interface KnowledgeImportResult {
|
||||
status: 'imported' | 'skipped' | 'failed'
|
||||
path?: string
|
||||
name?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface KnowledgeImportPayload {
|
||||
imported: number
|
||||
skipped: number
|
||||
failed: number
|
||||
results: KnowledgeImportResult[]
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Scheduler
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user