feat: 知识库页优化——文档列表与检索结果分页;目录来源/文档列表移到检索上方;目录扫描后台化并推送进度条与处理统计(处理/成功/失败/忽略/分块)
This commit is contained in:
+258
-122
@@ -1560,9 +1560,21 @@ pub fn remove_knowledge_base(state: State<'_, App>, id: String) -> Result<(), St
|
|||||||
pub fn list_kb_documents(
|
pub fn list_kb_documents(
|
||||||
state: State<'_, App>,
|
state: State<'_, App>,
|
||||||
kb_id: String,
|
kb_id: String,
|
||||||
) -> Result<Vec<xianren_core::KbDocument>, String> {
|
page: Option<i64>,
|
||||||
|
page_size: Option<i64>,
|
||||||
|
) -> Result<KbDocumentPage, String> {
|
||||||
|
let page = page.unwrap_or(1).max(1);
|
||||||
|
let page_size = page_size.unwrap_or(10).clamp(1, 100);
|
||||||
let db = state.core.db.lock().unwrap();
|
let db = state.core.db.lock().unwrap();
|
||||||
kb_db::list_documents(&db, &kb_id).map_err(|e| e.to_string())
|
let total = kb_db::count_documents(&db, &kb_id).map_err(|e| e.to_string())?;
|
||||||
|
let items = kb_db::list_documents_page(&db, &kb_id, page, page_size)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(KbDocumentPage {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1679,11 +1691,38 @@ pub fn kb_search(
|
|||||||
state: State<'_, App>,
|
state: State<'_, App>,
|
||||||
kb_id: Option<String>,
|
kb_id: Option<String>,
|
||||||
query: String,
|
query: String,
|
||||||
limit: Option<usize>,
|
page: Option<i64>,
|
||||||
) -> Result<Vec<xianren_core::KbSearchHit>, String> {
|
page_size: Option<i64>,
|
||||||
|
) -> Result<KbSearchPage, String> {
|
||||||
|
let page = page.unwrap_or(1).max(1);
|
||||||
|
let page_size = page_size.unwrap_or(10).clamp(1, 100);
|
||||||
let db = state.core.db.lock().unwrap();
|
let db = state.core.db.lock().unwrap();
|
||||||
kb_db::search(&db, kb_id.as_deref(), &query, limit.unwrap_or(10))
|
let total = kb_db::count_search(&db, kb_id.as_deref(), &query).map_err(|e| e.to_string())?;
|
||||||
.map_err(|e| e.to_string())
|
let offset = ((page - 1) * page_size) as usize;
|
||||||
|
let items = kb_db::search(&db, kb_id.as_deref(), &query, page_size as usize, offset)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(KbSearchPage {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct KbDocumentPage {
|
||||||
|
pub items: Vec<xianren_core::KbDocument>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: i64,
|
||||||
|
pub page_size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct KbSearchPage {
|
||||||
|
pub items: Vec<xianren_core::KbSearchHit>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: i64,
|
||||||
|
pub page_size: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -1696,10 +1735,18 @@ pub struct KbSourceInput {
|
|||||||
pub recursive: bool,
|
pub recursive: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct KbSourceScanResult {
|
pub struct KbScanProgressEvent {
|
||||||
|
pub kb_id: String,
|
||||||
|
pub source_path: String,
|
||||||
|
/// scanning(进行中)/ done(完成)
|
||||||
|
pub status: String,
|
||||||
|
pub current: usize,
|
||||||
|
pub total: usize,
|
||||||
pub imported: usize,
|
pub imported: usize,
|
||||||
|
pub failed: usize,
|
||||||
pub ignored: usize,
|
pub ignored: usize,
|
||||||
|
pub chunks: usize,
|
||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1712,70 +1759,115 @@ pub fn kb_list_sources(
|
|||||||
kb_db::list_sources(&db, &kb_id).map_err(|e| e.to_string())
|
kb_db::list_sources(&db, &kb_id).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加目录来源并立即扫描导入:只收录文本 / 图片视频 / 音频三类文件,其余忽略。
|
/// 添加目录来源并后台扫描导入:只收录文本 / 图片视频 / 音频三类文件,其余忽略;进度通过 `kb://scan-progress` 事件推送。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn kb_add_source(
|
pub async fn kb_add_source(
|
||||||
|
app: AppHandle,
|
||||||
state: State<'_, App>,
|
state: State<'_, App>,
|
||||||
input: KbSourceInput,
|
input: KbSourceInput,
|
||||||
) -> Result<KbSourceScanResult, String> {
|
) -> Result<(), String> {
|
||||||
let dir = std::path::PathBuf::from(input.path.trim());
|
let dir = std::path::PathBuf::from(input.path.trim());
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return Err(format!("目录不存在或不是文件夹:{}", dir.display()));
|
return Err(format!("目录不存在或不是文件夹:{}", dir.display()));
|
||||||
}
|
}
|
||||||
let exts = crate::knowledge::normalize_extensions(&input.extensions);
|
let exts = crate::knowledge::normalize_extensions(&input.extensions);
|
||||||
let db = state.core.db.lock().unwrap();
|
let core = state.core.clone();
|
||||||
let kb = kb_db::get_knowledge_base(&db, &input.kb_id)
|
let (kb, source_id) = {
|
||||||
.map_err(|e| e.to_string())?
|
let db = core.db.lock().unwrap();
|
||||||
.ok_or_else(|| "知识库不存在".to_string())?;
|
let kb = kb_db::get_knowledge_base(&db, &input.kb_id)
|
||||||
let source_id = uuid::Uuid::new_v4().to_string();
|
.map_err(|e| e.to_string())?
|
||||||
let source = xianren_core::KbSource {
|
.ok_or_else(|| "知识库不存在".to_string())?;
|
||||||
id: source_id.clone(),
|
let source_id = uuid::Uuid::new_v4().to_string();
|
||||||
kb_id: input.kb_id.clone(),
|
let source = xianren_core::KbSource {
|
||||||
path: dir.to_string_lossy().to_string(),
|
id: source_id.clone(),
|
||||||
extensions: input.extensions.trim().to_string(),
|
kb_id: input.kb_id.clone(),
|
||||||
recursive: input.recursive,
|
path: dir.to_string_lossy().to_string(),
|
||||||
created_at: String::new(),
|
extensions: input.extensions.trim().to_string(),
|
||||||
|
recursive: input.recursive,
|
||||||
|
created_at: String::new(),
|
||||||
|
};
|
||||||
|
kb_db::upsert_source(&db, &source).map_err(|e| e.to_string())?;
|
||||||
|
// 同目录重复添加时沿用已有来源 id,避免文档归属错乱
|
||||||
|
let source_id = kb_db::list_sources(&db, &input.kb_id)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.path == source.path && s.kb_id == source.kb_id)
|
||||||
|
.map(|s| s.id)
|
||||||
|
.unwrap_or(source_id);
|
||||||
|
(kb, source_id)
|
||||||
};
|
};
|
||||||
kb_db::upsert_source(&db, &source).map_err(|e| e.to_string())?;
|
let app2 = app.clone();
|
||||||
// 同目录重复添加时沿用已有来源 id,避免文档归属错乱
|
tokio::spawn(async move {
|
||||||
let source_id = kb_db::list_sources(&db, &input.kb_id)
|
run_scan_with_progress(&app2, &core, kb, Some(source_id), dir, exts, input.recursive, true)
|
||||||
.map_err(|e| e.to_string())?
|
.await;
|
||||||
.into_iter()
|
});
|
||||||
.find(|s| s.path == source.path && s.kb_id == source.kb_id)
|
Ok(())
|
||||||
.map(|s| s.id)
|
|
||||||
.unwrap_or(source_id);
|
|
||||||
Ok(scan_and_import(&db, &kb, &source_id, &dir, &exts, input.recursive))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重新扫描该知识库的全部目录来源,导入新增文件(按文件路径去重)。
|
/// 重新扫描该知识库的全部目录来源,导入新增文件(按文件路径去重)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn kb_scan_sources(
|
pub async fn kb_scan_sources(
|
||||||
|
app: AppHandle,
|
||||||
state: State<'_, App>,
|
state: State<'_, App>,
|
||||||
kb_id: String,
|
kb_id: String,
|
||||||
) -> Result<KbSourceScanResult, String> {
|
) -> Result<(), String> {
|
||||||
let db = state.core.db.lock().unwrap();
|
let core = state.core.clone();
|
||||||
let kb = kb_db::get_knowledge_base(&db, &kb_id)
|
let (kb, sources) = {
|
||||||
.map_err(|e| e.to_string())?
|
let db = core.db.lock().unwrap();
|
||||||
.ok_or_else(|| "知识库不存在".to_string())?;
|
let kb = kb_db::get_knowledge_base(&db, &kb_id)
|
||||||
let sources = kb_db::list_sources(&db, &kb_id).map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?
|
||||||
let mut total = KbSourceScanResult {
|
.ok_or_else(|| "知识库不存在".to_string())?;
|
||||||
imported: 0,
|
let sources = kb_db::list_sources(&db, &kb_id).map_err(|e| e.to_string())?;
|
||||||
ignored: 0,
|
(kb, sources)
|
||||||
errors: Vec::new(),
|
|
||||||
};
|
};
|
||||||
for src in sources {
|
let app2 = app.clone();
|
||||||
let dir = std::path::PathBuf::from(&src.path);
|
tokio::spawn(async move {
|
||||||
if !dir.is_dir() {
|
let mut total_imported = 0usize;
|
||||||
total.errors.push(format!("目录不存在:{}", src.path));
|
let mut total_failed = 0usize;
|
||||||
continue;
|
let mut total_ignored = 0usize;
|
||||||
|
let mut total_chunks = 0usize;
|
||||||
|
let mut total_current = 0usize;
|
||||||
|
let mut total_files = 0usize;
|
||||||
|
let mut all_errors: Vec<String> = Vec::new();
|
||||||
|
for src in sources {
|
||||||
|
let dir = std::path::PathBuf::from(&src.path);
|
||||||
|
if !dir.is_dir() {
|
||||||
|
all_errors.push(format!("目录不存在:{}", src.path));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let exts = crate::knowledge::normalize_extensions(&src.extensions);
|
||||||
|
let result =
|
||||||
|
run_scan_with_progress(&app2, &core, kb.clone(), Some(src.id), dir, exts, src.recursive, false)
|
||||||
|
.await;
|
||||||
|
total_imported += result.imported;
|
||||||
|
total_failed += result.failed;
|
||||||
|
total_ignored += result.ignored;
|
||||||
|
total_chunks += result.chunks;
|
||||||
|
total_current += result.current;
|
||||||
|
total_files += result.total;
|
||||||
|
for e in result.errors {
|
||||||
|
if all_errors.len() < 30 {
|
||||||
|
all_errors.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let exts = crate::knowledge::normalize_extensions(&src.extensions);
|
let _ = app2.emit(
|
||||||
let r = scan_and_import(&db, &kb, &src.id, &dir, &exts, src.recursive);
|
"kb://scan-progress",
|
||||||
total.imported += r.imported;
|
KbScanProgressEvent {
|
||||||
total.ignored += r.ignored;
|
kb_id: kb.id,
|
||||||
total.errors.extend(r.errors);
|
source_path: "全部目录".into(),
|
||||||
}
|
status: "done".into(),
|
||||||
Ok(total)
|
current: total_current,
|
||||||
|
total: total_files,
|
||||||
|
imported: total_imported,
|
||||||
|
failed: total_failed,
|
||||||
|
ignored: total_ignored,
|
||||||
|
chunks: total_chunks,
|
||||||
|
errors: all_errors,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除目录来源及其导入的全部文档。
|
/// 删除目录来源及其导入的全部文档。
|
||||||
@@ -1790,22 +1882,29 @@ pub fn kb_remove_source(state: State<'_, App>, source_id: String) -> Result<usiz
|
|||||||
Ok(removed)
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 扫描目录并导入文档(文本提取切块;图片/视频/音频以文件名为内容登记;其他忽略)。
|
/// 后台扫描一个目录来源并逐文件推送进度,结束推送 done 事件。
|
||||||
fn scan_and_import(
|
async fn run_scan_with_progress(
|
||||||
db: &rusqlite::Connection,
|
app: &AppHandle,
|
||||||
kb: &xianren_core::KnowledgeBase,
|
core: &CoreApp,
|
||||||
source_id: &str,
|
kb: xianren_core::KnowledgeBase,
|
||||||
dir: &std::path::Path,
|
source_id: Option<String>,
|
||||||
extensions: &[String],
|
dir: std::path::PathBuf,
|
||||||
|
extensions: Vec<String>,
|
||||||
recursive: bool,
|
recursive: bool,
|
||||||
) -> KbSourceScanResult {
|
emit_done: bool,
|
||||||
let mut result = KbSourceScanResult {
|
) -> KbScanProgressEvent {
|
||||||
imported: 0,
|
let source_path = dir.to_string_lossy().to_string();
|
||||||
ignored: 0,
|
let files = crate::knowledge::collect_files(&dir, &extensions, recursive);
|
||||||
errors: Vec::new(),
|
let total = files.len();
|
||||||
};
|
let mut current = 0usize;
|
||||||
let files = crate::knowledge::collect_files(dir, extensions, recursive);
|
let mut imported = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
let mut ignored = 0usize;
|
||||||
|
let mut chunks = 0usize;
|
||||||
|
let mut errors: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for file in files {
|
for file in files {
|
||||||
|
current += 1;
|
||||||
let name = file
|
let name = file
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
@@ -1813,64 +1912,101 @@ fn scan_and_import(
|
|||||||
.to_string();
|
.to_string();
|
||||||
let file_path = file.to_string_lossy().to_string();
|
let file_path = file.to_string_lossy().to_string();
|
||||||
let category = crate::knowledge::classify(&name);
|
let category = crate::knowledge::classify(&name);
|
||||||
if category == crate::knowledge::FileCategory::Other {
|
let duplicate = kb_db::document_exists(&core.db.lock().unwrap(), &kb.id, &file_path)
|
||||||
result.ignored += 1;
|
.unwrap_or(false);
|
||||||
continue;
|
if category == crate::knowledge::FileCategory::Other || duplicate {
|
||||||
}
|
ignored += 1;
|
||||||
if kb_db::document_exists(db, &kb.id, &file_path).unwrap_or(false) {
|
} else {
|
||||||
continue;
|
match process_scan_file(&core.db.lock().unwrap(), &kb, source_id.as_deref(), &file, &name, category) {
|
||||||
}
|
Ok(n) => {
|
||||||
let bytes = match std::fs::read(&file) {
|
imported += 1;
|
||||||
Ok(b) => b,
|
chunks += n;
|
||||||
Err(e) => {
|
}
|
||||||
result.errors.push(format!("{name}: 读取失败 {e}"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let content = if category == crate::knowledge::FileCategory::Text {
|
|
||||||
match crate::knowledge::extract_text(&name, &bytes) {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
result.errors.push(format!("{name}: {e}"));
|
failed += 1;
|
||||||
continue;
|
if errors.len() < 30 {
|
||||||
|
errors.push(format!("{name}: {e}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 图片 / 视频 / 音频:以文件名为内容登记,便于按名检索
|
|
||||||
name.clone()
|
|
||||||
};
|
|
||||||
let chunks = if category == crate::knowledge::FileCategory::Text {
|
|
||||||
kb_db::chunk_text(&content, kb.chunk_size as usize, kb.chunk_overlap as usize)
|
|
||||||
} else {
|
|
||||||
vec![content.clone()]
|
|
||||||
};
|
|
||||||
if chunks.is_empty() {
|
|
||||||
result.errors.push(format!("{name}: 切分后没有有效分块"));
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let doc = xianren_core::KbDocument {
|
let _ = app.emit(
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
"kb://scan-progress",
|
||||||
kb_id: kb.id.clone(),
|
KbScanProgressEvent {
|
||||||
name,
|
kb_id: kb.id.clone(),
|
||||||
file_type: file
|
source_path: source_path.clone(),
|
||||||
.extension()
|
status: "scanning".into(),
|
||||||
.map(|e| e.to_string_lossy().to_lowercase())
|
current,
|
||||||
.unwrap_or_default(),
|
total,
|
||||||
file_size: bytes.len() as i64,
|
imported,
|
||||||
char_count: content.chars().count() as i64,
|
failed,
|
||||||
chunk_count: chunks.len() as i64,
|
ignored,
|
||||||
file_path,
|
chunks,
|
||||||
source_id: Some(source_id.to_string()),
|
errors: errors.clone(),
|
||||||
created_at: String::new(),
|
},
|
||||||
updated_at: String::new(),
|
);
|
||||||
};
|
|
||||||
if let Err(e) = kb_db::insert_document_with_chunks(db, &doc, &content, &chunks) {
|
|
||||||
result.errors.push(format!("{}: 写入失败 {e}", doc.name));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result.imported += 1;
|
|
||||||
}
|
}
|
||||||
result
|
let done = KbScanProgressEvent {
|
||||||
|
kb_id: kb.id.clone(),
|
||||||
|
source_path,
|
||||||
|
status: "done".into(),
|
||||||
|
current: total,
|
||||||
|
total,
|
||||||
|
imported,
|
||||||
|
failed,
|
||||||
|
ignored,
|
||||||
|
chunks,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
if emit_done {
|
||||||
|
let _ = app.emit("kb://scan-progress", done.clone());
|
||||||
|
}
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理单个文件:文本提取切块,媒体以文件名为内容登记;返回导入的分块数。
|
||||||
|
fn process_scan_file(
|
||||||
|
db: &rusqlite::Connection,
|
||||||
|
kb: &xianren_core::KnowledgeBase,
|
||||||
|
source_id: Option<&str>,
|
||||||
|
file: &std::path::Path,
|
||||||
|
name: &str,
|
||||||
|
category: crate::knowledge::FileCategory,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let bytes = std::fs::read(file).map_err(|e| format!("读取失败 {e}"))?;
|
||||||
|
let content = if category == crate::knowledge::FileCategory::Text {
|
||||||
|
crate::knowledge::extract_text(name, &bytes)?
|
||||||
|
} else {
|
||||||
|
// 图片 / 视频 / 音频:以文件名为内容登记,便于按名检索
|
||||||
|
name.to_string()
|
||||||
|
};
|
||||||
|
let chunks = if category == crate::knowledge::FileCategory::Text {
|
||||||
|
kb_db::chunk_text(&content, kb.chunk_size as usize, kb.chunk_overlap as usize)
|
||||||
|
} else {
|
||||||
|
vec![content.clone()]
|
||||||
|
};
|
||||||
|
if chunks.is_empty() {
|
||||||
|
return Err("切分后没有有效分块".into());
|
||||||
|
}
|
||||||
|
let doc = xianren_core::KbDocument {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kb_id: kb.id.clone(),
|
||||||
|
name: name.to_string(),
|
||||||
|
file_type: file
|
||||||
|
.extension()
|
||||||
|
.map(|e| e.to_string_lossy().to_lowercase())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
file_size: bytes.len() as i64,
|
||||||
|
char_count: content.chars().count() as i64,
|
||||||
|
chunk_count: chunks.len() as i64,
|
||||||
|
file_path: file.to_string_lossy().to_string(),
|
||||||
|
source_id: source_id.map(str::to_string),
|
||||||
|
created_at: String::new(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
};
|
||||||
|
kb_db::insert_document_with_chunks(db, &doc, &content, &chunks)
|
||||||
|
.map_err(|e| format!("写入失败 {e}"))?;
|
||||||
|
Ok(chunks.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -145,6 +145,38 @@ pub fn list_documents(db: &Connection, kb_id: &str) -> Result<Vec<KbDocument>> {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn count_documents(db: &Connection, kb_id: &str) -> Result<i64> {
|
||||||
|
let n: i64 = db.query_row(
|
||||||
|
"SELECT COUNT(*) FROM kb_documents WHERE kb_id = ?1",
|
||||||
|
params![kb_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页查询文档:`page` 从 1 开始,`page_size` 会被限制在 1–100。
|
||||||
|
pub fn list_documents_page(
|
||||||
|
db: &Connection,
|
||||||
|
kb_id: &str,
|
||||||
|
page: i64,
|
||||||
|
page_size: i64,
|
||||||
|
) -> Result<Vec<KbDocument>> {
|
||||||
|
let page = page.max(1);
|
||||||
|
let page_size = page_size.clamp(1, 100);
|
||||||
|
let offset = (page - 1) * page_size;
|
||||||
|
let mut stmt = db.prepare(&format!(
|
||||||
|
"SELECT {DOC_COLUMNS} FROM kb_documents
|
||||||
|
WHERE kb_id = ?1 ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT ?2 OFFSET ?3"
|
||||||
|
))?;
|
||||||
|
let rows = stmt.query_map(params![kb_id, page_size, offset], row_to_doc)?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
out.push(row?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_document(db: &Connection, id: &str) -> Result<Option<KbDocument>> {
|
pub fn get_document(db: &Connection, id: &str) -> Result<Option<KbDocument>> {
|
||||||
let mut stmt = db.prepare(&format!(
|
let mut stmt = db.prepare(&format!(
|
||||||
"SELECT {DOC_COLUMNS} FROM kb_documents WHERE id = ?1"
|
"SELECT {DOC_COLUMNS} FROM kb_documents WHERE id = ?1"
|
||||||
@@ -349,12 +381,14 @@ pub fn search(
|
|||||||
kb_id: Option<&str>,
|
kb_id: Option<&str>,
|
||||||
query: &str,
|
query: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
|
offset: usize,
|
||||||
) -> Result<Vec<KbSearchHit>> {
|
) -> Result<Vec<KbSearchHit>> {
|
||||||
let q = query.trim();
|
let q = query.trim();
|
||||||
if q.is_empty() {
|
if q.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let limit = limit.clamp(1, 100) as i64;
|
let limit = limit.clamp(1, 100) as i64;
|
||||||
|
let offset = offset as i64;
|
||||||
if q.chars().count() >= 3 {
|
if q.chars().count() >= 3 {
|
||||||
let fts_q = format!("\"{}\"", q.replace('"', "\"\""));
|
let fts_q = format!("\"{}\"", q.replace('"', "\"\""));
|
||||||
let result = db.prepare(
|
let result = db.prepare(
|
||||||
@@ -363,10 +397,10 @@ pub fn search(
|
|||||||
JOIN kb_documents d ON d.id = c.document_id
|
JOIN kb_documents d ON d.id = c.document_id
|
||||||
JOIN kb_chunks_fts ON kb_chunks_fts.rowid = c.rowid
|
JOIN kb_chunks_fts ON kb_chunks_fts.rowid = c.rowid
|
||||||
WHERE (?1 IS NULL OR c.kb_id = ?1) AND kb_chunks_fts MATCH ?2
|
WHERE (?1 IS NULL OR c.kb_id = ?1) AND kb_chunks_fts MATCH ?2
|
||||||
ORDER BY bm25(kb_chunks_fts) LIMIT ?3",
|
ORDER BY bm25(kb_chunks_fts) LIMIT ?3 OFFSET ?4",
|
||||||
)
|
)
|
||||||
.and_then(|mut stmt| {
|
.and_then(|mut stmt| {
|
||||||
let rows = stmt.query_map(params![kb_id, fts_q, limit], row_to_hit)?;
|
let rows = stmt.query_map(params![kb_id, fts_q, limit, offset], row_to_hit)?;
|
||||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
});
|
});
|
||||||
if let Ok(hits) = result {
|
if let Ok(hits) = result {
|
||||||
@@ -379,9 +413,9 @@ pub fn search(
|
|||||||
FROM kb_chunks c
|
FROM kb_chunks c
|
||||||
JOIN kb_documents d ON d.id = c.document_id
|
JOIN kb_documents d ON d.id = c.document_id
|
||||||
WHERE (?1 IS NULL OR c.kb_id = ?1) AND c.content LIKE '%' || ?2 || '%'
|
WHERE (?1 IS NULL OR c.kb_id = ?1) AND c.content LIKE '%' || ?2 || '%'
|
||||||
ORDER BY c.rowid LIMIT ?3",
|
ORDER BY c.rowid LIMIT ?3 OFFSET ?4",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![kb_id, q, limit], row_to_hit)?;
|
let rows = stmt.query_map(params![kb_id, q, limit, offset], row_to_hit)?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
out.push(row?);
|
out.push(row?);
|
||||||
@@ -389,6 +423,36 @@ pub fn search(
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 检索命中总数(与 `search` 使用同一套 FTS / LIKE 回退逻辑)。
|
||||||
|
pub fn count_search(db: &Connection, kb_id: Option<&str>, query: &str) -> Result<i64> {
|
||||||
|
let q = query.trim();
|
||||||
|
if q.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
if q.chars().count() >= 3 {
|
||||||
|
let fts_q = format!("\"{}\"", q.replace('"', "\"\""));
|
||||||
|
let result = db.query_row(
|
||||||
|
"SELECT COUNT(*) FROM kb_chunks c
|
||||||
|
JOIN kb_documents d ON d.id = c.document_id
|
||||||
|
JOIN kb_chunks_fts ON kb_chunks_fts.rowid = c.rowid
|
||||||
|
WHERE (?1 IS NULL OR c.kb_id = ?1) AND kb_chunks_fts MATCH ?2",
|
||||||
|
params![kb_id, fts_q],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
);
|
||||||
|
if let Ok(n) = result {
|
||||||
|
return Ok(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let n: i64 = db.query_row(
|
||||||
|
"SELECT COUNT(*) FROM kb_chunks c
|
||||||
|
JOIN kb_documents d ON d.id = c.document_id
|
||||||
|
WHERE (?1 IS NULL OR c.kb_id = ?1) AND c.content LIKE '%' || ?2 || '%'",
|
||||||
|
params![kb_id, q],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
fn row_to_kb(row: &rusqlite::Row<'_>) -> rusqlite::Result<KnowledgeBase> {
|
fn row_to_kb(row: &rusqlite::Row<'_>) -> rusqlite::Result<KnowledgeBase> {
|
||||||
Ok(KnowledgeBase {
|
Ok(KnowledgeBase {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
@@ -491,7 +555,7 @@ mod tests {
|
|||||||
assert_eq!(kbs.len(), 1);
|
assert_eq!(kbs.len(), 1);
|
||||||
assert!(kbs[0].chunk_count >= 1);
|
assert!(kbs[0].chunk_count >= 1);
|
||||||
|
|
||||||
let hits = search(&conn, Some("kb1"), "智能体", 10).unwrap();
|
let hits = search(&conn, Some("kb1"), "智能体", 10, 0).unwrap();
|
||||||
assert_eq!(hits.len(), 1);
|
assert_eq!(hits.len(), 1);
|
||||||
assert!(hits[0].content.contains("智能体"));
|
assert!(hits[0].content.contains("智能体"));
|
||||||
}
|
}
|
||||||
@@ -515,7 +579,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
insert_doc(&conn, "d1", "今天是星期一,天气晴朗,适合出去散步。");
|
insert_doc(&conn, "d1", "今天是星期一,天气晴朗,适合出去散步。");
|
||||||
let hits = search(&conn, Some("kb1"), "散步", 10).unwrap();
|
let hits = search(&conn, Some("kb1"), "散步", 10, 0).unwrap();
|
||||||
assert_eq!(hits.len(), 1);
|
assert_eq!(hits.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +604,7 @@ mod tests {
|
|||||||
insert_doc(&conn, "d1", "知识库测试内容,用于验证删除级联。");
|
insert_doc(&conn, "d1", "知识库测试内容,用于验证删除级联。");
|
||||||
delete_document(&conn, "d1").unwrap();
|
delete_document(&conn, "d1").unwrap();
|
||||||
assert!(get_document(&conn, "d1").unwrap().is_none());
|
assert!(get_document(&conn, "d1").unwrap().is_none());
|
||||||
let hits = search(&conn, Some("kb1"), "知识库", 10).unwrap();
|
let hits = search(&conn, Some("kb1"), "知识库", 10, 0).unwrap();
|
||||||
assert!(hits.is_empty());
|
assert!(hits.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,6 +633,6 @@ mod tests {
|
|||||||
let after = get_document(&conn, "d1").unwrap().unwrap().chunk_count;
|
let after = get_document(&conn, "d1").unwrap().unwrap().chunk_count;
|
||||||
assert_eq!(n as i64, after);
|
assert_eq!(n as i64, after);
|
||||||
assert!(after < before);
|
assert!(after < before);
|
||||||
assert!(!search(&conn, Some("kb1"), "测试文本", 5).unwrap().is_empty());
|
assert!(!search(&conn, Some("kb1"), "测试文本", 5, 0).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,8 +110,8 @@ ui/src/
|
|||||||
| 工作流 | `list_workflows`、`add_workflow`、`update_workflow`、`remove_workflow`、`set_workflow_enabled`、`restore_preset_workflows`、`run_workflow` |
|
| 工作流 | `list_workflows`、`add_workflow`、`update_workflow`、`remove_workflow`、`set_workflow_enabled`、`restore_preset_workflows`、`run_workflow` |
|
||||||
| 定时计划 | `list_scheduled_tasks`、`add_scheduled_task`、`update_scheduled_task`、`remove_scheduled_task`、`set_scheduled_task_enabled`、`run_scheduled_task_now` |
|
| 定时计划 | `list_scheduled_tasks`、`add_scheduled_task`、`update_scheduled_task`、`remove_scheduled_task`、`set_scheduled_task_enabled`、`run_scheduled_task_now` |
|
||||||
| 邮件 | `mail_test`(用当前 SMTP 配置发送测试邮件) |
|
| 邮件 | `mail_test`(用当前 SMTP 配置发送测试邮件) |
|
||||||
| 知识库 | `list_knowledge_bases`、`add_knowledge_base`、`update_knowledge_base`、`remove_knowledge_base`、`list_kb_documents`、`get_kb_document`、`kb_import_documents`、`remove_kb_document`、`kb_rechunk_document`、`kb_search` |
|
| 知识库 | `list_knowledge_bases`、`add_knowledge_base`、`update_knowledge_base`、`remove_knowledge_base`、`list_kb_documents`(分页)、`get_kb_document`、`kb_import_documents`、`remove_kb_document`、`kb_rechunk_document`、`kb_search`(分页) |
|
||||||
| 知识库·目录来源 | `kb_list_sources`、`kb_add_source`、`kb_scan_sources`、`kb_remove_source` |
|
| 知识库·目录来源 | `kb_list_sources`、`kb_add_source`、`kb_scan_sources`(后台扫描,`kb://scan-progress` 事件推送进度)、`kb_remove_source` |
|
||||||
| 应用/设置 | `app_info`、`autostart_status`、`autostart_set`、`settings_get`、`settings_set` |
|
| 应用/设置 | `app_info`、`autostart_status`、`autostart_set`、`settings_get`、`settings_set` |
|
||||||
| 模型 | `list_models`、`import_model`、`remove_model`、`set_model_enabled`、`scan_models`、`add_remote_model` |
|
| 模型 | `list_models`、`import_model`、`remove_model`、`set_model_enabled`、`scan_models`、`add_remote_model` |
|
||||||
| 模型广场 | `search_models`、`list_model_files`、`list_recommended_models`、`import_recommendations`、`fetch_model_page` |
|
| 模型广场 | `search_models`、`list_model_files`、`list_recommended_models`、`import_recommendations`、`fetch_model_page` |
|
||||||
@@ -144,7 +144,7 @@ ui/src/
|
|||||||
- `send_task_email` / `load_smtp_config` / `split_recipients`:读取设置页「邮件」SMTP 配置、拆分多收件人并调用 `mail.rs` 发送。
|
- `send_task_email` / `load_smtp_config` / `split_recipients`:读取设置页「邮件」SMTP 配置、拆分多收件人并调用 `mail.rs` 发送。
|
||||||
- `kb_import_documents`:base64 解码 → `knowledge.rs::extract_text` 提取文本(文本类直接 UTF-8,PDF 走 pdf-extract)→ `kb_db::chunk_text` 切块(按知识库 chunk_size/overlap)→ `insert_document_with_chunks` 事务写入文档与分块,逐文件返回成功/失败结果。
|
- `kb_import_documents`:base64 解码 → `knowledge.rs::extract_text` 提取文本(文本类直接 UTF-8,PDF 走 pdf-extract)→ `kb_db::chunk_text` 切块(按知识库 chunk_size/overlap)→ `insert_document_with_chunks` 事务写入文档与分块,逐文件返回成功/失败结果。
|
||||||
- `kb_search` / `kb_rechunk_document`:检索走 `kb_db::search`(FTS5 trigram + BM25,短查询回退 LIKE);重切按知识库最新分块设置重建该文档分块与索引。
|
- `kb_search` / `kb_rechunk_document`:检索走 `kb_db::search`(FTS5 trigram + BM25,短查询回退 LIKE);重切按知识库最新分块设置重建该文档分块与索引。
|
||||||
- `kb_add_source` / `kb_scan_sources` / `kb_remove_source`:目录来源注册与扫描——`knowledge.rs::collect_files` 按后缀过滤(空=全部)与递归开关收集文件,`classify` 分成文本 / 图片视频 / 音频三类(其他忽略),文本提取切块入库、媒体以文件名为内容登记,`kb_db::document_exists` 按(kb_id + file_path)去重;删除来源级联删除其导入文档。
|
- `kb_add_source` / `kb_scan_sources` / `kb_remove_source`:目录来源注册与后台扫描——`knowledge.rs::collect_files` 按后缀过滤(空=全部)与递归开关收集文件,`classify` 分成文本 / 图片视频 / 音频三类(其他忽略),文本提取切块入库、媒体以文件名为内容登记,`kb_db::document_exists` 按(kb_id + file_path)去重;扫描过程逐文件推送 `kb://scan-progress`(处理数/成功/失败/忽略/分块),多目录重扫最后推送一次汇总 done;删除来源级联删除其导入文档。
|
||||||
|
|
||||||
**邮件发送模块(`apps/desktop/src/mail.rs`):**
|
**邮件发送模块(`apps/desktop/src/mail.rs`):**
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ ui/src/
|
|||||||
|
|
||||||
### 5.9 `knowledge_base.rs`
|
### 5.9 `knowledge_base.rs`
|
||||||
|
|
||||||
知识库 CRUD(`list/get/insert/update/delete`)、文档 CRUD(`list/get/get_detail`)、`insert_document_with_chunks`(文档 + 分块同事务写入,FTS 触发器自动建索引)、`delete_document`(级联删除分块并同步索引)、`document_exists`(目录导入按文件路径去重)、`rechunk_document`、`chunk_text`(按字符切块 + 重叠)、`search`(FTS5 trigram BM25 排序,查询 <3 字或 MATCH 失败回退 LIKE);目录来源 `list/get/upsert/delete_sources` 与 `delete_docs_by_source`。
|
知识库 CRUD(`list/get/insert/update/delete`)、文档 CRUD(`list/get/get_detail`、`count_documents` / `list_documents_page` 分页)、`insert_document_with_chunks`(文档 + 分块同事务写入,FTS 触发器自动建索引)、`delete_document`(级联删除分块并同步索引)、`document_exists`(目录导入按文件路径去重)、`rechunk_document`、`chunk_text`(按字符切块 + 重叠)、`search` / `count_search`(FTS5 trigram BM25 排序 + 总数统计,查询 <3 字或 MATCH 失败回退 LIKE);目录来源 `list/get/upsert/delete_sources` 与 `delete_docs_by_source`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -269,6 +269,7 @@ ui/src/
|
|||||||
| `workflow://node-status` | 后端→前端 | 工作流节点运行状态(running/done/error)与输出文本 |
|
| `workflow://node-status` | 后端→前端 | 工作流节点运行状态(running/done/error)与输出文本 |
|
||||||
| `scheduled://updated` | 后端→前端 | 定时计划状态变化(创建/编辑/启停/执行完成),前端刷新列表 |
|
| `scheduled://updated` | 后端→前端 | 定时计划状态变化(创建/编辑/启停/执行完成),前端刷新列表 |
|
||||||
| `conversations://updated` | 后端→前端 | 定时计划执行后会话列表变化(供智能体会话页刷新) |
|
| `conversations://updated` | 后端→前端 | 定时计划执行后会话列表变化(供智能体会话页刷新) |
|
||||||
|
| `kb://scan-progress` | 后端→前端 | 知识库目录扫描进度(处理/成功/失败/忽略/分块数;status=scanning/done) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -139,8 +139,10 @@
|
|||||||
- **递归子目录**:可开关是否递归扫描所有子目录;
|
- **递归子目录**:可开关是否递归扫描所有子目录;
|
||||||
- **类型过滤**:只收录**可读文本**(提取内容并分块检索)、**图片 / 视频**、**音频**三类文件(图片视频音频以文件名为内容登记,可按文件名检索),检测到其他类型自动忽略;
|
- **类型过滤**:只收录**可读文本**(提取内容并分块检索)、**图片 / 视频**、**音频**三类文件(图片视频音频以文件名为内容登记,可按文件名检索),检测到其他类型自动忽略;
|
||||||
- 同一目录重复添加自动更新配置不产生重复来源;按文件路径去重,可一键「重新扫描」增量导入新增文件;删除目录来源会同时删除它导入的文档。
|
- 同一目录重复添加自动更新配置不产生重复来源;按文件路径去重,可一键「重新扫描」增量导入新增文件;删除目录来源会同时删除它导入的文档。
|
||||||
- **文档操作**:文档列表展示类型 / 大小 / 字数 / 分块数;支持全文预览、「重切」(按最新分块设置重新切分,适用于修改设置后)与删除。
|
- **扫描进度**:添加目录 / 重新扫描时显示进度条与处理统计(处理 X / Y 个文件、成功、失败、忽略、分块数),失败文件在结尾汇总提示。
|
||||||
- **检索**:全文检索基于 SQLite FTS5(trigram 分词,对中文友好),按 BM25 相关性排序;支持单库检索(也可全库检索),结果展示来源文档与分块序号,可一键复制分块内容用于对话。
|
- **文档操作**:文档列表每页 10 条,带分页控件;展示类型 / 大小 / 字数 / 分块数;支持全文预览、「重切」(按最新分块设置重新切分,适用于修改设置后)与删除。
|
||||||
|
- **检索**:全文检索基于 SQLite FTS5(trigram 分词,对中文友好),按 BM25 相关性排序;支持单库检索(也可全库检索),结果每页 10 条带分页控件,展示来源文档与分块序号,可一键复制分块内容用于对话。
|
||||||
|
- **页面布局**:知识库主区内自上而下依次为「目录来源」→「文档列表」→「检索」。
|
||||||
- 数据表:`knowledge_bases`、`kb_documents`(含提取后的纯文本)、`kb_chunks`(分块)、`kb_chunks_fts`(FTS5 全文索引,contentless-delete 模式 + 触发器同步)。
|
- 数据表:`knowledge_bases`、`kb_documents`(含提取后的纯文本)、`kb_chunks`(分块)、`kb_chunks_fts`(FTS5 全文索引,contentless-delete 模式 + 触发器同步)。
|
||||||
|
|
||||||
## 6. 模型管理页(`/`)
|
## 6. 模型管理页(`/`)
|
||||||
@@ -230,6 +232,7 @@
|
|||||||
|
|
||||||
### 2026-08-17
|
### 2026-08-17
|
||||||
|
|
||||||
|
- 知识库页调整:文档列表与检索结果增加分页控件;目录来源、文档列表区块移到检索上方;目录扫描增加进度条与处理统计(处理/成功/失败/忽略/分块数,事件驱动实时刷新)。
|
||||||
- 修复:知识库「添加目录 → 浏览…」无响应——为 dialog 插件在 capabilities 中补充权限(`dialog:default`),并让选择器异常在前端可见。
|
- 修复:知识库「添加目录 → 浏览…」无响应——为 dialog 插件在 capabilities 中补充权限(`dialog:default`),并让选择器异常在前端可见。
|
||||||
- 知识库「添加目录」弹窗支持两种方式选择目录:手动输入路径,或点击「浏览…」调起系统原生文件夹选择器(接入 tauri-plugin-dialog)。
|
- 知识库「添加目录」弹窗支持两种方式选择目录:手动输入路径,或点击「浏览…」调起系统原生文件夹选择器(接入 tauri-plugin-dialog)。
|
||||||
- 知识库新增「目录来源」:支持添加多个本地目录批量导入(后缀过滤默认全部、可开关递归子目录),只收录文本 / 图片视频 / 音频三类文件其余忽略,支持重新扫描增量导入与按来源删除。
|
- 知识库新增「目录来源」:支持添加多个本地目录批量导入(后缀过滤默认全部、可开关递归子目录),只收录文本 / 图片视频 / 音频三类文件其余忽略,支持重新扫描增量导入与按来源删除。
|
||||||
|
|||||||
+37
-7
@@ -261,9 +261,30 @@ export interface KbSourceInput {
|
|||||||
recursive: boolean;
|
recursive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KbSourceScanResult {
|
export interface KbDocumentPage {
|
||||||
|
items: KbDocument[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KbSearchPage {
|
||||||
|
items: KbSearchHit[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KbScanProgressEvent {
|
||||||
|
kb_id: string;
|
||||||
|
source_path: string;
|
||||||
|
status: string;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
imported: number;
|
imported: number;
|
||||||
|
failed: number;
|
||||||
ignored: number;
|
ignored: number;
|
||||||
|
chunks: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,8 +512,12 @@ export const api = {
|
|||||||
invoke<void>("update_knowledge_base", { id, input }),
|
invoke<void>("update_knowledge_base", { id, input }),
|
||||||
removeKnowledgeBase: (id: string) =>
|
removeKnowledgeBase: (id: string) =>
|
||||||
invoke<void>("remove_knowledge_base", { id }),
|
invoke<void>("remove_knowledge_base", { id }),
|
||||||
listKbDocuments: (kbId: string) =>
|
listKbDocuments: (kbId: string, page?: number, pageSize?: number) =>
|
||||||
invoke<KbDocument[]>("list_kb_documents", { kbId }),
|
invoke<KbDocumentPage>("list_kb_documents", {
|
||||||
|
kbId,
|
||||||
|
page: page ?? 1,
|
||||||
|
pageSize: pageSize ?? 10,
|
||||||
|
}),
|
||||||
getKbDocument: (documentId: string) =>
|
getKbDocument: (documentId: string) =>
|
||||||
invoke<KbDocumentDetail>("get_kb_document", { documentId }),
|
invoke<KbDocumentDetail>("get_kb_document", { documentId }),
|
||||||
kbImportDocuments: (
|
kbImportDocuments: (
|
||||||
@@ -503,13 +528,18 @@ export const api = {
|
|||||||
invoke<void>("remove_kb_document", { documentId }),
|
invoke<void>("remove_kb_document", { documentId }),
|
||||||
kbRechunkDocument: (documentId: string) =>
|
kbRechunkDocument: (documentId: string) =>
|
||||||
invoke<number>("kb_rechunk_document", { documentId }),
|
invoke<number>("kb_rechunk_document", { documentId }),
|
||||||
kbSearch: (kbId: string | null, query: string, limit?: number) =>
|
kbSearch: (kbId: string | null, query: string, page?: number, pageSize?: number) =>
|
||||||
invoke<KbSearchHit[]>("kb_search", { kbId, query, limit: limit ?? 10 }),
|
invoke<KbSearchPage>("kb_search", {
|
||||||
|
kbId,
|
||||||
|
query,
|
||||||
|
page: page ?? 1,
|
||||||
|
pageSize: pageSize ?? 10,
|
||||||
|
}),
|
||||||
kbListSources: (kbId: string) => invoke<KbSource[]>("kb_list_sources", { kbId }),
|
kbListSources: (kbId: string) => invoke<KbSource[]>("kb_list_sources", { kbId }),
|
||||||
kbAddSource: (input: KbSourceInput) =>
|
kbAddSource: (input: KbSourceInput) =>
|
||||||
invoke<KbSourceScanResult>("kb_add_source", { input }),
|
invoke<void>("kb_add_source", { input }),
|
||||||
kbScanSources: (kbId: string) =>
|
kbScanSources: (kbId: string) =>
|
||||||
invoke<KbSourceScanResult>("kb_scan_sources", { kbId }),
|
invoke<void>("kb_scan_sources", { kbId }),
|
||||||
kbRemoveSource: (sourceId: string) =>
|
kbRemoveSource: (sourceId: string) =>
|
||||||
invoke<number>("kb_remove_source", { sourceId }),
|
invoke<number>("kb_remove_source", { sourceId }),
|
||||||
listModels: () => invoke<ModelInfo[]>("list_models"),
|
listModels: () => invoke<ModelInfo[]>("list_models"),
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ import {
|
|||||||
api,
|
api,
|
||||||
KbDocument,
|
KbDocument,
|
||||||
KbDocumentDetail,
|
KbDocumentDetail,
|
||||||
|
KbScanProgressEvent,
|
||||||
KbSearchHit,
|
KbSearchHit,
|
||||||
KbSource,
|
KbSource,
|
||||||
KbSourceScanResult,
|
|
||||||
KnowledgeBase,
|
KnowledgeBase,
|
||||||
KnowledgeBaseInput,
|
KnowledgeBaseInput,
|
||||||
|
onEvent,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
|
||||||
|
const DOC_PAGE_SIZE = 10;
|
||||||
|
const SEARCH_PAGE_SIZE = 10;
|
||||||
|
|
||||||
function fmtSize(bytes: number) {
|
function fmtSize(bytes: number) {
|
||||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
@@ -48,6 +52,8 @@ export default function KnowledgeBasePage() {
|
|||||||
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
|
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [docs, setDocs] = useState<KbDocument[]>([]);
|
const [docs, setDocs] = useState<KbDocument[]>([]);
|
||||||
|
const [docTotal, setDocTotal] = useState(0);
|
||||||
|
const [docPage, setDocPage] = useState(1);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [kbModal, setKbModal] = useState<{
|
const [kbModal, setKbModal] = useState<{
|
||||||
@@ -59,10 +65,16 @@ export default function KnowledgeBasePage() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<KbSearchHit[] | null>(null);
|
const [searchResults, setSearchResults] = useState<KbSearchHit[] | null>(null);
|
||||||
|
const [searchTotal, setSearchTotal] = useState(0);
|
||||||
|
const [searchPage, setSearchPage] = useState(1);
|
||||||
const [sources, setSources] = useState<KbSource[]>([]);
|
const [sources, setSources] = useState<KbSource[]>([]);
|
||||||
const [sourceModal, setSourceModal] = useState(false);
|
const [sourceModal, setSourceModal] = useState(false);
|
||||||
const [sourceMsg, setSourceMsg] = useState<string | null>(null);
|
const [sourceMsg, setSourceMsg] = useState<string | null>(null);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [scanProgress, setScanProgress] = useState<KbScanProgressEvent | null>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const selectedIdRef = useRef<string | null>(null);
|
||||||
|
const docPageRef = useRef(1);
|
||||||
|
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => kbs.find((k) => k.id === selectedId) ?? null,
|
() => kbs.find((k) => k.id === selectedId) ?? null,
|
||||||
@@ -79,27 +91,73 @@ export default function KnowledgeBasePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDocPage(kbId: string, page: number) {
|
||||||
|
const p = await api.listKbDocuments(kbId, page, DOC_PAGE_SIZE);
|
||||||
|
if (p.items.length === 0 && p.page > 1) {
|
||||||
|
const prev = await api.listKbDocuments(kbId, p.page - 1, DOC_PAGE_SIZE);
|
||||||
|
setDocs(prev.items);
|
||||||
|
setDocTotal(prev.total);
|
||||||
|
setDocPage(prev.page);
|
||||||
|
docPageRef.current = prev.page;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDocs(p.items);
|
||||||
|
setDocTotal(p.total);
|
||||||
|
setDocPage(p.page);
|
||||||
|
docPageRef.current = p.page;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshKbs().catch((e) => setMsg(String(e)));
|
refreshKbs().catch((e) => setMsg(String(e)));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedIdRef.current = selectedId;
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
docPageRef.current = docPage;
|
||||||
|
}, [docPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
setDocs([]);
|
setDocs([]);
|
||||||
|
setDocTotal(0);
|
||||||
|
setDocPage(1);
|
||||||
|
docPageRef.current = 1;
|
||||||
|
setSources([]);
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
|
setSearchTotal(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api
|
|
||||||
.listKbDocuments(selectedId)
|
|
||||||
.then(setDocs)
|
|
||||||
.catch((e) => setMsg(`加载文档失败:${String(e)}`));
|
|
||||||
api
|
api
|
||||||
.kbListSources(selectedId)
|
.kbListSources(selectedId)
|
||||||
.then(setSources)
|
.then(setSources)
|
||||||
.catch((e) => setMsg(`加载目录来源失败:${String(e)}`));
|
.catch((e) => setMsg(`加载目录来源失败:${String(e)}`));
|
||||||
|
loadDocPage(selectedId, 1).catch((e) => setMsg(`加载文档失败:${String(e)}`));
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const un = onEvent<KbScanProgressEvent>("kb://scan-progress", (e) => {
|
||||||
|
setScanProgress(e);
|
||||||
|
setScanning(e.status !== "done");
|
||||||
|
if (e.status === "done") {
|
||||||
|
refreshKbs().catch(() => {});
|
||||||
|
if (selectedIdRef.current === e.kb_id) {
|
||||||
|
api
|
||||||
|
.kbListSources(e.kb_id)
|
||||||
|
.then(setSources)
|
||||||
|
.catch(() => {});
|
||||||
|
loadDocPage(e.kb_id, docPageRef.current).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
un.then((f) => f());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleSaveKb(input: KnowledgeBaseInput) {
|
async function handleSaveKb(input: KnowledgeBaseInput) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -121,7 +179,11 @@ export default function KnowledgeBasePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteKb(kb: KnowledgeBase) {
|
async function handleDeleteKb(kb: KnowledgeBase) {
|
||||||
if (!confirm(`确定删除知识库「${kb.name}」吗?将同时删除其中 ${kb.doc_count} 个文档与 ${kb.chunk_count} 个分块。`)) {
|
if (
|
||||||
|
!confirm(
|
||||||
|
`确定删除知识库「${kb.name}」吗?将同时删除其中 ${kb.doc_count} 个文档与 ${kb.chunk_count} 个分块。`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -147,10 +209,14 @@ export default function KnowledgeBasePage() {
|
|||||||
const okCount = results.filter((r) => r.ok).length;
|
const okCount = results.filter((r) => r.ok).length;
|
||||||
const failed = results.filter((r) => !r.ok);
|
const failed = results.filter((r) => !r.ok);
|
||||||
setUploadMsg(
|
setUploadMsg(
|
||||||
`导入完成:成功 ${okCount} 个${failed.length > 0 ? `,失败 ${failed.length} 个(${failed.map((f) => `${f.name}: ${f.error}`).join(";")})` : ""}`,
|
`导入完成:成功 ${okCount} 个${
|
||||||
|
failed.length > 0
|
||||||
|
? `,失败 ${failed.length} 个(${failed.map((f) => `${f.name}: ${f.error}`).join(";")})`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
await refreshKbs();
|
await refreshKbs();
|
||||||
setDocs(await api.listKbDocuments(selected.id));
|
await loadDocPage(selected.id, docPageRef.current);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setUploadMsg(`导入失败:${String(e)}`);
|
setUploadMsg(`导入失败:${String(e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -165,7 +231,7 @@ export default function KnowledgeBasePage() {
|
|||||||
const n = await api.kbRechunkDocument(doc.id);
|
const n = await api.kbRechunkDocument(doc.id);
|
||||||
setMsg(`已重新切分为 ${n} 个分块`);
|
setMsg(`已重新切分为 ${n} 个分块`);
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setDocs(await api.listKbDocuments(selected.id));
|
await loadDocPage(selected.id, docPageRef.current);
|
||||||
await refreshKbs();
|
await refreshKbs();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -179,7 +245,7 @@ export default function KnowledgeBasePage() {
|
|||||||
await api.removeKbDocument(doc.id);
|
await api.removeKbDocument(doc.id);
|
||||||
setMsg("文档已删除");
|
setMsg("文档已删除");
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setDocs(await api.listKbDocuments(selected.id));
|
await loadDocPage(selected.id, docPageRef.current);
|
||||||
await refreshKbs();
|
await refreshKbs();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -187,11 +253,14 @@ export default function KnowledgeBasePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSearch() {
|
async function handleSearch(page = 1) {
|
||||||
if (!query.trim()) return;
|
if (!query.trim()) return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
setSearchResults(await api.kbSearch(selectedId, query.trim(), 20));
|
const p = await api.kbSearch(selectedId, query.trim(), page, SEARCH_PAGE_SIZE);
|
||||||
|
setSearchResults(p.items);
|
||||||
|
setSearchTotal(p.total);
|
||||||
|
setSearchPage(p.page);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(`检索失败:${String(e)}`);
|
setMsg(`检索失败:${String(e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -199,42 +268,23 @@ export default function KnowledgeBasePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshSourcesAndDocs() {
|
|
||||||
if (!selected) return;
|
|
||||||
const [d, s] = await Promise.all([
|
|
||||||
api.listKbDocuments(selected.id),
|
|
||||||
api.kbListSources(selected.id),
|
|
||||||
]);
|
|
||||||
setDocs(d);
|
|
||||||
setSources(s);
|
|
||||||
await refreshKbs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatScanMsg(label: string, r: KbSourceScanResult) {
|
|
||||||
const errPart =
|
|
||||||
r.errors.length > 0
|
|
||||||
? `,${r.errors.length} 个错误(${r.errors
|
|
||||||
.slice(0, 3)
|
|
||||||
.join(";")}${r.errors.length > 3 ? "…" : ""})`
|
|
||||||
: "";
|
|
||||||
return `${label}完成:导入 ${r.imported} 个,忽略 ${r.ignored} 个${errPart}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddSource(path: string, extensions: string, recursive: boolean) {
|
async function handleAddSource(path: string, extensions: string, recursive: boolean) {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setSourceMsg(null);
|
setSourceMsg(null);
|
||||||
|
setScanProgress(null);
|
||||||
|
setScanning(true);
|
||||||
try {
|
try {
|
||||||
const r = await api.kbAddSource({
|
await api.kbAddSource({
|
||||||
kb_id: selected.id,
|
kb_id: selected.id,
|
||||||
path,
|
path,
|
||||||
extensions,
|
extensions,
|
||||||
recursive,
|
recursive,
|
||||||
});
|
});
|
||||||
await refreshSourcesAndDocs();
|
setSourceMsg("开始扫描目录,请稍候…");
|
||||||
setSourceMsg(formatScanMsg("添加目录", r));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSourceMsg(`添加目录失败:${String(e)}`);
|
setSourceMsg(`添加目录失败:${String(e)}`);
|
||||||
|
setScanning(false);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
setSourceModal(false);
|
setSourceModal(false);
|
||||||
@@ -245,12 +295,13 @@ export default function KnowledgeBasePage() {
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setSourceMsg(null);
|
setSourceMsg(null);
|
||||||
|
setScanProgress(null);
|
||||||
|
setScanning(true);
|
||||||
try {
|
try {
|
||||||
const r = await api.kbScanSources(selected.id);
|
await api.kbScanSources(selected.id);
|
||||||
await refreshSourcesAndDocs();
|
|
||||||
setSourceMsg(formatScanMsg("重新扫描", r));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSourceMsg(`重新扫描失败:${String(e)}`);
|
setSourceMsg(`重新扫描失败:${String(e)}`);
|
||||||
|
setScanning(false);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -262,7 +313,9 @@ export default function KnowledgeBasePage() {
|
|||||||
const n = await api.kbRemoveSource(src.id);
|
const n = await api.kbRemoveSource(src.id);
|
||||||
setSourceMsg(`已删除目录来源及其 ${n} 个文档`);
|
setSourceMsg(`已删除目录来源及其 ${n} 个文档`);
|
||||||
if (selected) {
|
if (selected) {
|
||||||
await refreshSourcesAndDocs();
|
setSources(await api.kbListSources(selected.id));
|
||||||
|
await loadDocPage(selected.id, docPageRef.current);
|
||||||
|
await refreshKbs();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSourceMsg(`删除失败:${String(e)}`);
|
setSourceMsg(`删除失败:${String(e)}`);
|
||||||
@@ -388,41 +441,6 @@ export default function KnowledgeBasePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* 检索 */}
|
|
||||||
<section className="mb-6 rounded-xl border border-border bg-panel p-4">
|
|
||||||
<div className="mb-2 text-xs uppercase text-slate-500">检索知识库</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
className="input flex-1"
|
|
||||||
value={query}
|
|
||||||
placeholder="输入关键词,检索全部文档分块(支持中文)"
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") handleSearch();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn-primary shrink-0"
|
|
||||||
onClick={handleSearch}
|
|
||||||
disabled={searching || !query.trim()}
|
|
||||||
>
|
|
||||||
<Icon name="search" className="mr-1 inline h-3.5 w-3.5" />
|
|
||||||
{searching ? "检索中…" : "检索"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{searchResults ? (
|
|
||||||
searchResults.length === 0 ? (
|
|
||||||
<div className="mt-3 text-xs text-slate-500">没有匹配的内容</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{searchResults.map((hit) => (
|
|
||||||
<SearchHitRow key={hit.chunk_id} hit={hit} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 目录来源 */}
|
{/* 目录来源 */}
|
||||||
<section className="mb-6 rounded-xl border border-border bg-panel p-4">
|
<section className="mb-6 rounded-xl border border-border bg-panel p-4">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
@@ -433,7 +451,7 @@ export default function KnowledgeBasePage() {
|
|||||||
<button
|
<button
|
||||||
className="btn-secondary !px-2.5 !py-1 text-xs"
|
className="btn-secondary !px-2.5 !py-1 text-xs"
|
||||||
onClick={handleScanAll}
|
onClick={handleScanAll}
|
||||||
disabled={busy || sources.length === 0}
|
disabled={busy || scanning || sources.length === 0}
|
||||||
>
|
>
|
||||||
<Icon name="refresh" className="mr-1 inline h-3 w-3" />
|
<Icon name="refresh" className="mr-1 inline h-3 w-3" />
|
||||||
重新扫描
|
重新扫描
|
||||||
@@ -441,6 +459,7 @@ export default function KnowledgeBasePage() {
|
|||||||
<button
|
<button
|
||||||
className="btn-primary !px-2.5 !py-1 text-xs"
|
className="btn-primary !px-2.5 !py-1 text-xs"
|
||||||
onClick={() => setSourceModal(true)}
|
onClick={() => setSourceModal(true)}
|
||||||
|
disabled={scanning}
|
||||||
>
|
>
|
||||||
+ 添加目录
|
+ 添加目录
|
||||||
</button>
|
</button>
|
||||||
@@ -454,6 +473,40 @@ export default function KnowledgeBasePage() {
|
|||||||
{sourceMsg}
|
{sourceMsg}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{scanProgress ? (
|
||||||
|
<div className="mb-3 rounded-lg border border-border bg-panel-2/60 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-slate-300">
|
||||||
|
<span className="min-w-0 flex-1 truncate">{scanProgress.source_path}</span>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{scanning
|
||||||
|
? `处理中 ${scanProgress.current}/${scanProgress.total}`
|
||||||
|
: "扫描完成"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-panel">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-accent to-accent-2 transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
width: `${
|
||||||
|
scanProgress.total > 0
|
||||||
|
? (scanProgress.current / scanProgress.total) * 100
|
||||||
|
: 0
|
||||||
|
}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 text-[11px] text-slate-400">
|
||||||
|
处理 {scanProgress.current} / {scanProgress.total} 个文件 · 成功{" "}
|
||||||
|
{scanProgress.imported} · 失败 {scanProgress.failed} · 忽略{" "}
|
||||||
|
{scanProgress.ignored} · 分块 {scanProgress.chunks}
|
||||||
|
{scanProgress.errors.length > 0
|
||||||
|
? `(${scanProgress.errors[0]}${
|
||||||
|
scanProgress.errors.length > 1 ? " 等" : ""
|
||||||
|
})`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{sources.length === 0 ? (
|
{sources.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-border bg-panel-2/40 px-3 py-6 text-center text-xs text-slate-500">
|
<div className="rounded-lg border border-dashed border-border bg-panel-2/40 px-3 py-6 text-center text-xs text-slate-500">
|
||||||
还没有目录来源,点击「添加目录」批量导入文件夹中的文档
|
还没有目录来源,点击「添加目录」批量导入文件夹中的文档
|
||||||
@@ -499,30 +552,84 @@ export default function KnowledgeBasePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 文档列表 */}
|
{/* 文档列表 */}
|
||||||
<section>
|
<section className="mb-6">
|
||||||
<div className="mb-2 text-xs uppercase text-slate-500">
|
<div className="mb-2 text-xs uppercase text-slate-500">
|
||||||
文档({docs.length})
|
文档(共 {docTotal} 个)
|
||||||
</div>
|
</div>
|
||||||
{docs.length === 0 ? (
|
{docs.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-border bg-panel/50 px-4 py-10 text-center text-sm text-slate-500">
|
<div className="rounded-xl border border-dashed border-border bg-panel/50 px-4 py-10 text-center text-sm text-slate-500">
|
||||||
还没有文档,点击右上角「上传文档」导入 txt / md / PDF 等文件
|
还没有文档,可在上方「目录来源」批量导入,或点击右上角「上传文档」导入 txt / md / PDF 等文件
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
{docs.map((doc) => (
|
<div className="space-y-2">
|
||||||
<DocRow
|
{docs.map((doc) => (
|
||||||
key={doc.id}
|
<DocRow
|
||||||
doc={doc}
|
key={doc.id}
|
||||||
onPreview={() => {
|
doc={doc}
|
||||||
api.getKbDocument(doc.id).then(setPreview).catch((e) => setMsg(String(e)));
|
onPreview={() => {
|
||||||
}}
|
api
|
||||||
onRechunk={() => handleRechunk(doc)}
|
.getKbDocument(doc.id)
|
||||||
onDelete={() => handleDeleteDoc(doc)}
|
.then(setPreview)
|
||||||
/>
|
.catch((e) => setMsg(String(e)));
|
||||||
))}
|
}}
|
||||||
</div>
|
onRechunk={() => handleRechunk(doc)}
|
||||||
|
onDelete={() => handleDeleteDoc(doc)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pager
|
||||||
|
page={docPage}
|
||||||
|
total={docTotal}
|
||||||
|
pageSize={DOC_PAGE_SIZE}
|
||||||
|
onChange={(p) => selected && loadDocPage(selected.id, p)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* 检索 */}
|
||||||
|
<section className="rounded-xl border border-border bg-panel p-4">
|
||||||
|
<div className="mb-2 text-xs uppercase text-slate-500">检索知识库</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={query}
|
||||||
|
placeholder="输入关键词,检索全部文档分块(支持中文)"
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSearch(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-primary shrink-0"
|
||||||
|
onClick={() => handleSearch(1)}
|
||||||
|
disabled={searching || !query.trim()}
|
||||||
|
>
|
||||||
|
<Icon name="search" className="mr-1 inline h-3.5 w-3.5" />
|
||||||
|
{searching ? "检索中…" : "检索"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{searchResults ? (
|
||||||
|
searchResults.length === 0 ? (
|
||||||
|
<div className="mt-3 text-xs text-slate-500">没有匹配的内容</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{searchResults.map((hit) => (
|
||||||
|
<SearchHitRow key={hit.chunk_id} hit={hit} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pager
|
||||||
|
page={searchPage}
|
||||||
|
total={searchTotal}
|
||||||
|
pageSize={SEARCH_PAGE_SIZE}
|
||||||
|
onChange={(p) => handleSearch(p)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
@@ -571,6 +678,41 @@ export default function KnowledgeBasePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Pager({
|
||||||
|
page,
|
||||||
|
total,
|
||||||
|
pageSize,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
total: number;
|
||||||
|
pageSize: number;
|
||||||
|
onChange: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex items-center justify-center gap-3 text-xs text-slate-400">
|
||||||
|
<button
|
||||||
|
className="rounded border border-border bg-panel-2 px-2.5 py-1 hover:text-slate-200 disabled:opacity-40"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onChange(page - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
第 {page} / {pages} 页 · 共 {total} 条
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="rounded border border-border bg-panel-2 px-2.5 py-1 hover:text-slate-200 disabled:opacity-40"
|
||||||
|
disabled={page >= pages}
|
||||||
|
onClick={() => onChange(page + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SearchHitRow({ hit }: { hit: KbSearchHit }) {
|
function SearchHitRow({ hit }: { hit: KbSearchHit }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -845,10 +987,7 @@ function SourceModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Toggle
|
<Toggle checked={recursive} onChange={(v) => setRecursive(v)} />
|
||||||
checked={recursive}
|
|
||||||
onChange={(v) => setRecursive(v)}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-slate-400">
|
<span className="text-xs text-slate-400">
|
||||||
{recursive ? "递归扫描所有子目录" : "仅扫描当前目录"}
|
{recursive ? "递归扫描所有子目录" : "仅扫描当前目录"}
|
||||||
</span>
|
</span>
|
||||||
@@ -866,7 +1005,7 @@ function SourceModal({
|
|||||||
disabled={busy || !valid}
|
disabled={busy || !valid}
|
||||||
onClick={() => onSave(path.trim(), extensions.trim(), recursive)}
|
onClick={() => onSave(path.trim(), extensions.trim(), recursive)}
|
||||||
>
|
>
|
||||||
{busy ? "扫描中…" : "添加并扫描"}
|
{busy ? "添加中…" : "添加并扫描"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user