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 -->
|
<!-- 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">
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|||||||
@@ -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 和 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_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',
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
|
||||||
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,19 +405,81 @@ 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-1 bg-inset rounded-btn p-0.5">
|
<div className="flex items-center gap-2">
|
||||||
<TabBtn icon={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
|
{status && (
|
||||||
<TabBtn
|
<span
|
||||||
icon={Network}
|
className={`text-xs max-w-[260px] truncate ${
|
||||||
label={t('knowledge_tab_graph')}
|
status.error ? 'text-danger' : 'text-content-tertiary'
|
||||||
active={tab === 'graph'}
|
}`}
|
||||||
onClick={() => switchTab('graph')}
|
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>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user