33 * 「**⌘K 的数据源**(ADR-029 RAG 层)」;批 3 Task 12)。
44 *
55 * Why 单独立文件:把「IPC 结果 → 命令列表」做成**纯函数**,就能在没有 Tauri 运行时的 vitest 环境里测
6- * (本仓 vitest 全局 `environment: "node"`,`invoke` 不可用)。组件只负责调用与渲染,不负责数据形状
7- * —— 这样 T12 的测试面是 100%,而不是 0%。
6+ * (本仓 vitest 全局 `environment: "node"`,`invoke` 不可用)⇒ T12 的测试面是 100%,而不是 0%。
87 *
98 * 契约**逐字取自 Rust**(`app/src-tauri/src/commands_kb.rs:36-60`,不凭记忆写):
109 * `pub fn kb_search(state: State<'_, AppState>, query: String, limit: Option<usize>) -> Result<Vec<KbHit>, String>`
1110 * · 入参 `query: String` / `limit: Option<usize>`(JS 侧键名同名:两个参数都无下划线,不受 camelCase 重命名影响);
1211 * · 返回 `Vec<KbHit>`,`KbHit` 带 `#[serde(rename_all = "camelCase")]` ⇒ 前端 `KbHit`(`app/src/types/chat.ts:40`)逐字段同形;
1312 * · 错误是 `String`(`Result<_, String>`)⇒ IPC 拒绝值即可读中文串,不是结构化错误体;
1413 * · Rust 侧行为:空白 query ⇒ `Ok(vec![])`(不报错)· `query.chars().count() > 500` ⇒ `Err("查询过长(≤500 字符)")`
15- * · `limit` 缺省 `KB_SEARCH_DEFAULT_LIMIT = 10` 且 `.min(KB_SEARCH_MAX_LIMIT = 50)`。
14+ * · `limit` 缺省 10 且 `.min(50)`(命令层**只夹上界**;下界 1 在引擎层 `kb_search.rs:107` 的 `.clamp(1, 50)`) 。
1615 * ⚠️ 本任务**不增删命令**(`kb_search` 早已注册,见 `check-command-registry.mjs` 的 312/312)。
1716 *
1817 * 防御性(AGENTS.md §3.4:系统调用必须有超时/重试/降级):
2120 * `degraded` 标记上抛给面板显示灰字提示(**不是空 catch**:`console.warn` 带命令名与原因);
2221 * · 命中缺字段 / 形状不对 / 无跳转目标 ⇒ **跳过该条**(不让一条脏数据毁掉整个列表)。
2322 *
24- * 边界:本模块**不 import 任何 React**(纯逻辑,V3 判据;也因此可脱离 Tauri 运行时单测);
25- * 不做排序(顺序由后端给)· 不做中文分词(Rust 侧 `kb_fts.rs` 已把中文规划成 trigram)·
26- * 不碰任何 `focus*` 状态(只产出「跳转意图」,落到状态机是 `App.tsx` 的事)。
23+ * 边界:本模块**不 import 任何 React**(纯逻辑;也因此可脱离 Tauri 运行时单测)· 不做排序(顺序由后端给)
24+ * · 不做中文分词(Rust 侧 `kb_fts.rs` 已把中文规划成 trigram)· 不碰 `focus*` 状态(落状态机是 `App.tsx` 的事)。
2725 */
2826import { invoke } from "@tauri-apps/api/core" ;
2927import type { KbHit } from "../types" ;
@@ -32,7 +30,12 @@ import type { Command } from "./CommandPalette";
3230
3331/** Rust `KB_SEARCH_DEFAULT_LIMIT` 的前端镜像(两端同值:默认取 10 条命中) */
3432export const KB_SEARCH_DEFAULT_LIMIT = 10 ;
35- /** Rust `KB_SEARCH_MAX_LIMIT` 的前端镜像(命令层已 clamp,这里再夹一次防越界入参) */
33+ /**
34+ * Rust `KB_SEARCH_MAX_LIMIT` 的前端镜像(**上界**同值 50)。⚠️ 口径**不是**「两端同一处 clamp」(M-4):
35+ * Rust **命令层**只夹上界(`commands_kb.rs:57` = `limit.unwrap_or(10).min(50)`,`Some(0)` 原样透传)——
36+ * 下界 1 要到**引擎层** `kb_search.rs:107` 的 `.clamp(1, KB_SEARCH_MAX_LIMIT)` 才兜住 ⇒ 前端在命令层
37+ * 就自夹 `[1, 50]`(`kbSearchHits`);**别照这条注释去 Rust 命令层"对齐"下界**(那边本来就没有)。
38+ */
3639export const KB_SEARCH_MAX_LIMIT = 50 ;
3740
3841/**
@@ -85,11 +88,11 @@ export function commandsFromHits(hits: readonly KbHit[], onJump?: HitJumpHandler
8588 return out ;
8689}
8790
91+ /** 一次 `kb_search` 的**原始命中** + 降级标记(⌘K 的 hook 要数「命中里被跳过的条数」⇒ 命中层单独开口) */
92+ export interface KbHitsOutcome { hits : KbHit [ ] ; degraded : boolean }
93+
8894/** 一次 `kb_search` 的结果 + 是否走了降级路径(面板据此显示灰字提示;「没命中」**不算**降级) */
89- export interface KbSearchOutcome {
90- commands : Command [ ] ;
91- degraded : boolean ;
92- }
95+ export interface KbSearchOutcome { commands : Command [ ] ; degraded : boolean }
9396
9497/** IPC 拒绝值的可读化(错误是 Rust 的 `String`,但也可能被运行时包成 Error——两种都给出信息量) */
9598function describeError ( e : unknown ) : string {
@@ -99,28 +102,38 @@ function describeError(e: unknown): string {
99102}
100103
101104/**
102- * 检索 → 结果命令(带降级标记;面板用的是这个版本 —— 「空结果」与「检索失败」必须可区分)。
103- * 计划 `Interfaces` 的 `searchCommands` 见下方薄封装。
105+ * 检索 → **原始命中**(带降级标记)。⌘K 的 hook 走这条:除了「命中 → 命令」,它还要算
106+ * **被跳过的条数**(I-2:无跳转目标的命中必须在 UI 上如实说出来,不许静默丢弃后谎报「没有匹配」)。
107+ * 「空结果」与「检索失败」必须可区分 ⇒ 失败时 `degraded = true` 且**不抛**。
104108 */
105- export async function kbSearchCommands (
106- q : string ,
107- limit : number = KB_SEARCH_DEFAULT_LIMIT ,
108- onJump ?: HitJumpHandler ,
109- ) : Promise < KbSearchOutcome > {
109+ export async function kbSearchHits ( q : string , limit : number = KB_SEARCH_DEFAULT_LIMIT ) : Promise < KbHitsOutcome > {
110110 const query = q . trim ( ) ;
111111 // 空白串不发 IPC(省一次往返;Rust 侧同样返回空)——也顺带让「只输空格」不进降级态
112- if ( ! query ) return { commands : [ ] , degraded : false } ;
112+ if ( ! query ) return { hits : [ ] , degraded : false } ;
113113 const n = Number . isFinite ( limit ) ? Math . min ( Math . max ( Math . trunc ( limit ) , 1 ) , KB_SEARCH_MAX_LIMIT ) : KB_SEARCH_DEFAULT_LIMIT ;
114114 try {
115115 const hits = await invoke < KbHit [ ] > ( "kb_search" , { query, limit : n } ) ;
116116 // 后端契约是数组;真收到别的形状(版本漂移/代理层包装)按空结果处理,不猜结构
117- return { commands : commandsFromHits ( Array . isArray ( hits ) ? hits : [ ] , onJump ) , degraded : false } ;
117+ return { hits : Array . isArray ( hits ) ? hits : [ ] , degraded : false } ;
118118 } catch ( e ) {
119119 console . warn ( "[kbCommands] kb_search 失败——⌘K 降级为仅页面命令:" , describeError ( e ) ) ;
120- return { commands : [ ] , degraded : true } ;
120+ return { hits : [ ] , degraded : true } ;
121121 }
122122}
123123
124+ /**
125+ * 检索 → 结果命令(带降级标记;「空结果」与「检索失败」必须可区分)。
126+ * 计划 `Interfaces` 的 `searchCommands` 见下方薄封装。
127+ */
128+ export async function kbSearchCommands (
129+ q : string ,
130+ limit : number = KB_SEARCH_DEFAULT_LIMIT ,
131+ onJump ?: HitJumpHandler ,
132+ ) : Promise < KbSearchOutcome > {
133+ const { hits, degraded } = await kbSearchHits ( q , limit ) ;
134+ return { commands : commandsFromHits ( hits , onJump ) , degraded } ;
135+ }
136+
124137/** 计划 `Interfaces` 的逐字签名 `searchCommands(q, limit?)`:只要结果命令、不要降级标记时用它 */
125138export async function searchCommands ( q : string , limit ?: number , onJump ?: HitJumpHandler ) : Promise < Command [ ] > {
126139 return ( await kbSearchCommands ( q , limit ?? KB_SEARCH_DEFAULT_LIMIT , onJump ) ) . commands ;
0 commit comments