@@ -71,10 +71,10 @@ const EMBEDDING_MODEL_REL: &str = "embedding/bge-small-zh-v1.5";
7171/// 下载 kb embedding 模型(bge-small-zh-v1.5 int8 ONNX + BERT 词表)。
7272///
7373/// @ai-context: 模型文件不入库、不打包(约 25MB 按需下载——REQ-262 口径);
74- /// 源:ONNX 量化模型走 Xenova(社区导出),vocab.txt 走 BAAI
75- /// 官方仓库;下载写 .part 后原子改名(中断残留可重下覆盖)。
76- /// 下载完成 ≠ 就绪——需 kb_embedding_load 换槽 + 「学习库」段
77- /// 重建索引回填向量(命令返回值给前端引导文案) 。
74+ /// 审查修复(2026-09-04):双文件均已就位 → 幂等跳过(重启不重复
75+ /// 下 25MB);单文件下载失败清理 .part;落位前按 Content-Length
76+ /// 校验长度(防截断半文件被加载器吞并报费解错误)。
77+ /// 下载完成 ≠ 就绪——需 kb_embedding_load 换槽 + 重建索引回填 。
7878#[ tauri:: command]
7979pub async fn kb_embedding_download ( state : State < ' _ , AppState > ) -> Result < String , String > {
8080 let dir = state. model_dir . join ( EMBEDDING_MODEL_REL ) ;
@@ -90,23 +90,47 @@ pub async fn kb_embedding_download(state: State<'_, AppState>) -> Result<String,
9090 ] ;
9191 tauri:: async_runtime:: spawn_blocking ( move || -> Result < String , String > {
9292 std:: fs:: create_dir_all ( & dir) . map_err ( |e| format ! ( "创建模型目录失败: {e}" ) ) ?;
93- let mut downloaded = Vec :: new ( ) ;
94- for ( name, url) in files {
93+ let missing: Vec < & str > = files
94+ . iter ( )
95+ . filter ( |( name, _) | !dir. join ( name) . is_file ( ) )
96+ . map ( |( name, _) | * name)
97+ . collect ( ) ;
98+ if missing. is_empty ( ) {
99+ return Ok ( format ! (
100+ "模型文件已就绪(幂等跳过下载):{}——点击「▶ 加载引擎」后全量重建即可" ,
101+ dir. to_string_lossy( )
102+ ) ) ;
103+ }
104+ let mut downloaded: Vec < String > = Vec :: new ( ) ;
105+ for ( name, url) in files. iter ( ) . filter ( |( name, _) | missing. contains ( name) ) {
95106 let part = dir. join ( format ! ( "{name}.part" ) ) ;
96107 let target = dir. join ( name) ;
97- let resp = ureq:: get ( url)
98- . timeout ( std:: time:: Duration :: from_secs ( 600 ) )
99- . call ( )
100- . map_err ( |e| format ! ( "下载 {name} 失败: {e}" ) ) ?;
101- let mut reader = resp. into_reader ( ) ;
102- let mut out = std:: fs:: File :: create ( & part) . map_err ( |e| format ! ( "写临时文件失败: {e}" ) ) ?;
103- std:: io:: copy ( & mut reader, & mut out) . map_err ( |e| format ! ( "写 {name} 失败: {e}" ) ) ?;
104- drop ( out) ;
105- std:: fs:: rename ( & part, & target) . map_err ( |e| format ! ( "落位 {name} 失败: {e}" ) ) ?;
106- downloaded. push ( name. to_string ( ) ) ;
108+ let result = ( || -> Result < ( ) , String > {
109+ let resp = ureq:: get ( url)
110+ . timeout ( std:: time:: Duration :: from_secs ( 600 ) )
111+ . call ( )
112+ . map_err ( |e| format ! ( "下载 {name} 失败: {e}" ) ) ?;
113+ let expected = resp
114+ . header ( "content-length" )
115+ . and_then ( |v| v. parse :: < u64 > ( ) . ok ( ) ) ;
116+ let mut reader = resp. into_reader ( ) ;
117+ let mut out = std:: fs:: File :: create ( & part) . map_err ( |e| format ! ( "写临时文件失败: {e}" ) ) ?;
118+ let copied = std:: io:: copy ( & mut reader, & mut out) . map_err ( |e| format ! ( "写 {name} 失败: {e}" ) ) ?;
119+ drop ( out) ;
120+ if expected. is_some_and ( |len| copied != len) {
121+ return Err ( format ! ( "{name} 下载不完整({copied}/{expected:?} 字节)——已清理,请重试" ) ) ;
122+ }
123+ std:: fs:: rename ( & part, & target) . map_err ( |e| format ! ( "落位 {name} 失败: {e}" ) ) ?;
124+ Ok ( ( ) )
125+ } ) ( ) ;
126+ if let Err ( e) = result {
127+ let _ = std:: fs:: remove_file ( & part) ; // 失败清理半文件(重试不留残)
128+ return Err ( e) ;
129+ }
130+ downloaded. push ( ( * name) . to_string ( ) ) ;
107131 }
108132 Ok ( format ! (
109- "已下载:{}({})——请在「学习库」段点击加载引擎并重建索引以回填向量 " ,
133+ "已下载:{}({})——请点击「▶ 加载引擎」并「🔄 全量重建」以回填向量 " ,
110134 downloaded. join( "、" ) ,
111135 dir. to_string_lossy( )
112136 ) )
@@ -150,14 +174,19 @@ pub fn kb_embedding_status(state: State<'_, AppState>) -> Result<EmbeddingStatus
150174
151175/// 加载(或重载)本地 ONNX embedding 引擎。
152176///
153- /// @ai-context: 模型文件由下载命令/分发先落位(models/embedding/bge-small-zh-
154- /// v1.5/{model_quantized. onnx,vocab.txt});本命令只做加载与换槽:
155- /// 成功 → 槽位切 onnx(后续 reindex 按新引擎重嵌);失败 → 槽位
156- /// 保持原样并如实报错(不静默降级——状态命令仍显示旧态) 。
177+ /// @ai-context: 模型文件由下载命令/分发先落位;本命令只做加载与换槽:成功 →
178+ /// 槽位切 onnx(后续 reindex 按新引擎重嵌);失败 → 槽位保持原样
179+ /// 并如实报错。审查修复(2026-09-04):模型解析(~25MB onnx)搬
180+ /// 到 spawn_blocking——不在主线程/async 运行时上做秒级 CPU 工作 。
157181#[ tauri:: command]
158- pub fn kb_embedding_load ( state : State < ' _ , AppState > ) -> Result < EmbeddingStatusView , String > {
182+ pub async fn kb_embedding_load ( state : State < ' _ , AppState > ) -> Result < EmbeddingStatusView , String > {
159183 let dir = state. model_dir . join ( EMBEDDING_MODEL_REL ) ;
160- let engine = crate :: kb_embed_onnx:: OnnxEmbedding :: try_load ( & dir) ?;
184+ let dir_for_load = dir. clone ( ) ;
185+ let engine = tauri:: async_runtime:: spawn_blocking ( move || {
186+ crate :: kb_embed_onnx:: OnnxEmbedding :: try_load ( & dir_for_load)
187+ } )
188+ . await
189+ . map_err ( |e| format ! ( "引擎加载任务调度失败: {e}" ) ) ??;
161190 let mut slot = state
162191 . embedding_slot
163192 . lock ( )
0 commit comments