Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8c548792 | ||
|
|
01b17177c8 |
Generated
+7
@@ -1780,6 +1780,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -4125,6 +4131,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
|
||||
@@ -17,7 +17,7 @@ custom-protocol = ["tauri/custom-protocol"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
@@ -1668,6 +1668,16 @@ pub fn remove_kb_document(state: State<'_, App>, document_id: String) -> Result<
|
||||
kb_db::delete_document(&db, &document_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 清空知识库内的全部文档(分块与检索索引一并删除)。
|
||||
#[tauri::command]
|
||||
pub fn kb_clear_documents(state: State<'_, App>, kb_id: String) -> Result<i64, String> {
|
||||
let db = state.core.db.lock().unwrap();
|
||||
kb_db::get_knowledge_base(&db, &kb_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "知识库不存在".to_string())?;
|
||||
kb_db::delete_all_documents(&db, &kb_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 按知识库最新分块设置重新切分指定文档。
|
||||
#[tauri::command]
|
||||
pub fn kb_rechunk_document(
|
||||
|
||||
@@ -194,6 +194,7 @@ pub fn run() {
|
||||
commands::get_kb_document,
|
||||
commands::kb_import_documents,
|
||||
commands::remove_kb_document,
|
||||
commands::kb_clear_documents,
|
||||
commands::kb_rechunk_document,
|
||||
commands::kb_search,
|
||||
commands::kb_list_sources,
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -35,4 +39,3 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,15 @@ pub fn delete_document(db: &Connection, id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清空某知识库的全部文档(分块随外键级联删除,FTS 索引由触发器同步)。
|
||||
pub fn delete_all_documents(db: &Connection, kb_id: &str) -> Result<i64> {
|
||||
let n = db.execute(
|
||||
"DELETE FROM kb_documents WHERE kb_id = ?1",
|
||||
params![kb_id],
|
||||
)?;
|
||||
Ok(n as i64)
|
||||
}
|
||||
|
||||
/// 同一知识库下是否已存在来自该文件路径的文档(目录导入去重)。
|
||||
pub fn document_exists(db: &Connection, kb_id: &str, file_path: &str) -> Result<bool> {
|
||||
if file_path.is_empty() {
|
||||
@@ -608,6 +617,32 @@ mod tests {
|
||||
assert!(hits.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_all_documents_removes_everything() {
|
||||
let conn = test_conn();
|
||||
insert_knowledge_base(
|
||||
&conn,
|
||||
&KnowledgeBase {
|
||||
id: "kb1".to_string(),
|
||||
name: "测试库".to_string(),
|
||||
description: String::new(),
|
||||
chunk_size: 200,
|
||||
chunk_overlap: 20,
|
||||
doc_count: 0,
|
||||
chunk_count: 0,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
insert_doc(&conn, "d1", "第一份文档内容,用于清空测试。");
|
||||
insert_doc(&conn, "d2", "第二份文档内容,同样会被清空。");
|
||||
let n = delete_all_documents(&conn, "kb1").unwrap();
|
||||
assert_eq!(n, 2);
|
||||
assert!(list_documents(&conn, "kb1").unwrap().is_empty());
|
||||
assert!(search(&conn, Some("kb1"), "清空", 10, 0).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rechunk_changes_chunk_count() {
|
||||
let conn = test_conn();
|
||||
|
||||
@@ -98,6 +98,7 @@ ui/src/
|
||||
|
||||
- `run()`:初始化数据目录(`%APPDATA%\XianrenStudio`)、日志、panic 钩子;构建 Tauri 应用并注册 `invoke_handler`。
|
||||
- 注册插件:`tauri-plugin-opener`、`tauri-plugin-autostart`(开机启动,Windows 写注册表启动项)、`tauri-plugin-dialog`(原生文件/文件夹选择对话框,知识库目录来源「浏览…」使用)。
|
||||
- `tauri.conf.json` 启用 `assetProtocol`(scope `**`):知识库媒体文档(图片/视频/音频)预览通过 `convertFileSrc` 直读本地文件渲染/播放。
|
||||
- `setup` 末尾启动**自动加载模型**任务(`auto_load_models`:按设置列表顺序逐个部署,最后一个保持运行)。
|
||||
- `setup` 末尾启动**定时计划后台调度循环**(tokio interval 每 30 秒调用 `run_due_scheduled_tasks`)。
|
||||
- 数据目录:`logs/`、`models/`、`engines/`、`recommendations/`。
|
||||
@@ -110,7 +111,7 @@ ui/src/
|
||||
| 工作流 | `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` |
|
||||
| 邮件 | `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_clear_documents`、`kb_rechunk_document`、`kb_search`(分页) |
|
||||
| 知识库·目录来源 | `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` |
|
||||
| 模型 | `list_models`、`import_model`、`remove_model`、`set_model_enabled`、`scan_models`、`add_remote_model` |
|
||||
|
||||
+4
-1
@@ -140,7 +140,8 @@
|
||||
- **类型过滤**:只收录**可读文本**(提取内容并分块检索)、**图片 / 视频**、**音频**三类文件(图片视频音频以文件名为内容登记,可按文件名检索),检测到其他类型自动忽略;
|
||||
- 同一目录重复添加自动更新配置不产生重复来源;按文件路径去重,可一键「重新扫描」增量导入新增文件;删除目录来源会同时删除它导入的文档。
|
||||
- **扫描进度**:添加目录 / 重新扫描时显示进度条与处理统计(处理 X / Y 个文件、成功、失败、忽略、分块数),失败文件在结尾汇总提示。
|
||||
- **文档操作**:文档列表每页 10 条,带分页控件;展示类型 / 大小 / 字数 / 分块数;支持全文预览、「重切」(按最新分块设置重新切分,适用于修改设置后)与删除。
|
||||
- **文档操作**:文档列表每页 10 条,带分页控件;展示类型 / 大小 / 字数 / 分块数;支持全文预览、「重切」(按最新分块设置重新切分,适用于修改设置后)、单条删除;标题「文档(共 x 个)」右侧提供「清空」按钮,一键清空该知识库全部文档(含分块与检索索引),清空前弹窗警告确认。
|
||||
- **媒体预览**:图片 / 视频 / 音频文档点击「预览」直接展示实际内容——图片原图显示、视频/音频用原生播放器播放(经 Tauri asset protocol 直接读取本地文件,无需整文件过 IPC);文件被移动/删除时提示无法加载。
|
||||
- **检索**:全文检索基于 SQLite FTS5(trigram 分词,对中文友好),按 BM25 相关性排序;支持单库检索(也可全库检索),结果每页 10 条带分页控件,展示来源文档与分块序号,可一键复制分块内容用于对话。
|
||||
- **页面布局**:知识库主区内自上而下依次为「目录来源」→「文档列表」→「检索」。
|
||||
- 数据表:`knowledge_bases`、`kb_documents`(含提取后的纯文本)、`kb_chunks`(分块)、`kb_chunks_fts`(FTS5 全文索引,contentless-delete 模式 + 触发器同步)。
|
||||
@@ -232,6 +233,8 @@
|
||||
|
||||
### 2026-08-17
|
||||
|
||||
- 知识库文档预览支持多媒体:图片/视频/音频点击「预览」直接展示实际内容(asset protocol 直读本地文件,视频音频可播放)。
|
||||
- 知识库文档列表标题增加「清空」按钮:一键清空当前知识库全部文档(分块与索引级联删除),清空前弹出警告确认。
|
||||
- 知识库页调整:文档列表与检索结果增加分页控件;目录来源、文档列表区块移到检索上方;目录扫描增加进度条与处理统计(处理/成功/失败/忽略/分块数,事件驱动实时刷新)。
|
||||
- 修复:知识库「添加目录 → 浏览…」无响应——为 dialog 插件在 capabilities 中补充权限(`dialog:default`),并让选择器异常在前端可见。
|
||||
- 知识库「添加目录」弹窗支持两种方式选择目录:手动输入路径,或点击「浏览…」调起系统原生文件夹选择器(接入 tauri-plugin-dialog)。
|
||||
|
||||
@@ -221,6 +221,8 @@ export interface KbDocument {
|
||||
file_size: number;
|
||||
char_count: number;
|
||||
chunk_count: number;
|
||||
file_path: string;
|
||||
source_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -526,6 +528,8 @@ export const api = {
|
||||
) => invoke<KbImportResult[]>("kb_import_documents", { kbId, files }),
|
||||
removeKbDocument: (documentId: string) =>
|
||||
invoke<void>("remove_kb_document", { documentId }),
|
||||
kbClearDocuments: (kbId: string) =>
|
||||
invoke<number>("kb_clear_documents", { kbId }),
|
||||
kbRechunkDocument: (documentId: string) =>
|
||||
invoke<number>("kb_rechunk_document", { documentId }),
|
||||
kbSearch: (kbId: string | null, query: string, page?: number, pageSize?: number) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import {
|
||||
api,
|
||||
KbDocument,
|
||||
@@ -16,6 +17,24 @@ import Icon from "../components/Icon";
|
||||
const DOC_PAGE_SIZE = 10;
|
||||
const SEARCH_PAGE_SIZE = 10;
|
||||
|
||||
const IMAGE_EXTS = [
|
||||
"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "tiff", "tif", "ico", "avif", "heic",
|
||||
];
|
||||
const VIDEO_EXTS = [
|
||||
"mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v", "mpg", "mpeg", "3gp",
|
||||
];
|
||||
const AUDIO_EXTS = [
|
||||
"mp3", "wav", "flac", "ogg", "oga", "m4a", "aac", "wma", "opus", "aiff", "aif",
|
||||
];
|
||||
|
||||
function mediaKind(ext: string): "image" | "video" | "audio" | null {
|
||||
const e = ext.toLowerCase();
|
||||
if (IMAGE_EXTS.includes(e)) return "image";
|
||||
if (VIDEO_EXTS.includes(e)) return "video";
|
||||
if (AUDIO_EXTS.includes(e)) return "audio";
|
||||
return null;
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number) {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
@@ -253,6 +272,25 @@ export default function KnowledgeBasePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearDocs() {
|
||||
if (!selected) return;
|
||||
if (
|
||||
!confirm(
|
||||
`确定清空知识库「${selected.name}」的全部 ${docTotal} 个文档吗?\n\n此操作不可恢复,所有文档、分块与检索索引将一并删除。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const n = await api.kbClearDocuments(selected.id);
|
||||
setMsg(`已清空 ${n} 个文档`);
|
||||
await loadDocPage(selected.id, 1);
|
||||
await refreshKbs();
|
||||
} catch (e) {
|
||||
setMsg(`清空失败:${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch(page = 1) {
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
@@ -553,8 +591,18 @@ export default function KnowledgeBasePage() {
|
||||
|
||||
{/* 文档列表 */}
|
||||
<section className="mb-6">
|
||||
<div className="mb-2 text-xs uppercase text-slate-500">
|
||||
文档(共 {docTotal} 个)
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-xs uppercase text-slate-500">
|
||||
文档(共 {docTotal} 个)
|
||||
</div>
|
||||
{docTotal > 0 ? (
|
||||
<button
|
||||
className="rounded-lg border border-border bg-panel-2 px-2.5 py-1 text-xs text-slate-400 hover:text-red-400"
|
||||
onClick={handleClearDocs}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{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">
|
||||
@@ -668,9 +716,28 @@ export default function KnowledgeBasePage() {
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<pre className="flex-1 overflow-auto whitespace-pre-wrap break-words bg-panel-2/40 p-4 text-xs leading-relaxed text-slate-300">
|
||||
{preview.content}
|
||||
</pre>
|
||||
{(() => {
|
||||
const kind = mediaKind(preview.file_type);
|
||||
if (kind && preview.file_path) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto bg-panel-2/40 p-4">
|
||||
<MediaView kind={kind} filePath={preview.file_path} name={preview.name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-panel-2/40 p-4 text-xs text-slate-500">
|
||||
该文件没有本地路径(非目录导入),无法直接预览
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<pre className="flex-1 overflow-auto whitespace-pre-wrap break-words bg-panel-2/40 p-4 text-xs leading-relaxed text-slate-300">
|
||||
{preview.content}
|
||||
</pre>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -713,6 +780,39 @@ function Pager({
|
||||
);
|
||||
}
|
||||
|
||||
function MediaView({
|
||||
kind,
|
||||
filePath,
|
||||
name,
|
||||
}: {
|
||||
kind: "image" | "video" | "audio";
|
||||
filePath: string;
|
||||
name: string;
|
||||
}) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const src = convertFileSrc(filePath);
|
||||
const commonProps = {
|
||||
src,
|
||||
onError: () => setError("无法加载文件,文件可能已被移动或删除"),
|
||||
};
|
||||
if (error) {
|
||||
return <div className="text-xs text-red-400">{error}</div>;
|
||||
}
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<img
|
||||
{...commonProps}
|
||||
alt={name}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (kind === "video") {
|
||||
return <video {...commonProps} controls className="max-h-full max-w-full" />;
|
||||
}
|
||||
return <audio {...commonProps} controls className="w-full" />;
|
||||
}
|
||||
|
||||
function SearchHitRow({ hit }: { hit: KbSearchHit }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user