diff --git a/docs/specification/04_mcp_api_specification.md b/docs/specification/04_mcp_api_specification.md index 9457bff..ec869b4 100644 --- a/docs/specification/04_mcp_api_specification.md +++ b/docs/specification/04_mcp_api_specification.md @@ -29,6 +29,7 @@ | `update_item` | 知識の更新 | ID 指定による既存データの書き換え。 | | `delete_item` | 知識の抹消 | ID 指定による物理削除。 | | `get_item_by_id` | 生データ取得 | メタデータを含むレコードの直接参照。 | +| `get_document_count` | 件数取得 | DB に格納したドキュメント(documents)の総件数を返す。引数なし。 | ### 3.1 search_text パラメータ diff --git "a/journals/202602-011-MCP\344\273\266\346\225\260\343\203\273\350\207\252\345\213\225\345\206\215\345\255\246\347\277\222\343\203\273GUI\346\224\271\345\226\204.md" "b/journals/202602-011-MCP\344\273\266\346\225\260\343\203\273\350\207\252\345\213\225\345\206\215\345\255\246\347\277\222\343\203\273GUI\346\224\271\345\226\204.md" new file mode 100644 index 0000000..1c91ecb --- /dev/null +++ "b/journals/202602-011-MCP\344\273\266\346\225\260\343\203\273\350\207\252\345\213\225\345\206\215\345\255\246\347\277\222\343\203\273GUI\346\224\271\345\226\204.md" @@ -0,0 +1,133 @@ +# 2026年第011週 作業アーカイブ: MCP件数・自動再学習・インデックス表示・UI整理 + +## 週全体のサマリー + +Issue #5 対応として MCP でドキュメント件数を返す `get_document_count` を追加し、HTTP `/doc_count` と UI ヘッダーも documents 件数に統一。MCP ACTIVITY が空になる不具合をブロードキャスト追加で解消し、トレイクリックの二重発火にはデバウンスを導入。LSA 再学習を「変更件数が閾値(登録数の 20% と 5 の少ない方)を超えたら 90 秒デバウンスで自動実行」するようにし、インデックス化の状態をヘッダーに表示。文書一覧にチャンク0の先頭15文字を表示し、左サイドバー下部のアコーディオンと著作権表示を削除した。 + +--- + +## 2026-02-25: MCP でドキュメント件数取得(Issue #5) + +### 1. 作業実施の理由と指示 + +- **背景**: 格納した件数を MCP から取得したい要望があり、当初は items(チャンク)数だったが、ドキュメント数に統一するよう指摘された。 +- **意図**: MCP ツールと HTTP・UI の「件数」をすべて documents テーブルの件数で揃える。 + +### 2. 作業詳細 + +- **MCP**: `get_item_count` を `get_document_count` に変更。`SELECT COUNT(*) FROM documents` で件数を返す。`tools/list`・dispatch・`handle_get_document_count` を追加・改名。 +- **HTTP**: `handlers.rs` の `doc_count_handler` を `COUNT(*) FROM items` から `COUNT(*) FROM documents` に変更。 +- **仕様**: `docs/specification/04_mcp_api_specification.md` に `get_document_count` の説明を追記。 + +### 3. AI視点での結果 + +MCP と UI の「X docs」がどちらもドキュメント数で一致し、意味が明確になった。 + +--- + +## 2026-02-25: MCP ACTIVITY が空になる不具合 + +### 1. 作業実施の理由と指示 + +- **背景**: 検索パネル下部の MCP ACTIVITY が常に空だった。 +- **意図**: ツール呼び出し時に `mcp:call:` を SSE でブロードキャストし、UI に表示する。 + +### 2. 作業詳細 + +- `src/backend/src/mcp/mod.rs`: `mcp_messages_handler` 内で、`tools/call` および各ツール名に該当するリクエストを受けた直後に `state.tx.send(format!("mcp:call:{}", actual_method))` を実行。 +- フロントの `index.html` は既に `update` イベントで `mcp:call:` をパースして MCP ACTIVITY に追記する実装だったため、バックエンドの送信追加のみで解消。 + +### 3. AI視点での結果 + +Cursor 等から MCP ツールを呼ぶと、リアルタイムで MCP ACTIVITY にメソッド名が表示されるようになった。 + +--- + +## 2026-02-25: トレイアイコンクリックで一瞬開いてすぐ閉じる + +### 1. 作業実施の理由と指示 + +- **背景**: タスクトレイのアイコンをクリックするとウィンドウが一瞬表示された直後に閉じてしまう。 +- **意図**: クリックが二重に扱われているため、デバウンスで 2 回目を無視する。 + +### 2. 作業詳細 + +- `src/backend/src/lib.rs`: `on_tray_icon_event` 内で、左クリック時に `LAST_TRAY_CLICK_MS`(AtomicU64)で前回クリック時刻を記録。前回から 400ms 以内のクリックは無視し、それ以外は表示/非表示をトグル。 + +### 3. AI視点での結果 + +トレイクリックでウィンドウの表示・非表示が安定して切り替わるようになった。 + +--- + +## 2026-02-25: LSA 再学習の自動実行(閾値・割合) + +### 1. 作業実施の理由と指示 + +- **背景**: 追加・更新・削除のたびに手動で RE-INDEX するのは手間。毎回再学習するのはコストが高いため、変更が「たまった」タイミングで自動実行したい。 +- **意図**: 変更件数が「登録ドキュメント数の 20%」と「5 件」の少ない方を超えたら、90 秒デバウンス後にバックグラウンドで `train_lsa_and_sync_hnsw` を実行する。 + +### 2. 作業詳細 + +- **状態**: `AppState` に `changes_since_train`(AtomicU64)と `retrain_scheduled`(AtomicBool)を追加。 +- **閾値**: `schedule_retrain_if_needed` を async 化し、`SELECT COUNT(*) FROM documents` で現在件数を取得。`threshold = max(1, min(ceil(doc_count * 0.2), 5))`。変更を 1 加算し、`count >= threshold` かつ未スケジュールなら `retrain_scheduled` を立て、90 秒後に `train_lsa_and_sync_hnsw` を spawn。完了後にカウントとフラグをリセット。 +- **呼び出し**: `add_item_text`・`update_item`・`delete_item`・`delete_document` の成功後に `schedule_retrain_if_needed(state).await` を実行。 + +### 3. AI視点での結果 + +少ない登録数では 1〜2 件の変更で、多い登録数では最大 5 件の変更で再学習が走り、RE-INDEX を押さなくても語彙・LSA が適度に更新されるようになった。 + +--- + +## 2026-02-25: インデックス化の様子を GUI に表示 + +### 1. 作業実施の理由と指示 + +- **背景**: 再学習やインデックス構築がいつ走っているか分からず、操作のフィードバックが欲しい。 +- **意図**: バックエンドの状態(idle / training / syncing)を API と SSE で通知し、ヘッダーに表示する。 + +### 2. 作業詳細 + +- **状態**: `AppState` に `indexing_status`(RwLock)を追加。初期値 `"idle"`。 +- **API**: `GET /indexing_status` で `{ "status": "idle"|"training"|"syncing" }` を返す。 +- **system.rs**: `train_lsa_and_sync_hnsw` の開始時に `"training"` と `indexing:training` をブロードキャスト。`sync_all_vectors` の直前に `"syncing"` と `indexing:syncing`。完了・失敗・件数 0 の早期 return 時に `"idle"` と `indexing:idle`。 +- **フロント**: ヘッダーに「LSA学習中…」「ベクトル同期中…」用のバッジを追加。3 秒ごとのポーリングと SSE の `indexing:*` で表示を更新。idle 時はバッジを非表示。 + +### 3. AI視点での結果 + +起動時や RE-INDEX・自動再学習時に、ヘッダーでインデックス構築の進行が分かるようになった。 + +--- + +## 2026-02-25: 文書一覧にチャンク0の先頭15文字を表示 + +### 1. 作業実施の理由と指示 + +- **背景**: 文書管理のテーブルで、パス・MIME・チャンク数だけでは内容が想像しづらい。 +- **意図**: 各ドキュメントのチャンク0の先頭 15 文字を一覧に表示する。 + +### 2. 作業詳細 + +- **バックエンド**: `list_documents` の SQL に `(SELECT substr(i.content, 1, 15) FROM items i WHERE i.document_id = d.id ORDER BY i.chunk_index ASC LIMIT 1) AS chunk0_preview` を追加。返却 JSON に `chunk0_preview` を含める。 +- **フロント**: 文書管理テーブルに「先頭(chunk0)」列を追加。`chunk0_preview` を表示し、チャンクが 2 以上なら「…」を付与。`.docs-cell-preview` で最大幅・省略・色を指定。 + +### 3. AI視点での結果 + +一覧から各文書の冒頭が把握しやすくなった。 + +--- + +## 2026-02-25: 左サイドバー下部の整理 + +### 1. 作業実施の理由と指示 + +- **背景**: サイドバー下部の TelosDB アコーディオンは不要。著作権表示はサイトフッターにあるため重複する。 +- **意図**: アコーディオンと著作権表示を削除し、ナビのみのシンプルなサイドバーにする。 + +### 2. 作業詳細 + +- `src/frontend/components/app-sidebar.js`: `sidebar-bottom` 内のアコーディオン(TelosDB ▸ / v0.3.5-HUD・© 2026 DtmOjaji)を削除。アコーディオン用のクリックハンドラも削除。その後、著作権のみのフッターも削除し、`sidebar-bottom` ブロックごとなくした。 + +### 3. AI視点での結果 + +サイドバーは「検索・文書管理・設定」のナビのみとなり、見た目と役割が整理された。 diff --git a/src/backend/src/lib.rs b/src/backend/src/lib.rs index f1eb3ab..038c1da 100644 --- a/src/backend/src/lib.rs +++ b/src/backend/src/lib.rs @@ -36,8 +36,10 @@ pub mod utils; pub mod mcp; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; use tauri::Manager; use tauri::path::BaseDirectory; use tauri::menu::{Menu, MenuItem}; @@ -203,6 +205,18 @@ .. } = event { + // トレイクリックが二重発火することがあるためデバウンス(一瞬開いてすぐ閉じるのを防ぐ) + const DEBOUNCE_MS: u64 = 400; + static LAST_TRAY_CLICK_MS: AtomicU64 = AtomicU64::new(0); + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let prev = LAST_TRAY_CLICK_MS.swap(now_ms, Ordering::Relaxed); + if prev != 0 && now_ms.saturating_sub(prev) < DEBOUNCE_MS { + return; + } + let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { if window.is_visible().unwrap_or(false) { diff --git a/src/backend/src/mcp/handlers.rs b/src/backend/src/mcp/handlers.rs index 2268974..b079b53 100644 --- a/src/backend/src/mcp/handlers.rs +++ b/src/backend/src/mcp/handlers.rs @@ -17,7 +17,7 @@ } pub async fn doc_count_handler(State(state): State) -> impl IntoResponse { - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM items") + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents") .fetch_one(&state.db_pool) .await .unwrap_or(0); @@ -28,6 +28,11 @@ Json(serde_json::json!({ "model_name": state.model_name })) } +pub async fn indexing_status_handler(State(state): State) -> impl IntoResponse { + let status = state.indexing_status.read().await.clone(); + Json(serde_json::json!({ "status": status })) +} + #[allow(dead_code)] #[derive(Deserialize)] pub struct SseQuery { diff --git a/src/backend/src/mcp/mod.rs b/src/backend/src/mcp/mod.rs index 7010f6b..68b73e6 100644 --- a/src/backend/src/mcp/mod.rs +++ b/src/backend/src/mcp/mod.rs @@ -8,6 +8,7 @@ routing::{get, post}, Router, }; +use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::Arc; use tokio::sync::{broadcast, RwLock}; use tower_http::cors::{Any, CorsLayer}; @@ -25,6 +26,7 @@ .route("/llama_status", get(handlers::llama_status_handler)) .route("/doc_count", get(handlers::doc_count_handler)) .route("/model_name", get(handlers::model_name_handler)) + .route("/indexing_status", get(handlers::indexing_status_handler)) .layer(cors) .with_state(state) } @@ -47,6 +49,9 @@ tokenizer, lsa_model: Arc::new(RwLock::new(None)), hnsw_index: Arc::new(RwLock::new(None)), + changes_since_train: Arc::new(AtomicU64::new(0)), + retrain_scheduled: Arc::new(AtomicBool::new(false)), + indexing_status: Arc::new(RwLock::new("idle".to_string())), }; // 初期化時に LSA トレーニングとベクトル同期(HNSW構築含む)をバックグラウンドで開始 @@ -112,6 +117,11 @@ log::info!("MCP Request: {} (Actual: {}, Session: {:?})", method, actual_method, query.session_id); + // tools/call をブロードキャストして UI の MCP ACTIVITY に通知 + if method == "tools/call" || matches!(actual_method, "get_item_by_id" | "add_item_text" | "search_text" | "lsa_search" | "update_item" | "delete_item" | "list_documents" | "get_document_count" | "get_document" | "delete_document" | "lsa_retrain") { + let _ = state.tx.send(format!("mcp:call:{}", actual_method)); + } + let result = match method { "initialize" => { let client_version = req.params.as_ref() @@ -207,6 +217,11 @@ "inputSchema": { "type": "object", "properties": {} } }, { + "name": "get_document_count", + "description": "Get the total count of documents stored in the database", + "inputSchema": { "type": "object", "properties": {} } + }, + { "name": "get_document", "description": "Get full document content by document ID", "inputSchema": { @@ -235,7 +250,7 @@ } ] })), - "tools/call" | "get_item_by_id" | "add_item_text" | "search_text" | "lsa_search" | "update_item" | "delete_item" | "list_documents" | "get_document" | "delete_document" | "lsa_retrain" => { + "tools/call" | "get_item_by_id" | "add_item_text" | "search_text" | "lsa_search" | "update_item" | "delete_item" | "list_documents" | "get_document_count" | "get_document" | "delete_document" | "lsa_retrain" => { let empty_map = serde_json::Map::new(); let mut args = req.params.as_ref().and_then(|p| p.as_object()).unwrap_or(&empty_map); diff --git a/src/backend/src/mcp/system.rs b/src/backend/src/mcp/system.rs index a1c2dfd..a0a5937 100644 --- a/src/backend/src/mcp/system.rs +++ b/src/backend/src/mcp/system.rs @@ -1,5 +1,6 @@ use sqlx::Row; use std::collections::HashMap; +use std::sync::atomic::Ordering; use std::sync::Arc; use hnsw_rs::prelude::*; use crate::mcp::types::AppState; @@ -7,12 +8,19 @@ pub async fn train_lsa_and_sync_hnsw(state: AppState) { log::info!("Starting LSA model training (Async Pre-fetch)..."); - + { + let mut st = state.indexing_status.write().await; + *st = "training".to_string(); + } + let _ = state.tx.send("indexing:training".to_string()); + // 1. DBからデータを集める(I/O) let rows = match sqlx::query("SELECT content FROM items").fetch_all(&state.db_pool).await { Ok(rows) if !rows.is_empty() => rows, _ => { log::info!("No documents found for LSA training."); + *state.indexing_status.write().await = "idle".to_string(); + let _ = state.tx.send("indexing:idle".to_string()); return; } }; @@ -44,10 +52,20 @@ log::info!("Building HNSW index..."); let hnsw: Hnsw<'static, f32, DistCosine> = Hnsw::new(16, doc_count.max(100), 16, 200, DistCosine {}); - // 次の重い処理へ + { + let mut st = state.indexing_status.write().await; + *st = "syncing".to_string(); + } + let _ = state.tx.send("indexing:syncing".to_string()); sync_all_vectors(state.clone(), Some(hnsw)).await; + *state.indexing_status.write().await = "idle".to_string(); + let _ = state.tx.send("indexing:idle".to_string()); } - Err(e) => log::error!("LSA training failed: {}", e), + Err(e) => { + log::error!("LSA training failed: {}", e); + *state.indexing_status.write().await = "idle".to_string(); + let _ = state.tx.send("indexing:idle".to_string()); + } } } @@ -206,3 +224,44 @@ }).unwrap(); } } + +/// 追加・更新・削除が「登録件数に対する割合」で閾値を超えたら、デバウンス後にバックグラウンドで LSA 再学習を実行する。 +/// 二重実行防止のため、1本だけスケジュールされる。 +pub async fn schedule_retrain_if_needed(state: &AppState) { + const CHANGE_RATIO: f64 = 0.2; // 登録ドキュメント数の 20% + const MAX_THRESHOLD: u64 = 5; // 多くても5件で再学習を検討(数が多いときに学習しなくならないように) + const MIN_THRESHOLD: u64 = 1; + const DEBOUNCE_SECS: u64 = 90; + + let doc_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents") + .fetch_one(&state.db_pool) + .await + .unwrap_or(0); + let by_ratio = (doc_count as f64 * CHANGE_RATIO).ceil() as u64; + let threshold = by_ratio.min(MAX_THRESHOLD).max(MIN_THRESHOLD); + + let prev = state.changes_since_train.fetch_add(1, Ordering::Relaxed); + let count = prev + 1; + if count < threshold { + return; + } + if state + .retrain_scheduled + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_err() + { + return; + } + let state_clone = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(DEBOUNCE_SECS)).await; + log::info!( + "Auto LSA retrain starting (debounced {}s after {} changes, threshold was {}).", + DEBOUNCE_SECS, count, threshold + ); + train_lsa_and_sync_hnsw(state_clone.clone()).await; + state_clone.changes_since_train.store(0, Ordering::Relaxed); + state_clone.retrain_scheduled.store(false, Ordering::Relaxed); + log::info!("Auto LSA retrain completed."); + }); +} diff --git a/src/backend/src/mcp/tools/items.rs b/src/backend/src/mcp/tools/items.rs index 2270019..ddf0a1f 100644 --- a/src/backend/src/mcp/tools/items.rs +++ b/src/backend/src/mcp/tools/items.rs @@ -139,6 +139,7 @@ if !results.is_empty() { let _ = state.tx.send("data_changed".to_string()); + crate::mcp::system::schedule_retrain_if_needed(state).await; log::info!("Successfully added {} chunks to document {}.", results.len(), path_str); Some( serde_json::json!({ "content": [{ "type": "text", "text": format!("Successfully added {} chunks for {}", results.len(), path_str) }] }), @@ -253,6 +254,7 @@ })) } else { let _ = state.tx.send("data_changed".to_string()); + crate::mcp::system::schedule_retrain_if_needed(state).await; Some( serde_json::json!({ "content": [{ "type": "text", "text": format!("Successfully updated item {} (LSA)", id) }] }), ) @@ -332,6 +334,7 @@ })) } else { let _ = state.tx.send("data_changed".to_string()); + crate::mcp::system::schedule_retrain_if_needed(state).await; Some( serde_json::json!({ "content": [{ "type": "text", "text": format!("Successfully deleted item {}", id) }] }), ) @@ -348,6 +351,30 @@ } // ---------------------------------------------------------------------------- +// Document count (Issue #5) — documents テーブルの件数を返す +// ---------------------------------------------------------------------------- + +pub async fn handle_get_document_count(state: &AppState) -> Option { + let count: i64 = match sqlx::query_scalar("SELECT COUNT(*) FROM documents") + .fetch_one(&state.db_pool) + .await + { + Ok(c) => c, + Err(e) => { + return Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Failed to get document count: {}", e) }], + "isError": true + })); + } + }; + let text = serde_json::to_string_pretty(&serde_json::json!({ "count": count })) + .unwrap_or_else(|_| format!("{{\"count\": {}}}", count)); + Some(serde_json::json!({ + "content": [{ "type": "text", "text": text }] + })) +} + +// ---------------------------------------------------------------------------- // Document-level API (list, get full content, delete by document) // ---------------------------------------------------------------------------- @@ -356,7 +383,8 @@ ) -> Option { let rows = match sqlx::query( "SELECT d.id, d.path, d.mime, d.updated_at, - (SELECT COUNT(*) FROM items i WHERE i.document_id = d.id) AS chunk_count + (SELECT COUNT(*) FROM items i WHERE i.document_id = d.id) AS chunk_count, + (SELECT substr(i.content, 1, 15) FROM items i WHERE i.document_id = d.id ORDER BY i.chunk_index ASC LIMIT 1) AS chunk0_preview FROM documents d ORDER BY d.path" ) .fetch_all(&state.db_pool) @@ -377,12 +405,14 @@ let mime: Option = row.get(2); let updated_at: Option = row.get(3); let chunk_count: i64 = row.get(4); + let chunk0_preview: Option = row.get(5); serde_json::json!({ "id": id, "path": path, "mime": mime, "updated_at": updated_at, - "chunk_count": chunk_count + "chunk_count": chunk_count, + "chunk0_preview": chunk0_preview }) }).collect(); @@ -516,6 +546,7 @@ } let _ = state.tx.send("data_changed".to_string()); + crate::mcp::system::schedule_retrain_if_needed(state).await; Some(serde_json::json!({ "content": [{ "type": "text", "text": format!("Successfully deleted document {} ({} chunks)", doc_id, item_ids.len()) }] })) diff --git a/src/backend/src/mcp/tools/mod.rs b/src/backend/src/mcp/tools/mod.rs index e4db9d0..61408d3 100644 --- a/src/backend/src/mcp/tools/mod.rs +++ b/src/backend/src/mcp/tools/mod.rs @@ -17,6 +17,7 @@ "update_item" => items::handle_update_item(state, args).await, "delete_item" => items::handle_delete_item(state, args).await, "list_documents" => items::handle_list_documents(state).await, + "get_document_count" => items::handle_get_document_count(state).await, "get_document" => items::handle_get_document(state, args).await, "delete_document" => items::handle_delete_document(state, args).await, "lsa_retrain" => system::handle_lsa_retrain(state).await, diff --git a/src/backend/src/mcp/types.rs b/src/backend/src/mcp/types.rs index e8bc2af..fcc1f36 100644 --- a/src/backend/src/mcp/types.rs +++ b/src/backend/src/mcp/types.rs @@ -3,6 +3,7 @@ use crate::utils::tokenizer::JapaneseTokenizer; use hnsw_rs::prelude::*; use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::Arc; use tokio::sync::{broadcast, mpsc, RwLock}; @@ -18,6 +19,12 @@ pub tokenizer: Arc, pub lsa_model: Arc>>, pub hnsw_index: Arc>>>, + /// 前回の LSA 再学習以降の変更件数(追加・更新・削除)。この値が閾値を超えたら自動で再学習をスケジュールする。 + pub changes_since_train: Arc, + /// 再学習がスケジュール済みか(二重実行防止) + pub retrain_scheduled: Arc, + /// インデックス化の状態(idle / training / syncing)。GUI 表示用。 + pub indexing_status: Arc>, } #[derive(Serialize, Deserialize)] diff --git a/src/frontend/components/app-sidebar.js b/src/frontend/components/app-sidebar.js index 94375b4..8cc452d 100644 --- a/src/frontend/components/app-sidebar.js +++ b/src/frontend/components/app-sidebar.js @@ -11,37 +11,9 @@ - `; - // accordion behavior - const header = this.querySelector('.accordion-header'); - const content = this.querySelector('.accordion-content'); - if (header && content) { - header.addEventListener('click', () => { - const expanded = header.getAttribute('aria-expanded') === 'true'; - header.setAttribute('aria-expanded', String(!expanded)); - if (expanded) { - content.hidden = true; - header.querySelector('.accordion-toggle').textContent = '▸'; - } else { - content.hidden = false; - header.querySelector('.accordion-toggle').textContent = '▾'; - } - }); - } - // Add a resizer handle to allow dragging to change sidebar width const resizer = document.createElement('div'); resizer.className = 'sidebar-resizer'; diff --git a/src/frontend/components/main-panel.js b/src/frontend/components/main-panel.js index 4478d69..6c0788f 100644 --- a/src/frontend/components/main-panel.js +++ b/src/frontend/components/main-panel.js @@ -235,7 +235,7 @@ docsListEl.innerHTML = ` - + ${list.map(d => ` @@ -243,6 +243,7 @@ +
パスMIMEチャンク数操作
パスMIMEチャンク数先頭(chunk0)操作
${escapeHtml(d.path || '')} ${escapeHtml(d.mime || '')} ${Number(d.chunk_count) || 0}${escapeHtml(d.chunk0_preview || '')}${Number(d.chunk_count) > 1 ? '…' : ''} diff --git a/src/frontend/components/site-header.js b/src/frontend/components/site-header.js index ae3cebd..a1b0b9c 100644 --- a/src/frontend/components/site-header.js +++ b/src/frontend/components/site-header.js @@ -20,6 +20,10 @@ -- docs + +
diff --git a/src/frontend/index.html b/src/frontend/index.html index 574f84c..c874f9e 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -96,6 +96,26 @@ } } + function setIndexingStatus(status) { + const badge = document.getElementById("indexing-status-badge"); + const value = document.getElementById("indexing-status-value"); + if (!badge || !value) return; + const labels = { training: "LSA学習中…", syncing: "ベクトル同期中…", idle: "" }; + const text = labels[status] || status; + value.textContent = text; + badge.style.display = text ? "inline-flex" : "none"; + } + + async function updateIndexingStatus() { + try { + const res = await fetch(`${API_BASE}/indexing_status`); + const data = await res.json(); + setIndexingStatus(data.status || "idle"); + } catch { + setIndexingStatus("idle"); + } + } + async function reindex() { if (!confirm("LSAインデックスを再構築します。現在の全データが再学習され、数秒〜数十秒かかる場合があります。実行しますか?")) return; @@ -248,9 +268,11 @@ // Init setInterval(updateLlamaStatus, 3000); + setInterval(updateIndexingStatus, 3000); updateLlamaStatus(); updateDocCount(); updateModelName(); + updateIndexingStatus(); // Setup SSE for real-time updates const eventSource = new EventSource(`${API_BASE}/sse`); @@ -293,6 +315,9 @@ } } else if (msg === "data_changed") { updateDocCount(); + } else if (msg.startsWith("indexing:")) { + const status = msg.replace("indexing:", ""); + setIndexingStatus(status); } }); diff --git a/src/frontend/styles.css b/src/frontend/styles.css index d4d79ff..10229a5 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -315,6 +315,13 @@ overflow: hidden; text-overflow: ellipsis; } +.docs-cell-preview { + max-width: 14em; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-dim); + font-size: 0.85em; +} .docs-actions { display: flex; gap: 6px;