feat: sync knowledge management to desktop

This commit is contained in:
zhayujie
2026-06-25 11:43:19 +08:00
parent 8023c4e8b7
commit 5c67e970d1
6 changed files with 731 additions and 19 deletions

View File

@@ -1100,7 +1100,7 @@
<!-- Knowledge Action Dialog --> <!-- 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-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="p-6">
<div class="flex items-center gap-3 mb-5"> <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"> <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> <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" <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"> 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" <div id="knowledge-dialog-select" class="cfg-dropdown hidden w-full" tabindex="0">
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 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" <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> 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"> <div id="knowledge-document-form" class="hidden space-y-3">

View File

@@ -8054,7 +8054,10 @@ function openKnowledgeDialog(options) {
documentContent.focus(); documentContent.focus();
}; };
if (options.type === 'select') { 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'; submit.textContent = currentLang === 'zh' ? '确定' : 'Confirm';
cancel.textContent = currentLang === 'zh' ? '取消' : 'Cancel'; cancel.textContent = currentLang === 'zh' ? '取消' : 'Cancel';
@@ -8062,7 +8065,7 @@ function openKnowledgeDialog(options) {
const close = () => overlay.classList.add('hidden'); const close = () => overlay.classList.add('hidden');
const submitAction = async () => { const submitAction = async () => {
const rawValue = options.type === 'select' ? select.value : const rawValue = options.type === 'select' ? getDropdownValue(select) :
(options.type === 'textarea' ? textarea.value : (options.type === 'textarea' ? textarea.value :
(options.type === 'document' ? { (options.type === 'document' ? {
filename: documentFilename.value.trim(), filename: documentFilename.value.trim(),

View File

@@ -16,6 +16,7 @@ import type {
KnowledgeList, KnowledgeList,
KnowledgeGraph, KnowledgeGraph,
KnowledgeAction, KnowledgeAction,
KnowledgeImportPayload,
} from '../types' } from '../types'
interface ApiResult { 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 // Scheduler
// --------------------------------------------------------- // ---------------------------------------------------------

View File

@@ -29,6 +29,43 @@ const translations: Record<string, Record<string, string>> = {
knowledge_graph_empty: '暂无关联图谱', knowledge_graph_empty: '暂无关联图谱',
knowledge_disabled: '知识库未启用', knowledge_disabled: '知识库未启用',
knowledge_doc_load_error: '文档加载失败', 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 和 TXTTXT 会转成 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_expand: '展开侧栏',
nav_collapse: '收起侧栏', nav_collapse: '收起侧栏',
update_available: '发现新版本', update_available: '发现新版本',
@@ -324,6 +361,43 @@ const translations: Record<string, Record<string, string>> = {
knowledge_graph_empty: 'No graph available', knowledge_graph_empty: 'No graph available',
knowledge_disabled: 'Knowledge base is disabled', knowledge_disabled: 'Knowledge base is disabled',
knowledge_doc_load_error: 'Failed to load document', 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', menu_settings: 'Settings',
nav_expand: 'Expand sidebar', nav_expand: 'Expand sidebar',
nav_collapse: 'Collapse sidebar', nav_collapse: 'Collapse sidebar',

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
Loader2, Loader2,
Search, Search,
@@ -8,12 +8,21 @@ import {
MessageSquarePlus, MessageSquarePlus,
Network, Network,
Files, Files,
Plus,
FolderPlus,
FilePlus2,
Upload,
} from 'lucide-react' } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { t } from '../i18n' import { t, getLang } from '../i18n'
import apiClient from '../api/client' 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 Markdown from '../components/Markdown'
import KnowledgeGraph from '../components/KnowledgeGraph' import KnowledgeGraph from '../components/KnowledgeGraph'
@@ -23,12 +32,58 @@ interface KnowledgePageProps {
type Tab = 'docs' | 'graph' 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 => { const formatSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B' if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB' 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). // Find the first document (root files first, then a DFS over the tree).
function firstFile(list: KnowledgeList): { path: string; title: string } | null { function firstFile(list: KnowledgeList): { path: string; title: string } | null {
const root = list.root_files?.[0] const root = list.root_files?.[0]
@@ -50,6 +105,30 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
return 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 KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
const navigate = useNavigate() const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('docs') const [tab, setTab] = useState<Tab>('docs')
@@ -65,6 +144,22 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
const [graph, setGraph] = useState<KnowledgeGraphData | null>(null) const [graph, setGraph] = useState<KnowledgeGraphData | null>(null)
const [graphLoading, setGraphLoading] = useState(false) 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) => { const openDoc = useCallback(async (path: string, title: string) => {
setActivePath(path) setActivePath(path)
setDocTitle(title) 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(() => { useEffect(() => {
apiClient.setBaseUrl(baseUrl) apiClient.setBaseUrl(baseUrl)
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
try {
setLoading(true) setLoading(true)
try {
const fresh = await apiClient.getKnowledgeList() const fresh = await apiClient.getKnowledgeList()
if (cancelled) return if (cancelled) return
setData(fresh) setData(fresh)
// Auto-open the first document so the viewer isn't empty on entry.
const first = firstFile(fresh) const first = firstFile(fresh)
if (first) void openDoc(first.path, first.title) if (first) void openDoc(first.path, first.title)
} catch (e) { } catch (e) {
@@ -101,7 +217,9 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
return () => { return () => {
cancelled = true 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 () => { const loadGraph = useCallback(async () => {
if (graph) return if (graph) return
@@ -130,6 +248,115 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
[openDoc] [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 totalPages = data?.stats?.pages ?? 0
const statsLabel = useMemo(() => { const statsLabel = useMemo(() => {
if (!data) return '' if (!data) return ''
@@ -178,6 +405,17 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
<h2 className="text-xl font-bold text-content">{t('knowledge_title')}</h2> <h2 className="text-xl font-bold text-content">{t('knowledge_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{statsLabel}</p> <p className="text-xs text-content-tertiary mt-1">{statsLabel}</p>
</div> </div>
<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"> <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={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
<TabBtn <TabBtn
@@ -187,10 +425,61 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
onClick={() => switchTab('graph')} onClick={() => switchTab('graph')}
/> />
</div> </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> </div>
{tab === 'docs' ? ( {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 */} {/* Tree sidebar */}
<div className="w-72 flex-shrink-0 flex flex-col border-r border-default min-h-0"> <div className="w-72 flex-shrink-0 flex flex-col border-r border-default min-h-0">
<div className="p-3 flex-shrink-0"> <div className="p-3 flex-shrink-0">
@@ -251,6 +540,313 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
)} )}
</div> </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> </div>
) )
} }

View File

@@ -422,11 +422,27 @@ export interface KnowledgeGraph {
export type KnowledgeAction = export type KnowledgeAction =
| { action: 'create_category'; payload: { path: string } } | { 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: 'rename_category'; payload: { path: string; new_path: string } }
| { action: 'delete_category'; payload: { path: string; confirm?: boolean } } | { action: 'delete_category'; payload: { path: string; confirm?: boolean } }
| { action: 'delete_documents'; payload: { paths: string[] } } | { action: 'delete_documents'; payload: { paths: string[] } }
| { action: 'move_documents'; payload: { paths: string[]; target_category: 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 // Scheduler
// ============================================================ // ============================================================