diff --git a/.agent/rules/report_then_act.md b/.agent/rules/report_then_act.md new file mode 100644 index 0000000..ddadc10 --- /dev/null +++ b/.agent/rules/report_then_act.md @@ -0,0 +1,40 @@ +# 報告で止まらない・検証してから言う (Report Then Act / Verify Before Done) + +## 原則 + +- **不足・問題を指摘・認識したら、報告だけして終わらせない。** +- 必ず**具体的な対応**まで行う: + - 手順の明文化(README・コメント) + - コード修正・設定変更 + - ルール・チェックリストの追加 + - スクリプト・ツールの改善(目的を満たす範囲で) + +## 「対応しました」「反映しました」の前に + +- **検証を実行してから**完了と宣言する。「言っただけ」で終わらせない。 +- **UI を変えた場合** + - `npm run debug-ui:serve`(または同等)を実行し、スクリーンショットを取得する。 + - 取得した画像(例: `tmp/debug-ui-screenshot-settings.png`)を**開いて確認**し、意図どおりか判断してから「対応しました」と返す。 + - 検証を飛ばして「対応しました」と言うと「ほらね」を招く。 +- **設定・ビルド・非UI を変えた場合** + - 該当するビルド・テストを実行し、成功または期待どおりであることを確認してから完了と宣言する。 + - 実行できない環境の場合は「変更内容」と「ユーザーが確認する方法」を明示する。 + +## 手段の目的化をしない + +- 目的は「意図どおり動いているか確認する」「ユーザーが困らないようにする」こと。 +- ツールの細かい調整(タイムアウト値など)は、その目的に沿うときだけ行う。手段をいじることが目的にならない。 +- 大局観を持つ:何のための変更か、誰が何を確認できるか。 + +## UI 変更時のルール(要約) + +1. 目的を満たす実装をする。 +2. **必ず** `npm run debug-ui:serve` を実行し、スクリーンショットを取得する。 +3. 取得した画像を**開いて目視確認**する。 +4. 問題なければ「対応しました」と報告する。問題があれば修正して 2–3 を繰り返す。 + +## 反省会で明文化したこと(要約) + +- 「報告だけして止まる」→ 具体的な対応まで行う(既存ルール)。 +- 「対応しました」と先に言ってから検証しない → **検証してから**「対応しました」と言う。 +- ウィンドウサイズ・設定変更なども「反映しました」で終わらせない → ビルドや起動で確認するか、確認方法を明示する。 diff --git a/.gitignore b/.gitignore index 391658e..015a649 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,10 @@ tools/ !tools/serve-frontend.mjs !tools/ensure-embedding-model.mjs +!tools/debug-ui-puppeteer.mjs +!tools/debug-ui-with-serve.mjs # Local helper/status files -.git_status_output.txt \ No newline at end of file +.git_status_output.txt + +telosdb-settings-*.json \ No newline at end of file diff --git a/package.json b/package.json index 9949a05..00c5856 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "dev:fast:pro": "node tools/run-dev-fast.mjs --pro", "copy-editor": "node tools/copy-toast-ui-editor.js", "build-editor": "vite build --config tools/vite.config.editor.mjs", - "build:community": "tauri build -c src/backend/tauri.community.conf.json", + "build:community": "tauri build -c src/backend/tauri.community.conf.json -- --no-default-features --features community", "build:pro": "node tools/ensure-embedding-model.mjs && tauri build -c src/backend/tauri.pro.conf.json -- --no-default-features --features pro", + "build:installers": "npm run build:community && npm run build:pro", "test:rust": "cd src/backend && cargo test", "test:rust:pro": "cd src/backend && cargo test --no-default-features --features pro", "test:unit": "node --test \"tests/unit/**/*.test.mjs\"", @@ -36,7 +37,10 @@ "build:e2e": "node tools/build-for-e2e.mjs", "build:e2e:pro": "node tools/ensure-embedding-model.mjs && tauri build --debug --no-bundle -c src/backend/tauri.pro.conf.json -- --no-default-features --features pro", "kill-ports": "node tools/kill-ports.mjs", - "check-mermaid": "node tools/check-mermaid.mjs" + "check-mermaid": "node tools/check-mermaid.mjs", + "debug-ui": "node tools/debug-ui-puppeteer.mjs", + "debug-ui:serve": "node tools/debug-ui-with-serve.mjs", + "debug-ui:pro": "node tools/debug-ui-pro-full.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", @@ -62,6 +66,7 @@ "@wdio/spec-reporter": "^9.19.0", "edgedriver": "^6.3.0", "prettier": "^3.8.1", + "puppeteer": "^23.0.0", "typescript": "^5.2.2", "vite": "^6.0.0" } diff --git a/src/backend/src/lib.rs b/src/backend/src/lib.rs index 7dab6f3..dbb5f62 100644 --- a/src/backend/src/lib.rs +++ b/src/backend/src/lib.rs @@ -25,6 +25,7 @@ "min_score": 0.3, "limit": 5, "run_on_login": false, + "standard_folders_enabled": false, "monitor_paths": [], "watch_extensions": ["txt", "md", "json", "html", "css", "js", "mjs", "ts", "rs"] }); @@ -51,10 +52,14 @@ .and_then(|v| v.as_array()) .cloned() .unwrap_or_else(|| def.get("watch_extensions").and_then(|v| v.as_array()).cloned().unwrap_or_else(|| vec![])); + let standard_folders_enabled = obj.get("standard_folders_enabled") + .and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|n| n != 0))) + .unwrap_or(false); let merged = serde_json::json!({ "min_score": obj.get("min_score").or(def.get("min_score")).unwrap_or(&serde_json::json!(0.3)), "limit": obj.get("limit").or(def.get("limit")).unwrap_or(&serde_json::json!(5)), "run_on_login": run_on_login, + "standard_folders_enabled": standard_folders_enabled, "monitor_paths": monitor_paths, "watch_extensions": watch_extensions }); @@ -62,25 +67,237 @@ Ok(merged) } +/// 標準フォルダ用カテゴリ名と既定説明(set_app_settings と mcp::handlers で同じ定義) +const STANDARD_FOLDER_CATEGORIES: &[(&str, &str)] = &[ + ("汎用ルール", "エディタや AI エージェント用のルールを格納します。"), + ("汎用スキル", "AI エージェント用のスキル(SKILL.md など)を格納します。"), + ("汎用ツール", "MCP ツール定義やスクリプト(ソースコード)を格納します。監視対象の拡張子は設定(watch_extensions)で指定し、**MCP からも変更可能**にします。"), + ("汎用ナレッジ", "調べものや参照用のナレッジを格納します。"), +]; + /// アプリデータディレクトリの settings.json に保存 #[tauri::command] fn set_app_settings(app: tauri::AppHandle, settings: serde_json::Value) -> Result<(), String> { let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let path = dir.join("settings.json"); - let to_write = if settings.get("min_score").is_some() || settings.get("limit").is_some() || settings.get("run_on_login").is_some() || settings.get("monitor_paths").is_some() || settings.get("watch_extensions").is_some() { + let mut to_write = if settings.get("min_score").is_some() || settings.get("limit").is_some() || settings.get("run_on_login").is_some() || settings.get("standard_folders_enabled").is_some() || settings.get("monitor_paths").is_some() || settings.get("watch_extensions").is_some() { settings.clone() } else if let Some(inner) = settings.get("settings").and_then(|v| v.as_object()) { serde_json::to_value(inner).unwrap_or(settings) } else { settings }; + if let Some(obj) = to_write.as_object_mut() { + let enabled = obj + .get("standard_folders_enabled") + .and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|n| n != 0))) + .unwrap_or(false); + let mut paths: Vec = obj + .get("monitor_paths") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if enabled { + for (name, desc) in STANDARD_FOLDER_CATEGORIES { + let has = paths.iter().any(|p| { + p.get("category").and_then(|c| c.as_str()) == Some(*name) + }); + if !has { + let default_path = dir.join(name); + let _ = std::fs::create_dir_all(&default_path); + paths.push(serde_json::json!({ + "path": default_path.to_string_lossy(), + "category": name, + "description": desc + })); + } + } + } else { + paths.retain(|p| { + let cat = p.get("category").and_then(|c| c.as_str()).unwrap_or(""); + !STANDARD_FOLDER_CATEGORIES.iter().any(|(name, _)| *name == cat) + }); + } + obj.insert("monitor_paths".to_string(), serde_json::Value::Array(paths)); + } let s = serde_json::to_string_pretty(&to_write).map_err(|e| e.to_string())?; std::fs::write(&path, &s).map_err(|e| e.to_string())?; log::info!("set_app_settings: wrote to {:?}, run_on_login={:?}", path, to_write.get("run_on_login")); Ok(()) } +/// アプリデータフォルダを OS の既定アプリ(エクスプローラ等)で開く。 +#[tauri::command] +fn open_app_data_folder(app: tauri::AppHandle) -> Result<(), String> { + let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = dir.to_string_lossy(); + #[cfg(windows)] + let status = std::process::Command::new("explorer").arg(path.as_ref()).status(); + #[cfg(target_os = "macos")] + let status = std::process::Command::new("open").arg(path.as_ref()).status(); + #[cfg(not(any(windows, target_os = "macos")))] + let status = std::process::Command::new("xdg-open").arg(path.as_ref()).status(); + status.map_err(|e| e.to_string())?; + Ok(()) +} + +/// 現在の設定を指定パスに JSON で書き出す(バックアップ)。 +#[tauri::command] +fn export_settings_to_file(app: tauri::AppHandle, path: String) -> Result<(), String> { + let settings_path = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("settings.json"); + let default = serde_json::json!({ + "min_score": 0.3, + "limit": 5, + "run_on_login": false, + "standard_folders_enabled": false, + "monitor_paths": [], + "watch_extensions": ["txt", "md", "json", "html", "css", "js", "mjs", "ts", "rs"] + }); + let content = if settings_path.exists() { + let s = std::fs::read_to_string(&settings_path).map_err(|e| e.to_string())?; + serde_json::from_str::(&s).unwrap_or(default) + } else { + default + }; + let out = serde_json::to_string_pretty(&content).map_err(|e| e.to_string())?; + std::fs::write(&path, &out).map_err(|e| e.to_string())?; + log::info!("export_settings_to_file: wrote to {:?}", path); + Ok(()) +} + +/// 指定パスの JSON ファイルから設定を読み、app_data_dir/settings.json に書き込む(復元)。 +#[tauri::command] +fn import_settings_from_file(app: tauri::AppHandle, path: String) -> Result<(), String> { + let s = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let loaded: serde_json::Value = serde_json::from_str(&s).map_err(|e| e.to_string())?; + let obj = loaded.as_object().ok_or("設定ファイルは JSON オブジェクトである必要があります")?; + + // 許可キーのみ抽出し、型を検証 + let mut to_write = serde_json::Map::new(); + if let Some(v) = obj.get("min_score") { + if v.is_f64() || v.is_i64() { + to_write.insert("min_score".to_string(), v.clone()); + } + } + if let Some(v) = obj.get("limit") { + if v.is_i64() || v.is_u64() { + to_write.insert("limit".to_string(), v.clone()); + } + } + if let Some(v) = obj.get("run_on_login") { + if v.as_bool().is_some() { + to_write.insert("run_on_login".to_string(), v.clone()); + } + } + if let Some(v) = obj.get("standard_folders_enabled") { + if v.as_bool().is_some() || v.as_i64().is_some() { + to_write.insert("standard_folders_enabled".to_string(), v.clone()); + } + } + if let Some(v) = obj.get("monitor_paths") { + let arr = v.as_array().ok_or("monitor_paths は配列である必要があります")?; + for item in arr { + if let Some(o) = item.as_object() { + if o.get("path").and_then(|p| p.as_str()).is_none() { + return Err("monitor_paths の各要素に path が必要です".to_string()); + } + if let Some(desc) = o.get("description") { + if desc.as_str().is_none() { + return Err("monitor_paths の description は文字列です".to_string()); + } + } + } else { + return Err("monitor_paths の各要素は { path, category?, description? } のオブジェクトです".to_string()); + } + } + to_write.insert("monitor_paths".to_string(), v.clone()); + } + if let Some(v) = obj.get("watch_extensions") { + let arr = v.as_array().ok_or("watch_extensions は配列である必要があります")?; + for item in arr { + if item.as_str().is_none() { + return Err("watch_extensions の各要素は文字列です".to_string()); + } + } + to_write.insert("watch_extensions".to_string(), v.clone()); + } + + if to_write.is_empty() { + return Err("有効な設定キーがありません(min_score, limit, run_on_login, standard_folders_enabled, monitor_paths, watch_extensions のいずれかを含めてください)".to_string()); + } + + let default = serde_json::json!({ + "min_score": 0.3, + "limit": 5, + "run_on_login": false, + "standard_folders_enabled": false, + "monitor_paths": [], + "watch_extensions": ["txt", "md", "json", "html", "css", "js", "mjs", "ts", "rs"] + }); + let mut merged = default.as_object().unwrap().clone(); + for (k, v) in to_write { + merged.insert(k, v); + } + + let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let out_path = dir.join("settings.json"); + let out = serde_json::to_string_pretty(&serde_json::Value::Object(merged)).map_err(|e| e.to_string())?; + std::fs::write(&out_path, &out).map_err(|e| e.to_string())?; + log::info!("import_settings_from_file: restored from {:?} to {:?}", path, out_path); + Ok(()) +} + +/// 自動起動を有効にする。Windows では HKCU に正規化パス・引用符付きで登録し os error 2 を回避。 +#[tauri::command] +fn autostart_enable(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(windows)] + { + let name = app.package_info().name.clone(); + autostart_win::enable(&name) + } + #[cfg(not(windows))] + { + use tauri_plugin_autostart::ManagerExt; + app.autolaunch().enable().map_err(|e| e.to_string()) + } +} + +/// 自動起動を無効にする。 +#[tauri::command] +fn autostart_disable(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(windows)] + { + let name = app.package_info().name.clone(); + autostart_win::disable(&name) + } + #[cfg(not(windows))] + { + use tauri_plugin_autostart::ManagerExt; + app.autolaunch().disable().map_err(|e| e.to_string()) + } +} + +/// 自動起動が有効かどうか。 +#[tauri::command] +fn autostart_is_enabled(app: tauri::AppHandle) -> Result { + #[cfg(windows)] + { + let name = app.package_info().name.clone(); + autostart_win::is_enabled(&name) + } + #[cfg(not(windows))] + { + use tauri_plugin_autostart::ManagerExt; + app.autolaunch().is_enabled().map_err(|e| e.to_string()) + } +} + // Read last N lines from the log file for UI consumption #[tauri::command] fn read_logs(limit: Option) -> Result { @@ -111,6 +328,9 @@ pub mod utils; pub mod mcp; +#[cfg(windows)] +mod autostart_win; + use std::sync::atomic::{AtomicU64, Ordering}; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -196,6 +416,12 @@ read_logs, get_app_settings, set_app_settings, + autostart_enable, + autostart_disable, + autostart_is_enabled, + export_settings_to_file, + import_settings_from_file, + open_app_data_folder, ]) .setup(move |app| { let boot_start = std::time::Instant::now(); @@ -205,6 +431,11 @@ .path() .app_data_dir() .expect("failed to get app data dir"); + // 初回インストール時は app_data_dir が存在しないため、先に作成する(DB/設定/vec0コピー前に必須) + if let Err(e) = std::fs::create_dir_all(&app_data_dir) { + log::error!("Failed to create app data dir {:?}: {}", app_data_dir, e); + return Err(e.into()); + } let db_path = app_data_dir.join("telos.db"); let resource_dir = app.path().resource_dir().unwrap_or_default(); diff --git a/src/backend/src/mcp/handlers.rs b/src/backend/src/mcp/handlers.rs index 4167636..46aa976 100644 --- a/src/backend/src/mcp/handlers.rs +++ b/src/backend/src/mcp/handlers.rs @@ -51,27 +51,33 @@ Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION") })) } +const STANDARD_FOLDER_CATEGORIES: &[(&str, &str)] = &[ + ("汎用ルール", "エディタや AI エージェント用のルールを格納します。"), + ("汎用スキル", "AI エージェント用のスキル(SKILL.md など)を格納します。"), + ("汎用ツール", "MCP ツール定義やスクリプト(ソースコード)を格納します。監視対象の拡張子は設定(watch_extensions)で指定し、**MCP からも変更可能**にします。"), + ("汎用ナレッジ", "調べものや参照用のナレッジを格納します。"), +]; + fn settings_default() -> serde_json::Value { serde_json::json!({ "min_score": 0.3, "limit": 5, "run_on_login": false, + "standard_folders_enabled": false, "monitor_paths": [], "watch_extensions": ["txt", "md", "json", "html", "css", "js", "mjs", "ts", "rs"] }) } -pub async fn settings_get_handler(State(state): State) -> impl IntoResponse { - log::info!("[server] GET /settings 受信"); +/// 設定を取得する。MCP ツール settings_get および GET /settings で使用。 +pub async fn get_settings(state: &AppState) -> serde_json::Value { let path = state.app_data_dir.join("settings.json"); let default = settings_default(); if !path.exists() { - log::info!("settings_get: no file at {:?}, returning default", path); - return Json(default); + return default; } match tokio::fs::read_to_string(&path).await { Ok(s) => { - log::info!("settings_get: read from {:?}, len={}", path, s.len()); match serde_json::from_str::(&s) { Ok(loaded) => { let empty: serde_json::Map = serde_json::Map::new(); @@ -93,62 +99,91 @@ .map(|s| serde_json::Value::String((*s).to_string())) .collect() }); - let merged = serde_json::json!({ + let standard_folders_enabled = obj + .get("standard_folders_enabled") + .and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|n| n != 0))) + .unwrap_or(false); + serde_json::json!({ "min_score": obj.get("min_score").and_then(|v| v.as_f64()).unwrap_or(0.3), "limit": obj.get("limit").and_then(|v| v.as_i64()).unwrap_or(5), "run_on_login": run_on_login, + "standard_folders_enabled": standard_folders_enabled, "monitor_paths": monitor_paths, "watch_extensions": watch_extensions - }); - log::info!("settings_get: returning run_on_login={}", run_on_login); - Json(merged) + }) } - Err(_) => Json(default), + Err(_) => default, } } - Err(e) => { - log::warn!("settings_get: read error {:?}", e); - Json(default) - } + Err(_) => default, } } -pub async fn settings_post_handler( - State(state): State, - axum::Json(payload): axum::Json, -) -> impl IntoResponse { - log::info!("[server] POST /settings 受信: payload = {:?}", payload); +pub async fn settings_get_handler(State(state): State) -> impl IntoResponse { + log::info!("[server] GET /settings 受信"); + Json(get_settings(&state).await) +} + +/// 設定を保存しワッチャーを再起動する。MCP ツール settings_update および POST /settings で使用。 +pub async fn apply_settings(state: &AppState, payload: serde_json::Value) -> Result<(), String> { let dir = &state.app_data_dir; - if let Err(e) = tokio::fs::create_dir_all(dir).await { - log::error!("settings_post: create_dir_all {:?}", e); - return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "failed to create dir"}))); - } - let mut to_write = if payload.get("min_score").is_some() || payload.get("limit").is_some() || payload.get("run_on_login").is_some() || payload.get("monitor_paths").is_some() || payload.get("watch_extensions").is_some() { + tokio::fs::create_dir_all(dir) + .await + .map_err(|e| format!("failed to create dir: {}", e))?; + let mut to_write = if payload.get("min_score").is_some() + || payload.get("limit").is_some() + || payload.get("run_on_login").is_some() + || payload.get("standard_folders_enabled").is_some() + || payload.get("monitor_paths").is_some() + || payload.get("watch_extensions").is_some() + { payload.clone() } else if let Some(inner) = payload.get("settings").and_then(|v| v.as_object()) { serde_json::to_value(inner).unwrap_or_else(|_| payload.clone()) } else { payload.clone() }; - // remove_from_index_paths は保存しない(解除時の一回限りの指示) if let Some(obj) = to_write.as_object_mut() { obj.remove("remove_from_index_paths"); + // 標準フォルダ: standard_folders_enabled に応じて monitor_paths を更新 + let enabled = obj + .get("standard_folders_enabled") + .and_then(|v| v.as_bool().or_else(|| v.as_i64().map(|n| n != 0))) + .unwrap_or(false); + let mut paths: Vec = obj + .get("monitor_paths") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if enabled { + for (name, desc) in STANDARD_FOLDER_CATEGORIES { + let has = paths.iter().any(|p| { + p.get("category").and_then(|c| c.as_str()) == Some(*name) + }); + if !has { + let default_path = dir.join(name); + let _ = tokio::fs::create_dir_all(&default_path).await; + paths.push(serde_json::json!({ + "path": default_path.to_string_lossy(), + "category": name, + "description": desc + })); + } + } + } else { + paths.retain(|p| { + let cat = p.get("category").and_then(|c| c.as_str()).unwrap_or(""); + !STANDARD_FOLDER_CATEGORIES.iter().any(|(name, _)| *name == cat) + }); + } + obj.insert("monitor_paths".to_string(), serde_json::Value::Array(paths)); } let path = dir.join("settings.json"); - let s = match serde_json::to_string_pretty(&to_write) { - Ok(s) => s, - Err(e) => { - log::error!("settings_post: to_string {:?}", e); - return (axum::http::StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid json"}))); - } - }; - if let Err(e) = tokio::fs::write(&path, &s).await { - log::error!("settings_post: write {:?}", e); - return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "failed to write"}))); - } - log::info!("settings_post: wrote to {:?}, run_on_login={:?}", path, to_write.get("run_on_login")); + let s = serde_json::to_string_pretty(&to_write).map_err(|e| format!("invalid json: {:?}", e))?; + tokio::fs::write(&path, &s) + .await + .map_err(|e| format!("failed to write: {}", e))?; - // フォルダ監視: 保存した monitor_paths と watch_extensions をワッチャーに送り再起動(Phase 3) if let Some(ref tx) = state.watcher_restart_tx { let (monitor_paths, category_map) = to_write .get("monitor_paths") @@ -170,35 +205,28 @@ extensions: watch_extensions, category_map: category_map.clone(), }; - if tx.send(config).is_err() { - log::warn!("settings_post: watcher channel closed"); - } else { - log::info!("settings_post: sent watcher config ({} paths) to watcher", monitor_paths.len()); - } + let _ = tx.send(config); - // カテゴリマップに基づき既存ドキュメントのカテゴリを一括更新 for (dir, cat) in &category_map { let prefix = crate::db::normalize_document_path(&dir.to_string_lossy()); - if prefix.is_empty() { continue; } + if prefix.is_empty() { + continue; + } let like_pat = format!("{}/%", crate::db::escape_like(&prefix)); - match sqlx::query("UPDATE documents SET category = ? WHERE (path = ? OR path LIKE ? ESCAPE '\\') AND COALESCE(category, '') != ?") + let _ = sqlx::query("UPDATE documents SET category = ? WHERE (path = ? OR path LIKE ? ESCAPE '\\') AND COALESCE(category, '') != ?") .bind(cat) .bind(&prefix) .bind(&like_pat) .bind(cat) .execute(&state.db_pool) - .await - { - Ok(r) if r.rows_affected() > 0 => log::info!("settings_post: updated category '{}' for {} docs under {}", cat, r.rows_affected(), prefix), - Ok(_) => {} - Err(e) => log::warn!("settings_post: category update for {}: {}", prefix, e), - } + .await; } - // カテゴリなしのパス → 空文字にリセット for path in &monitor_paths { if !category_map.contains_key(path) { let prefix = crate::db::normalize_document_path(&path.to_string_lossy()); - if prefix.is_empty() { continue; } + if prefix.is_empty() { + continue; + } let like_pat = format!("{}/%", crate::db::escape_like(&prefix)); let _ = sqlx::query("UPDATE documents SET category = '' WHERE (path = ? OR path LIKE ? ESCAPE '\\') AND COALESCE(category, '') != ''") .bind(&prefix) @@ -209,19 +237,32 @@ } } - // モニター解除時に「インデックスからも削除」を選んだパスを処理 if let Some(arr) = payload.get("remove_from_index_paths").and_then(|v| v.as_array()) { for path_value in arr { if let Some(path_str) = path_value.as_str() { - match crate::mcp::tools::items::delete_documents_under_path_prefix(&state, path_str).await { - Ok(n) => log::info!("settings_post: removed {} documents under path {}", n, path_str), - Err(e) => log::warn!("settings_post: delete under path {}: {}", path_str, e), - } + let _ = crate::mcp::tools::items::delete_documents_under_path_prefix(state, path_str).await; } } } - (axum::http::StatusCode::OK, Json(serde_json::json!({"ok": true}))) + Ok(()) +} + +pub async fn settings_post_handler( + State(state): State, + axum::Json(payload): axum::Json, +) -> impl IntoResponse { + log::info!("[server] POST /settings 受信: payload = {:?}", payload); + match apply_settings(&state, payload).await { + Ok(()) => (axum::http::StatusCode::OK, Json(serde_json::json!({"ok": true}))), + Err(e) => { + log::error!("settings_post: {}", e); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": e})), + ) + } + } } #[allow(dead_code)] @@ -300,6 +341,16 @@ ); } + #[test] + fn settings_default_has_standard_folders_enabled_false() { + let d = settings_default(); + assert_eq!( + d.get("standard_folders_enabled").and_then(|v| v.as_bool()), + Some(false), + "標準フォルダはデフォルトでオフ" + ); + } + /// 計画 folder_monitor: デフォルトは監視パスなし(01 スコープ) #[test] fn settings_default_has_monitor_paths_empty() { diff --git a/src/backend/src/mcp/mod.rs b/src/backend/src/mcp/mod.rs index 6330275..e5f8306 100644 --- a/src/backend/src/mcp/mod.rs +++ b/src/backend/src/mcp/mod.rs @@ -230,7 +230,7 @@ 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" | "list_categories" | "get_document_count" | "get_document" | "delete_document" | "lsa_retrain") { + if method == "tools/call" || matches!(actual_method, "get_item_by_id" | "add_item_text" | "search_text" | "lsa_search" | "update_item" | "delete_item" | "list_documents" | "list_categories" | "get_document_count" | "get_document" | "delete_document" | "lsa_retrain" | "write_file" | "settings_get" | "settings_update") { let _ = state.tx.send(format!("mcp:call:{}", actual_method)); } @@ -262,7 +262,7 @@ "resources/list" => Some(serde_json::json!({ "resources": [] })), "prompts/list" => Some(serde_json::json!({ "prompts": [] })), "tools/list" => Some(serde_json::json!({ "tools": tools::registry::tool_list() })), - "tools/call" | "get_item_by_id" | "add_item_text" | "search_text" | "lsa_search" | "update_item" | "delete_item" | "list_documents" | "list_categories" | "get_document_count" | "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" | "list_categories" | "get_document_count" | "get_document" | "delete_document" | "lsa_retrain" | "write_file" | "settings_get" | "settings_update" => { 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/tools/items.rs b/src/backend/src/mcp/tools/items.rs index ffa0a1f..7d3db52 100644 --- a/src/backend/src/mcp/tools/items.rs +++ b/src/backend/src/mcp/tools/items.rs @@ -1,8 +1,8 @@ use sqlx::Row; #[cfg(feature = "community")] use std::collections::HashMap; -use crate::mcp::types::AppState; -use std::path::Path; +use crate::mcp::types::{parse_monitor_paths, AppState}; +use std::path::{Path, PathBuf}; pub async fn handle_get_item_by_id( state: &AppState, @@ -730,3 +730,132 @@ } Ok(count) } + +/// settings.json から monitor_paths を読み、PathBuf のリストを返す。 +async fn get_monitor_paths_from_settings(state: &AppState) -> Vec { + let path = state.app_data_dir.join("settings.json"); + let s = match tokio::fs::read_to_string(&path).await { + Ok(x) => x, + Err(_) => return vec![], + }; + let loaded: serde_json::Value = match serde_json::from_str(&s) { + Ok(x) => x, + Err(_) => return vec![], + }; + let arr = match loaded.get("monitor_paths").and_then(|v| v.as_array()) { + Some(a) => a, + None => return vec![], + }; + let (paths, _) = parse_monitor_paths(arr); + paths +} + +/// パスから "." と ".." を解決した PathBuf を返す(ファイルが存在しなくてもよい)。 +fn normalize_path(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for c in path.components() { + match c { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + _ => out.push(c), + } + } + out +} + +/// target が monitor_paths のいずれか配下であるか(同一または子パス)。 +fn path_under_any(monitor_paths: &[PathBuf], target: &Path) -> bool { + let target_norm = normalize_path(target); + for base in monitor_paths { + let base_norm = normalize_path(base); + if target_norm == base_norm { + return true; + } + if let Ok(rest) = target_norm.strip_prefix(&base_norm) { + if !rest.as_os_str().is_empty() { + return true; + } + } + } + false +} + +/// Issue #14: 監視フォルダ配下にファイルを書き込む。path は監視対象のいずれか配下である必要がある。 +pub async fn handle_write_file( + state: &AppState, + args: &serde_json::Map, +) -> Option { + let path_str = match args.get("path").and_then(|v| v.as_str()) { + Some(s) => s.trim(), + None => { + return Some(serde_json::json!({ + "content": [{ "type": "text", "text": "path is required" }], + "isError": true + })); + } + }; + let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); + + let target = PathBuf::from(path_str); + if target.is_relative() { + return Some(serde_json::json!({ + "content": [{ "type": "text", "text": "path must be absolute" }], + "isError": true + })); + } + + let monitor_paths = get_monitor_paths_from_settings(state).await; + if !path_under_any(&monitor_paths, &target) { + return Some(serde_json::json!({ + "content": [{ "type": "text", "text": "path must be under a monitored folder" }], + "isError": true + })); + } + + if let Some(parent) = target.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + return Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Failed to create parent dir: {}", e) }], + "isError": true + })); + } + } + + match tokio::fs::write(&target, content).await { + Ok(()) => Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Wrote {}", target.display()) }] + })), + Err(e) => Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Write failed: {}", e) }], + "isError": true + })), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_path_resolves_dot_dot() { + let p = Path::new("/a/b/../c"); + assert_eq!(normalize_path(p), PathBuf::from("/a/c")); + } + + #[test] + fn path_under_any_matches_child() { + let bases = vec![PathBuf::from("/monitored")]; + assert!(path_under_any(&bases, Path::new("/monitored/foo.txt"))); + assert!(path_under_any(&bases, Path::new("/monitored/sub/file.md"))); + assert!(!path_under_any(&bases, Path::new("/other/file.txt"))); + assert!(!path_under_any(&bases, Path::new("/monitored_other/file.txt"))); + } + + #[test] + fn path_under_any_matches_exact() { + let bases = vec![PathBuf::from("/monitored")]; + assert!(path_under_any(&bases, Path::new("/monitored"))); + } +} diff --git a/src/backend/src/mcp/tools/mod.rs b/src/backend/src/mcp/tools/mod.rs index 90e4922..acb4ce4 100644 --- a/src/backend/src/mcp/tools/mod.rs +++ b/src/backend/src/mcp/tools/mod.rs @@ -1,6 +1,7 @@ pub mod items; pub mod registry; pub mod search; +pub mod settings; pub mod system; use crate::mcp::types::AppState; @@ -27,6 +28,9 @@ "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, + "write_file" => items::handle_write_file(state, args).await, + "settings_get" => settings::handle_settings_get(state).await, + "settings_update" => settings::handle_settings_update(state, args).await, _ => Some(serde_json::json!({ "content": [{ "type": "text", "text": format!("Unknown tool: {}", actual_method) }], "isError": true diff --git a/src/backend/src/mcp/tools/registry.rs b/src/backend/src/mcp/tools/registry.rs index faf6058..e9f0cdb 100644 --- a/src/backend/src/mcp/tools/registry.rs +++ b/src/backend/src/mcp/tools/registry.rs @@ -100,6 +100,37 @@ "description": "Manually trigger LSA model retraining and vector rebuild", "inputSchema": { "type": "object", "properties": {} } }), + serde_json::json!({ + "name": "write_file", + "description": "Write content to a file under a monitored folder. Path must be absolute and under one of the configured monitor paths (Issue #14).", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "Absolute path to the file to write" }, + "content": { "type": "string", "description": "File content" } + }, + "required": ["path", "content"] + } + }), + serde_json::json!({ + "name": "settings_get", + "description": "Get current settings (monitor_paths, watch_extensions, min_score, limit, run_on_login).", + "inputSchema": { "type": "object", "properties": {} } + }), + serde_json::json!({ + "name": "settings_update", + "description": "Update settings. Partial update supported. Keys: monitor_paths (array of {path, category?, description?}), watch_extensions (array of strings), min_score, limit, run_on_login.", + "inputSchema": { + "type": "object", + "properties": { + "monitor_paths": { "type": "array", "description": "List of { path, category?, description? }" }, + "watch_extensions": { "type": "array", "items": { "type": "string" } }, + "min_score": { "type": "number" }, + "limit": { "type": "integer" }, + "run_on_login": { "type": "boolean" } + } + } + }), ] } @@ -126,6 +157,9 @@ "get_document", "delete_document", "lsa_retrain", + "write_file", + "settings_get", + "settings_update", ]; assert_eq!(list.len(), expected.len(), "tool_list length"); for name in &expected { diff --git a/src/backend/src/mcp/types.rs b/src/backend/src/mcp/types.rs index 053a8c9..94a7340 100644 --- a/src/backend/src/mcp/types.rs +++ b/src/backend/src/mcp/types.rs @@ -97,3 +97,23 @@ #[serde(alias = "sessionId")] pub session_id: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + /// Issue #13: monitor_paths に description が含まれていても path / category は正しくパースされる + #[test] + fn parse_monitor_paths_ignores_description() { + let arr = serde_json::json!([ + { "path": "/foo", "category": "cat", "description": "Markdown **note**" } + ]); + let (paths, category_map) = parse_monitor_paths(arr.as_array().unwrap()); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0], PathBuf::from("/foo")); + assert_eq!( + category_map.get(&PathBuf::from("/foo")), + Some(&"cat".to_string()) + ); + } +} diff --git a/src/backend/tauri.conf.json b/src/backend/tauri.conf.json index b21c25c..413b609 100644 --- a/src/backend/tauri.conf.json +++ b/src/backend/tauri.conf.json @@ -9,7 +9,7 @@ "beforeDevCommand": "" }, "app": { - "windows": [{ "title": "TelosDB", "width": 800, "height": 600, "resizable": true, "fullscreen": false, "visible": false }], + "windows": [{ "title": "TelosDB", "width": 1100, "height": 750, "resizable": true, "fullscreen": false, "visible": false }], "security": { "csp": null }, "withGlobalTauri": true }, diff --git a/src/frontend/components/docs-panel.js b/src/frontend/components/docs-panel.js new file mode 100644 index 0000000..e7a7545 --- /dev/null +++ b/src/frontend/components/docs-panel.js @@ -0,0 +1,321 @@ +import { callMcp, parseResultText, escapeHtml } from '../js/shared.js'; + +class DocsPanel extends HTMLElement { + constructor() { + super(); + this._initialized = false; + this._editingDocumentId = null; + this._docsEditorInstance = null; + this._docsCurrentPage = 1; + } + + connectedCallback() { + if (this._initialized) return; + this._initialized = true; + this.className = 'panel panel-docs'; + this.id = 'panel-docs'; + this.innerHTML = ` +

文書管理

+
+ + +
+
+
一覧を読み込み中...
+
+ + `; + + const DOCS_PAGE_SIZE = 20; + const docsListEl = this.querySelector('#docs-list'); + const docsEditModal = this.querySelector('#docs-edit-modal'); + const docsFormArea = this.querySelector('#docs-form-area'); + const docsPathEl = this.querySelector('#docs-path'); + const docsEditorContainer = this.querySelector('#docs-editor-container'); + const docsFormLegend = this.querySelector('#docs-form-legend'); + const docsFormFeedback = this.querySelector('#docs-form-feedback'); + const docsFileInput = this.querySelector('#docs-file-input'); + const docsImportFileBtn = this.querySelector('#docs-import-file-btn'); + const docsImportFeedback = this.querySelector('#docs-import-feedback'); + + const destroyDocsEditor = () => { + if (this._docsEditorInstance) { + try { + this._docsEditorInstance.destroy(); + } catch (_) {} + this._docsEditorInstance = null; + } + }; + + const createDocsEditor = (initialValue = '') => { + if (!docsEditorContainer) return; + destroyDocsEditor.call(this); + if (typeof toastui === 'undefined' || !toastui?.Editor) { + docsEditorContainer.innerHTML = '

Toast UI Editor を読み込んでください。npm run build-editor を実行し、vendor/toast-ui/ にバンドルを生成してください。

'; + return; + } + this._docsEditorInstance = new toastui.Editor({ + el: docsEditorContainer, + initialValue: initialValue || '', + height: '400px', + initialEditType: 'wysiwyg', + previewStyle: 'tab', + usageStatistics: false, + theme: 'dark', + }); + }; + + const getDocsEditorMarkdown = () => (this._docsEditorInstance ? this._docsEditorInstance.getMarkdown() : ''); + const setDocsEditorMarkdown = (markdown) => { + if (this._docsEditorInstance) this._docsEditorInstance.setMarkdown(markdown || ''); + }; + + const showDocsForm = (legend, path = '', content = '', documentId = null) => { + this._editingDocumentId = documentId; + if (docsFormLegend) docsFormLegend.textContent = legend; + if (docsPathEl) docsPathEl.value = path; + if (docsFormFeedback) { + docsFormFeedback.textContent = ''; + docsFormFeedback.classList.remove('error'); + } + if (docsEditModal) docsEditModal.classList.remove('hidden'); + createDocsEditor.call(this, content); + }; + + const hideDocsForm = () => { + destroyDocsEditor.call(this); + this._editingDocumentId = null; + if (docsEditModal) docsEditModal.classList.add('hidden'); + this.loadDocsList(); + }; + + const loadDocsList = async () => { + if (!docsListEl) return; + docsListEl.innerHTML = '
一覧を読み込み中...
'; + const timeoutMs = 15000; + try { + const result = await Promise.race([ + callMcp('list_documents', { limit: DOCS_PAGE_SIZE, page: this._docsCurrentPage }), + new Promise((_, reject) => setTimeout(() => reject(new Error('タイムアウト')), timeoutMs)), + ]); + const raw = parseResultText(result); + const list = Array.isArray(raw) ? raw : (raw?.items ?? []); + const totalPages = Math.max(0, raw?.total_pages ?? 1); + const totalCount = raw?.total_count ?? list.length; + const currentPage = raw?.page ?? this._docsCurrentPage; + this._docsCurrentPage = currentPage; + + if (!Array.isArray(list)) { + docsListEl.innerHTML = '
一覧の取得に失敗しました
'; + return; + } + if (list.length === 0) { + docsListEl.innerHTML = totalCount > 0 + ? '
このページには文書がありません
' + : '
登録された文書はありません
'; + return; + } + + const prevDisabled = currentPage <= 1; + const nextDisabled = currentPage >= totalPages; + const pagerHtml = totalPages > 1 + ? `
+ + ${currentPage} / ${totalPages} ページ(全 ${totalCount} 件) + +
` + : (totalCount > 0 ? `

全 ${totalCount} 件

` : ''); + + docsListEl.innerHTML = pagerHtml + ` + + + + + + ${list.map((d) => ` + + + + + + + + `).join('')} + +
パスMIMEチャンク数先頭(chunk0)操作
${escapeHtml(d.path || '')}${escapeHtml(d.mime || '')}${Number(d.chunk_count) || 0}${escapeHtml(d.chunk0_preview || '')}${Number(d.chunk_count) > 1 ? '…' : ''} + + +
+ `; + + docsListEl.querySelectorAll('[data-action="prev"]').forEach((btn) => { + btn.addEventListener('click', () => { + if (this._docsCurrentPage > 1) { + this._docsCurrentPage--; + loadDocsList(); + } + }); + }); + docsListEl.querySelectorAll('[data-action="next"]').forEach((btn) => { + btn.addEventListener('click', () => { + if (this._docsCurrentPage < totalPages) { + this._docsCurrentPage++; + loadDocsList(); + } + }); + }); + docsListEl.querySelectorAll('.docs-btn-edit').forEach((btn) => { + btn.addEventListener('click', () => openDocEdit(btn.dataset.id)); + }); + docsListEl.querySelectorAll('.docs-btn-delete').forEach((btn) => { + btn.addEventListener('click', () => deleteDoc(btn.dataset.id)); + }); + } catch (e) { + docsListEl.innerHTML = `
エラー: ${escapeHtml(e.message)}
`; + } + }; + + const openDocEdit = async (id) => { + const docId = typeof id === 'string' ? parseInt(id, 10) : id; + if (!Number.isFinite(docId)) { + if (docsFormFeedback) docsFormFeedback.textContent = '無効な文書IDです'; + return; + } + showDocsForm('編集', '', '読み込み中...', docId); + try { + const result = await callMcp('get_document', { document_id: docId }); + const doc = parseResultText(result); + if (doc && typeof doc === 'object') { + if (docsPathEl) docsPathEl.value = doc.path || ''; + setDocsEditorMarkdown(doc.content != null ? String(doc.content) : ''); + if (docsFormFeedback) { + docsFormFeedback.textContent = ''; + docsFormFeedback.classList.remove('error'); + } + } else { + if (docsFormFeedback) docsFormFeedback.textContent = '文書の取得に失敗しました'; + } + } catch (e) { + if (docsFormFeedback) { + docsFormFeedback.textContent = 'エラー: ' + e.message; + docsFormFeedback.classList.add('error'); + } + } + }; + + const deleteDoc = async (id) => { + const docId = typeof id === 'string' ? parseInt(id, 10) : id; + if (!Number.isFinite(docId)) { + alert('無効な文書IDです'); + return; + } + if (!confirm('この文書を削除しますか?')) return; + try { + await callMcp('delete_document', { document_id: docId }); + loadDocsList(); + if (typeof window !== 'undefined' && window.updateDocCount) window.updateDocCount(); + } catch (e) { + alert('削除に失敗しました: ' + e.message); + } + }; + + this.loadDocsList = loadDocsList; + + this.querySelector('#docs-add-btn')?.addEventListener('click', () => showDocsForm('新規登録', '', '')); + this.querySelector('#docs-refresh-btn')?.addEventListener('click', () => loadDocsList()); + this.querySelector('#docs-save-btn')?.addEventListener('click', async () => { + const path = docsPathEl?.value?.trim(); + const content = getDocsEditorMarkdown(); + if (!path) { + if (docsFormFeedback) { + docsFormFeedback.textContent = 'パスを入力してください'; + docsFormFeedback.classList.add('error'); + } + return; + } + if (docsFormFeedback) { + docsFormFeedback.textContent = '保存中...'; + docsFormFeedback.classList.remove('error'); + } + try { + if (this._editingDocumentId) { + await callMcp('delete_document', { document_id: this._editingDocumentId }); + } + await callMcp('add_item_text', { path, content }); + if (docsFormFeedback) { + docsFormFeedback.textContent = '保存しました'; + docsFormFeedback.classList.remove('error'); + } + setTimeout(() => hideDocsForm(), 600); + if (typeof window !== 'undefined' && window.updateDocCount) window.updateDocCount(); + } catch (e) { + if (docsFormFeedback) { + docsFormFeedback.textContent = 'エラー: ' + e.message; + docsFormFeedback.classList.add('error'); + } + } + }); + this.querySelector('#docs-cancel-btn')?.addEventListener('click', hideDocsForm); + docsFileInput?.addEventListener('change', () => { + const file = docsFileInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const text = typeof reader.result === 'string' ? reader.result : ''; + if (!docsPathEl?.value?.trim()) docsPathEl.value = file.name; + setDocsEditorMarkdown(text); + if (docsImportFeedback) { + docsImportFeedback.textContent = `「${escapeHtml(file.name)}」を読み込みました`; + docsImportFeedback.classList.remove('error'); + setTimeout(() => { docsImportFeedback.textContent = ''; }, 3000); + } + docsFileInput.value = ''; + }; + reader.onerror = () => { + if (docsImportFeedback) { + docsImportFeedback.textContent = 'ファイルの読み込みに失敗しました'; + docsImportFeedback.classList.add('error'); + } + docsFileInput.value = ''; + }; + reader.readAsText(file, 'UTF-8'); + }); + docsImportFileBtn?.addEventListener('click', () => docsFileInput?.click()); + + const backdrop = this.querySelector('.docs-edit-modal-backdrop'); + backdrop?.addEventListener('click', hideDocsForm); + } +} + +customElements.define('docs-panel', DocsPanel); +export {}; diff --git a/src/frontend/components/main-panel.js b/src/frontend/components/main-panel.js index b4cfbc3..6933f78 100644 --- a/src/frontend/components/main-panel.js +++ b/src/frontend/components/main-panel.js @@ -1,778 +1,140 @@ +import { loadSettingsFromFile, SETTINGS_KEY, DEFAULTS } from '../js/shared.js'; + class MainPanel extends HTMLElement { connectedCallback() { if (this._initialized) return; this._initialized = true; this.className = 'main-panel'; - this.innerHTML = ` - - - - - - `; - - // Handle navigation events from sidebar - document.addEventListener('navigate-panel', (e) => { - const target = String(e.detail || 'search'); - this.showPanel(target); - }); - - // Expose showPanel as method - const SETTINGS_KEY = 'telosdb_settings'; - const DEFAULT_EXTENSIONS = ['txt', 'md', 'json', 'html', 'css', 'js', 'mjs', 'ts', 'rs']; - const DEFAULTS = { min_score: 0.3, limit: 5, run_on_login: false, monitor_paths: [], watch_extensions: DEFAULT_EXTENSIONS }; - if (!this._removeFromIndexPaths) this._removeFromIndexPaths = new Set(); - - const renderMonitorPathsList = (paths) => { - const listEl = this.querySelector('#settings-monitor-paths-list'); - if (!listEl) return; - const arr = Array.isArray(paths) ? paths : []; - listEl.innerHTML = ''; - const addRow = (pathValue = '', categoryValue = '') => { - const row = document.createElement('div'); - row.className = 'setting-monitor-path-row'; - row.innerHTML = ` - - - - `; - row.querySelector('.setting-monitor-path').value = String(pathValue || ''); - row.querySelector('.setting-monitor-category').value = String(categoryValue || ''); - listEl.appendChild(row); - }; - arr.forEach((p) => { - if (typeof p === 'object' && p !== null) { - addRow(p.path || '', p.category || ''); - } else { - addRow(String(p || ''), ''); - } - }); - }; - - const loadSettingsIntoForm = (settingsOverrides) => { + // F5/WebView でスクリプト実行順がずれても子パネルが未定義だと中身が真っ黒になるため、 + // 先に子コンポーネントを import してから DOM を挿入する。 + (async () => { try { - const s = settingsOverrides != null - ? { ...DEFAULTS, ...settingsOverrides } - : (() => { + await Promise.all([ + import('./search-panel.js'), + import('./docs-panel.js'), + import('./settings-panel.js'), + ]); + } catch (e) { + console.error('[MainPanel] panel imports failed:', e); + this.innerHTML = ` +
+

設定

+

パネルの読み込みに失敗しました。F5 で再読み込みするかコンソールを確認してください。

+
+ `; + return; + } + try { + // 先頭パネルだけ挿入。他は表示時に作成して汚い描画を防ぐ + this.innerHTML = ` +
読み込み中...
+ + `; + + document.addEventListener('navigate-panel', (e) => { + const target = String(e.detail || 'search'); + this.showPanel(target); + }); + + const contentEl = () => this.querySelector('.main-panel-content'); + + this.showPanel = (panelId) => { + const content = contentEl(); + const panels = content ? content.querySelectorAll('.panel') : []; + panels.forEach((p) => p && p.classList.add('hidden')); + let target = null; + if (panelId === 'docs') { + target = this.querySelector('#panel-docs'); + if (!target && content) { + target = document.createElement('docs-panel'); + target.id = 'panel-docs'; + target.className = 'panel'; + content.appendChild(target); + } + if (target) { + target.classList.remove('hidden'); + if (target.loadDocsList) target.loadDocsList(); + } + } else if (panelId === 'settings') { + target = this.querySelector('#panel-settings'); + if (!target && content) { + target = document.createElement('settings-panel'); + target.id = 'panel-settings'; + target.className = 'panel'; + content.appendChild(target); + } + if (target) { + target.classList.remove('hidden'); + if (target.loadForm) target.loadForm(this._initialSettings); + } + } else { + target = this.querySelector('#panel-search'); + if (target) { + target.classList.remove('hidden'); + if (target.updateCategoryFilter) target.updateCategoryFilter(); + } + } + }; + + // 読み込み中を出したままパネルを描画させ、少し待ってから本編に切り替える(描画途中を見せない) + const loadingEl = this.querySelector('.main-panel-loading'); + const contentDiv = this.querySelector('.main-panel-content'); + const flipToContent = () => { + if (loadingEl) loadingEl.remove(); + if (contentDiv) contentDiv.classList.remove('hidden'); + }; + requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(flipToContent, 250))); + + (async () => { + let fromFile = null; + try { + fromFile = await loadSettingsFromFile(); + this._initialSettings = fromFile; + } catch (_) { + console.warn('[TelosDB] 設定ファイル読み込み失敗 — autostart 同期をスキップ'); + this.showPanel('search'); + return; + } + try { + const s = fromFile || (() => { const raw = localStorage.getItem(SETTINGS_KEY); return raw ? { ...DEFAULTS, ...JSON.parse(raw) } : DEFAULTS; })(); - const minScoreEl = this.querySelector('#setting-min-score'); - const limitEl = this.querySelector('#setting-limit'); - const runOnLoginEl = this.querySelector('#setting-run-on-login'); - if (minScoreEl) minScoreEl.value = String(Number(s.min_score)); - if (limitEl) limitEl.value = String(Number(s.limit)); - if (runOnLoginEl) runOnLoginEl.checked = Boolean(s.run_on_login); - renderMonitorPathsList(s.monitor_paths); - const extEl = this.querySelector('#setting-watch-extensions'); - if (extEl) extEl.value = Array.isArray(s.watch_extensions) ? s.watch_extensions.join(', ') : (s.watch_extensions || ''); - } catch (e) { - const minScoreEl = this.querySelector('#setting-min-score'); - const limitEl = this.querySelector('#setting-limit'); - const runOnLoginEl = this.querySelector('#setting-run-on-login'); - if (minScoreEl) minScoreEl.value = String(DEFAULTS.min_score); - if (limitEl) limitEl.value = String(DEFAULTS.limit); - if (runOnLoginEl) runOnLoginEl.checked = DEFAULTS.run_on_login; - renderMonitorPathsList(DEFAULTS.monitor_paths); - const extEl = this.querySelector('#setting-watch-extensions'); - if (extEl) extEl.value = DEFAULTS.watch_extensions.join(', '); - } - }; - - const API_BASE = (typeof window !== 'undefined' && window.API_BASE) ? window.API_BASE : 'http://127.0.0.1:3001'; - - // Tauri 内では invoke でアプリデータの settings.json を直接読む(MCP 起動待ち不要で永続化が確実) - const loadSettingsFromFile = async () => { - try { - const { invoke } = await import('@tauri-apps/api/core'); - const fileSettings = await invoke('get_app_settings'); - if (fileSettings && typeof fileSettings === 'object') { - return { - min_score: fileSettings.min_score ?? DEFAULTS.min_score, - limit: fileSettings.limit ?? DEFAULTS.limit, - run_on_login: Boolean(fileSettings.run_on_login), - monitor_paths: Array.isArray(fileSettings.monitor_paths) ? fileSettings.monitor_paths : DEFAULTS.monitor_paths, - watch_extensions: Array.isArray(fileSettings.watch_extensions) ? fileSettings.watch_extensions : DEFAULTS.watch_extensions, - }; - } - } catch (_) { - // Tauri 外(ブラウザ等)または invoke 失敗時は HTTP で取得 - try { - const res = await fetch(`${API_BASE}/settings`); - if (!res.ok) return null; - const fileSettings = await res.json(); - if (fileSettings && typeof fileSettings === 'object') { - return { - min_score: fileSettings.min_score ?? DEFAULTS.min_score, - limit: fileSettings.limit ?? DEFAULTS.limit, - run_on_login: Boolean(fileSettings.run_on_login), - monitor_paths: Array.isArray(fileSettings.monitor_paths) ? fileSettings.monitor_paths : DEFAULTS.monitor_paths, - watch_extensions: Array.isArray(fileSettings.watch_extensions) ? fileSettings.watch_extensions : DEFAULTS.watch_extensions, - }; - } - } catch (_) {} - } - return null; - }; - - const saveSettingsToFile = async (payload) => { - const toPersist = { min_score: payload.min_score, limit: payload.limit, run_on_login: payload.run_on_login, monitor_paths: payload.monitor_paths, watch_extensions: payload.watch_extensions }; - const hasRemovals = Array.isArray(payload.remove_from_index_paths) && payload.remove_from_index_paths.length > 0; - if (hasRemovals) { - try { - const res = await fetch(`${API_BASE}/settings`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...toPersist, remove_from_index_paths: payload.remove_from_index_paths }), - }); - return res.ok; - } catch (_) { - return false; - } - } - try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('set_app_settings', { settings: toPersist }); - return true; - } catch (_) { - try { - const res = await fetch(`${API_BASE}/settings`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(toPersist), - }); - return res.ok; - } catch (_) { - return false; - } - } - }; - - this.showPanel = (panelId) => { - const panels = this.querySelectorAll('.panel'); - panels.forEach((p) => p && p.classList.add('hidden')); - const target = - panelId === 'docs' - ? this.querySelector('#panel-docs') - : panelId === 'settings' - ? this.querySelector('#panel-settings') - : this.querySelector('#panel-search'); - if (target) target.classList.remove('hidden'); - if (panelId === 'settings') { - (async () => { - try { - const fromFile = await loadSettingsFromFile(); - if (fromFile) { - try { - const invoke = window.__TAURI__?.core?.invoke; - if (invoke) { - const osEnabled = await invoke('plugin:autostart|is_enabled'); - if (fromFile.run_on_login !== osEnabled) { - console.warn('[TelosDB] autostart: 設定値=', fromFile.run_on_login, ' OS実状態=', osEnabled, ' → OS側に合わせて表示'); - fromFile.run_on_login = osEnabled; - } - } - } catch (_) {} - loadSettingsIntoForm(fromFile); - localStorage.setItem(SETTINGS_KEY, JSON.stringify(fromFile)); - } else { - loadSettingsIntoForm(); + const invoke = window.__TAURI__?.core?.invoke; + if (invoke) { + const enabled = await invoke('autostart_is_enabled'); + if (s.run_on_login && !enabled) { + await invoke('autostart_enable'); + console.log('[TelosDB] autostart: 設定に合わせて有効化'); + } else if (!s.run_on_login && enabled) { + await invoke('autostart_disable'); + console.log('[TelosDB] autostart: 設定に合わせて無効化'); + } } - } catch (_) { - loadSettingsIntoForm(); + } catch (e) { + console.error('[TelosDB] autostart 同期エラー:', e); } + this.showPanel('search'); })(); - } - }; - // 起動時: 再インストール後も継承するため Tauri の場合はアプリデータの settings.json から読む - loadSettingsIntoForm(); - - // default show search - this.showPanel('search'); - - (async () => { - try { - let s = (() => { - const raw = localStorage.getItem(SETTINGS_KEY); - return raw ? { ...DEFAULTS, ...JSON.parse(raw) } : DEFAULTS; - })(); - await new Promise((r) => setTimeout(r, 0)); - const fromFile = await loadSettingsFromFile(); - if (fromFile) { - s = fromFile; - loadSettingsIntoForm(s); - localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); - } else { - console.warn('[TelosDB] 設定ファイル読み込み失敗 — autostart 同期をスキップ'); - return; - } - const invoke = window.__TAURI__?.core?.invoke; - if (invoke) { - const enabled = await invoke('plugin:autostart|is_enabled'); - if (s.run_on_login && !enabled) { - await invoke('plugin:autostart|enable'); - console.log('[TelosDB] autostart: 設定に合わせて有効化'); - } else if (!s.run_on_login && enabled) { - await invoke('plugin:autostart|disable'); - console.log('[TelosDB] autostart: 設定に合わせて無効化'); - } - } - } catch (e) { - console.error('[TelosDB] autostart 同期エラー:', e); + } catch (err) { + console.error('[MainPanel] init error:', err); + this.innerHTML = ` + + +
+

設定

+

設定パネルの読み込みに失敗しました。コンソールを確認してください。

+
+ `; + this.querySelectorAll('.panel').forEach((p, i) => { + if (i === 0) p.classList.remove('hidden'); + }); } })(); - - const runOnLoginCheckbox = this.querySelector('#setting-run-on-login'); - if (runOnLoginCheckbox) { - runOnLoginCheckbox.addEventListener('change', () => { - console.log('[TelosDB] 自動起動:', runOnLoginCheckbox.checked ? 'オン' : 'オフ'); - }); - } - - const saveBtn = this.querySelector('#settings-save-btn'); - const feedbackEl = this.querySelector('#settings-feedback'); - if (saveBtn && feedbackEl) { - saveBtn.addEventListener('click', async () => { - const minScoreEl = this.querySelector('#setting-min-score'); - const limitEl = this.querySelector('#setting-limit'); - const runOnLoginEl = this.querySelector('#setting-run-on-login'); - const min_score = Math.max(0, Math.min(1, parseFloat(minScoreEl?.value) || DEFAULTS.min_score)); - const limit = Math.max(1, Math.min(100, parseInt(limitEl?.value, 10) || DEFAULTS.limit)); - const run_on_login = runOnLoginEl ? runOnLoginEl.checked : DEFAULTS.run_on_login; - const pathRows = this.querySelectorAll('.setting-monitor-path-row'); - const monitor_paths = Array.from(pathRows) - .map((row) => { - const p = (row.querySelector('.setting-monitor-path')?.value || '').trim(); - const c = (row.querySelector('.setting-monitor-category')?.value || '').trim(); - return p ? { path: p, category: c } : null; - }) - .filter(Boolean); - const extInput = this.querySelector('#setting-watch-extensions'); - const watch_extensions = (extInput?.value || '') - .split(/[\s,]+/) - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); - try { - let autostartError = null; - try { - const invoke = window.__TAURI__?.core?.invoke; - if (!invoke) throw new Error('Tauri API が利用できません'); - if (run_on_login) { - await invoke('plugin:autostart|enable'); - console.log('[TelosDB] autostart: 有効化成功'); - } else { - await invoke('plugin:autostart|disable'); - console.log('[TelosDB] autostart: 無効化成功'); - } - } catch (autoErr) { - autostartError = String(autoErr?.message || autoErr); - console.error('[TelosDB] autostart 設定失敗:', autoErr); - } - const remove_from_index_paths = Array.from(this._removeFromIndexPaths || []); - const payload = { min_score, limit, run_on_login, monitor_paths, watch_extensions: watch_extensions.length ? watch_extensions : DEFAULTS.watch_extensions, remove_from_index_paths }; - console.log('[TelosDB] 保存: run_on_login =', run_on_login, 'monitor_paths =', monitor_paths.length, 'watch_extensions =', payload.watch_extensions.length, 'remove_from_index_paths =', remove_from_index_paths.length); - const saved = await saveSettingsToFile(payload); - if (saved) { - this._removeFromIndexPaths?.clear(); - const toStore = { min_score, limit, run_on_login, monitor_paths, watch_extensions: payload.watch_extensions }; - localStorage.setItem(SETTINGS_KEY, JSON.stringify(toStore)); - if (minScoreEl) minScoreEl.value = String(min_score); - if (limitEl) limitEl.value = String(limit); - if (runOnLoginEl) runOnLoginEl.checked = run_on_login; - renderMonitorPathsList(monitor_paths); - if (autostartError) { - feedbackEl.textContent = '保存しました(自動起動の登録に失敗: ' + autostartError + ')'; - feedbackEl.classList.add('error'); - } else { - feedbackEl.textContent = '保存しました'; - feedbackEl.classList.remove('error'); - } - setTimeout(() => { feedbackEl.textContent = ''; }, autostartError ? 5000 : 2000); - } else { - feedbackEl.textContent = '保存に失敗しました(ファイルへの書き込みに失敗しました)'; - feedbackEl.classList.add('error'); - } - } catch (e) { - feedbackEl.textContent = '保存に失敗しました'; - feedbackEl.classList.add('error'); - } - }); - } - - this.querySelector('#settings-monitor-path-add')?.addEventListener('click', async (e) => { - e.preventDefault(); - const listEl = this.querySelector('#settings-monitor-paths-list'); - if (!listEl) return; - - let selectedPath = null; - try { - const invoke = window.__TAURI__?.core?.invoke; - if (invoke) { - selectedPath = await invoke('plugin:dialog|open', { - options: { - directory: true, - multiple: false, - title: 'モニター先フォルダを選択', - }, - }); - } - } catch (err) { - console.warn('[TelosDB] folder dialog failed, falling back to text input:', err); - } - - if (selectedPath === null) return; - - const existing = [...listEl.querySelectorAll('.setting-monitor-path')].map(el => el.value.trim()); - if (existing.includes(selectedPath)) return; - - const row = document.createElement('div'); - row.className = 'setting-monitor-path-row'; - row.innerHTML = ` - - - - `; - row.querySelector('.setting-monitor-path').value = selectedPath; - listEl.appendChild(row); - }); - - this.querySelector('#settings-monitor-paths-list')?.addEventListener('click', (e) => { - if (e.target.classList.contains('setting-monitor-path-remove')) { - e.preventDefault(); - const row = e.target.closest('.setting-monitor-path-row'); - if (!row) return; - const pathInput = row.querySelector('.setting-monitor-path'); - const path = (pathInput?.value || '').trim(); - if (path) { - if (!this._removeFromIndexPaths) this._removeFromIndexPaths = new Set(); - this._removeFromIndexPaths.add(path); - } - row.remove(); - } - }); - - // Activity Log accordion behavior - const activityToggle = this.querySelector('.activity-toggle'); - const activityContent = this.querySelector('.activity-content'); - if (activityToggle && activityContent) { - activityToggle.addEventListener('click', () => { - const expanded = activityToggle.getAttribute('aria-expanded') === 'true'; - activityToggle.setAttribute('aria-expanded', String(!expanded)); - if (expanded) { - activityContent.hidden = true; - activityToggle.querySelector('.accordion-toggle').textContent = '▸'; - } else { - activityContent.hidden = false; - activityToggle.querySelector('.accordion-toggle').textContent = '▾'; - } - }); - } - - // --- 文書管理パネル --- - const callMcp = async (method, params = {}) => { - const res = await fetch(`${API_BASE}/messages`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - method, - params, - id: Date.now(), - }), - }); - const data = await res.json(); - if (data.error) throw new Error(data.error.message || JSON.stringify(data.error)); - return data.result; - }; - - const parseResultText = (result) => { - const content = result?.content; - if (!content || !Array.isArray(content) || content.length === 0) return null; - if (content[0].type === 'text' && content[0].text) { - try { return JSON.parse(content[0].text); } catch (_) { return content[0].text; } - } - return null; - }; - - let editingDocumentId = null; - let docsEditorInstance = null; - - const docsListEl = this.querySelector('#docs-list'); - const DOCS_PAGE_SIZE = 20; - let docsCurrentPage = 1; - const docsEditModal = this.querySelector('#docs-edit-modal'); - const docsFormArea = this.querySelector('#docs-form-area'); - const docsPathEl = this.querySelector('#docs-path'); - const docsEditorContainer = this.querySelector('#docs-editor-container'); - const docsFormLegend = this.querySelector('#docs-form-legend'); - const docsFormFeedback = this.querySelector('#docs-form-feedback'); - const docsFileInput = this.querySelector('#docs-file-input'); - const docsImportFileBtn = this.querySelector('#docs-import-file-btn'); - const docsImportFeedback = this.querySelector('#docs-import-feedback'); - - const loadDocsList = async () => { - if (!docsListEl) return; - docsListEl.innerHTML = '
一覧を読み込み中...
'; - try { - const result = await callMcp('list_documents', { limit: DOCS_PAGE_SIZE, page: docsCurrentPage }); - const raw = parseResultText(result); - const list = Array.isArray(raw) ? raw : (raw?.items ?? []); - const totalPages = Math.max(0, raw?.total_pages ?? 1); - const totalCount = raw?.total_count ?? list.length; - const currentPage = raw?.page ?? docsCurrentPage; - docsCurrentPage = currentPage; - - if (!Array.isArray(list)) { - docsListEl.innerHTML = '
一覧の取得に失敗しました
'; - return; - } - if (list.length === 0) { - docsListEl.innerHTML = totalCount > 0 - ? '
このページには文書がありません
' - : '
登録された文書はありません
'; - return; - } - - const prevDisabled = currentPage <= 1; - const nextDisabled = currentPage >= totalPages; - const pagerHtml = totalPages > 1 - ? `
- - ${currentPage} / ${totalPages} ページ(全 ${totalCount} 件) - -
` - : (totalCount > 0 ? `

全 ${totalCount} 件

` : ''); - - docsListEl.innerHTML = pagerHtml + ` - - - - - - ${list.map(d => ` - - - - - - - - `).join('')} - -
パスMIMEチャンク数先頭(chunk0)操作
${escapeHtml(d.path || '')}${escapeHtml(d.mime || '')}${Number(d.chunk_count) || 0}${escapeHtml(d.chunk0_preview || '')}${Number(d.chunk_count) > 1 ? '…' : ''} - - -
- `; - - docsListEl.querySelectorAll('[data-action="prev"]').forEach(btn => { - btn.addEventListener('click', () => { - if (docsCurrentPage > 1) { docsCurrentPage--; loadDocsList(); } - }); - }); - docsListEl.querySelectorAll('[data-action="next"]').forEach(btn => { - btn.addEventListener('click', () => { - if (docsCurrentPage < totalPages) { docsCurrentPage++; loadDocsList(); } - }); - }); - docsListEl.querySelectorAll('.docs-btn-edit').forEach(btn => { - btn.addEventListener('click', () => openDocEdit(btn.dataset.id)); - }); - docsListEl.querySelectorAll('.docs-btn-delete').forEach(btn => { - btn.addEventListener('click', () => deleteDoc(btn.dataset.id)); - }); - } catch (e) { - docsListEl.innerHTML = `
エラー: ${escapeHtml(e.message)}
`; - } - }; - - function escapeHtml(s) { - const div = document.createElement('div'); - div.textContent = s; - return div.innerHTML; - } - - const destroyDocsEditor = () => { - if (docsEditorInstance) { - try { docsEditorInstance.destroy(); } catch (_) {} - docsEditorInstance = null; - } - }; - - const createDocsEditor = (initialValue = '') => { - if (!docsEditorContainer) return; - destroyDocsEditor(); - if (typeof toastui === 'undefined' || !toastui.Editor) { - docsEditorContainer.innerHTML = '

Toast UI Editor を読み込んでください。npm run build-editor を実行し、vendor/toast-ui/ にバンドルを生成してください。

'; - return; - } - docsEditorInstance = new toastui.Editor({ - el: docsEditorContainer, - initialValue: initialValue || '', - height: '400px', - initialEditType: 'wysiwyg', - previewStyle: 'tab', - usageStatistics: false, - theme: 'dark', - }); - }; - - const getDocsEditorMarkdown = () => docsEditorInstance ? docsEditorInstance.getMarkdown() : ''; - const setDocsEditorMarkdown = (markdown) => { if (docsEditorInstance) docsEditorInstance.setMarkdown(markdown || ''); }; - - const showDocsForm = (legend, path = '', content = '', documentId = null) => { - editingDocumentId = documentId; - if (docsFormLegend) docsFormLegend.textContent = legend; - if (docsPathEl) docsPathEl.value = path; - if (docsFormFeedback) { docsFormFeedback.textContent = ''; docsFormFeedback.classList.remove('error'); } - if (docsEditModal) docsEditModal.classList.remove('hidden'); - createDocsEditor(content); - }; - - const hideDocsForm = () => { - destroyDocsEditor(); - editingDocumentId = null; - if (docsEditModal) docsEditModal.classList.add('hidden'); - loadDocsList(); - }; - - const openDocEdit = async (id) => { - const docId = typeof id === 'string' ? parseInt(id, 10) : id; - if (!Number.isFinite(docId)) { - if (docsFormFeedback) docsFormFeedback.textContent = '無効な文書IDです'; - return; - } - showDocsForm('編集', '', '読み込み中...', docId); - try { - const result = await callMcp('get_document', { document_id: docId }); - const doc = parseResultText(result); - if (doc && typeof doc === 'object') { - if (docsPathEl) docsPathEl.value = doc.path || ''; - setDocsEditorMarkdown(doc.content != null ? String(doc.content) : ''); - if (docsFormFeedback) { docsFormFeedback.textContent = ''; docsFormFeedback.classList.remove('error'); } - } else { - if (docsFormFeedback) docsFormFeedback.textContent = '文書の取得に失敗しました'; - } - } catch (e) { - if (docsFormFeedback) docsFormFeedback.textContent = 'エラー: ' + e.message; - if (docsFormFeedback) docsFormFeedback.classList.add('error'); - } - }; - - const deleteDoc = async (id) => { - const docId = typeof id === 'string' ? parseInt(id, 10) : id; - if (!Number.isFinite(docId)) { - alert('無効な文書IDです'); - return; - } - if (!confirm('この文書を削除しますか?')) return; - try { - await callMcp('delete_document', { document_id: docId }); - loadDocsList(); - if (typeof window !== 'undefined' && window.updateDocCount) window.updateDocCount(); - } catch (e) { - alert('削除に失敗しました: ' + e.message); - } - }; - - this.querySelector('#docs-add-btn')?.addEventListener('click', () => { - showDocsForm('新規登録', '', ''); - }); - this.querySelector('#docs-refresh-btn')?.addEventListener('click', () => { - loadDocsList(); - }); - this.querySelector('#docs-save-btn')?.addEventListener('click', async () => { - const path = docsPathEl?.value?.trim(); - const content = getDocsEditorMarkdown(); - if (!path) { - if (docsFormFeedback) { docsFormFeedback.textContent = 'パスを入力してください'; docsFormFeedback.classList.add('error'); } - return; - } - if (docsFormFeedback) { docsFormFeedback.textContent = '保存中...'; docsFormFeedback.classList.remove('error'); } - try { - if (editingDocumentId) { - await callMcp('delete_document', { document_id: editingDocumentId }); - } - await callMcp('add_item_text', { path, content }); - if (docsFormFeedback) { docsFormFeedback.textContent = '保存しました'; docsFormFeedback.classList.remove('error'); } - setTimeout(() => hideDocsForm(), 600); - if (typeof window !== 'undefined' && window.updateDocCount) window.updateDocCount(); - } catch (e) { - if (docsFormFeedback) { docsFormFeedback.textContent = 'エラー: ' + e.message; docsFormFeedback.classList.add('error'); } - } - }); - this.querySelector('#docs-cancel-btn')?.addEventListener('click', hideDocsForm); - docsFileInput?.addEventListener('change', () => { - const file = docsFileInput.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - const text = typeof reader.result === 'string' ? reader.result : ''; - if (!docsPathEl?.value?.trim()) docsPathEl.value = file.name; - setDocsEditorMarkdown(text); - if (docsImportFeedback) { - docsImportFeedback.textContent = `「${escapeHtml(file.name)}」を読み込みました`; - docsImportFeedback.classList.remove('error'); - setTimeout(() => { docsImportFeedback.textContent = ''; }, 3000); - } - docsFileInput.value = ''; - }; - reader.onerror = () => { - if (docsImportFeedback) { - docsImportFeedback.textContent = 'ファイルの読み込みに失敗しました'; - docsImportFeedback.classList.add('error'); - } - docsFileInput.value = ''; - }; - reader.readAsText(file, 'UTF-8'); - }); - docsImportFileBtn?.addEventListener('click', () => docsFileInput?.click()); - - const backdrop = this.querySelector('.docs-edit-modal-backdrop'); - backdrop?.addEventListener('click', hideDocsForm); - - const updateCategoryFilter = () => { - const select = this.querySelector('#search-category-filter'); - if (!select) return; - const raw = localStorage.getItem(SETTINGS_KEY); - const s = raw ? JSON.parse(raw) : {}; - const cats = new Set(); - (s.monitor_paths || []).forEach((p) => { - const c = typeof p === 'object' ? (p.category || '') : ''; - if (c) cats.add(c); - }); - const current = select.value; - select.innerHTML = ''; - [...cats].sort().forEach((c) => { - const opt = document.createElement('option'); - opt.value = c; - opt.textContent = c; - select.appendChild(opt); - }); - select.value = current; - }; - updateCategoryFilter(); - - const originalShowPanel = this.showPanel; - this.showPanel = (panelId) => { - originalShowPanel.call(this, panelId); - if (panelId === 'docs') loadDocsList(); - if (panelId === 'search') updateCategoryFilter(); - }; } } customElements.define('main-panel', MainPanel); - -export { }; - +export {}; diff --git a/src/frontend/components/search-panel.js b/src/frontend/components/search-panel.js new file mode 100644 index 0000000..7697b1f --- /dev/null +++ b/src/frontend/components/search-panel.js @@ -0,0 +1,188 @@ +import { getApiBase, SETTINGS_KEY, DEFAULTS, escapeHtml } from '../js/shared.js'; + +class SearchPanel extends HTMLElement { + connectedCallback() { + if (this._initialized) return; + this._initialized = true; + this.className = 'panel panel-search'; + this.id = 'panel-search'; + this.innerHTML = ` +
+ + + +
+ +
+
クエリを入力して検索を開始してください
+
+ + + `; + + const queryEl = this.querySelector('#query'); + const resultPanel = this.querySelector('#result'); + const searchBtn = this.querySelector('.search-btn'); + + searchBtn?.addEventListener('click', () => this.search()); + + const activityToggle = this.querySelector('.activity-toggle'); + const activityContent = this.querySelector('.activity-content'); + if (activityToggle && activityContent) { + activityToggle.addEventListener('click', () => { + const expanded = activityToggle.getAttribute('aria-expanded') === 'true'; + activityToggle.setAttribute('aria-expanded', String(!expanded)); + if (expanded) { + activityContent.hidden = true; + const toggleSpan = activityToggle.querySelector('.accordion-toggle'); + if (toggleSpan) toggleSpan.textContent = '▸'; + } else { + activityContent.hidden = false; + const toggleSpan = activityToggle.querySelector('.accordion-toggle'); + if (toggleSpan) toggleSpan.textContent = '▾'; + } + }); + } + + document.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && e.target.id === 'query') this.search(); + }); + } + + async search() { + const queryEl = this.querySelector('#query'); + const resultPanel = this.querySelector('#result'); + if (!queryEl || !resultPanel) return; + const query = queryEl.value; + if (!query.trim()) return; + + const settings = (() => { + try { + const raw = localStorage.getItem(SETTINGS_KEY); + const def = { min_score: DEFAULTS.min_score, limit: DEFAULTS.limit, run_on_login: DEFAULTS.run_on_login }; + return raw ? { ...def, ...JSON.parse(raw) } : def; + } catch (e) { + return { min_score: DEFAULTS.min_score, limit: DEFAULTS.limit }; + } + })(); + + resultPanel.innerHTML = '
検索中...
'; + + try { + const categoryFilter = this.querySelector('#search-category-filter')?.value || ''; + const searchParams = { + content: query, + limit: settings.limit, + min_score: settings.min_score, + }; + if (categoryFilter) searchParams.category = categoryFilter; + const res = await fetch(`${getApiBase()}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'search_text', + params: searchParams, + id: 1, + }), + }); + const data = await res.json(); + let results = data.result?.content || []; + let vectorSearchUsed = null; + + if (results.length === 1 && results[0].type === 'text') { + try { + const parsed = JSON.parse(results[0].text); + if (Array.isArray(parsed)) { + results = parsed; + } else if (parsed && Array.isArray(parsed.items)) { + vectorSearchUsed = parsed.vector_search_used === true; + results = parsed.items; + } else { + results = []; + } + } catch (e) { + results = []; + } + } + + if (results.length === 0 || (results.length === 1 && results[0].isError)) { + const msg = results[0]?.text || '結果が見つかりませんでした'; + resultPanel.innerHTML = `
${escapeHtml(msg)}
`; + return; + } + + const currentEdition = typeof window !== 'undefined' ? window.currentEdition : 'community'; + const searchHint = (() => { + if (currentEdition !== 'pro') return ''; + if (vectorSearchUsed === true) { + return '
意味検索(近似近傍)でランキングしています
'; + } + if (vectorSearchUsed === false) { + return '
キーワード一致のみです。RE-INDEX を実行すると意味検索が有効になります
'; + } + return ''; + })(); + + resultPanel.innerHTML = searchHint + results.map((r) => { + const id = r.id !== undefined ? String(r.id).padStart(4, '0') : '????'; + const score = typeof r.similarity === 'number' ? r.similarity.toFixed(4) : 'N/A'; + const content = r.content || r.text || ''; + const category = r.category || ''; + const categoryBadge = category ? `${escapeHtml(category)}` : ''; + return ` +
+
+ DOC_${escapeHtml(String(id))} ${categoryBadge} + SCORE: ${escapeHtml(String(score))} +
+
${escapeHtml(content)}
+
+ `; + }).join(''); + } catch (e) { + resultPanel.innerHTML = `
検索エラー: ${escapeHtml(e.message)}
`; + } + } + + updateCategoryFilter() { + const select = this.querySelector('#search-category-filter'); + if (!select) return; + const raw = localStorage.getItem(SETTINGS_KEY); + const s = raw ? JSON.parse(raw) : {}; + const cats = new Set(); + (s.monitor_paths || []).forEach((p) => { + const c = typeof p === 'object' ? (p.category || '') : ''; + if (c) cats.add(c); + }); + const current = select.value; + select.innerHTML = ''; + [...cats].sort().forEach((c) => { + const opt = document.createElement('option'); + opt.value = c; + opt.textContent = c; + select.appendChild(opt); + }); + select.value = current; + } +} + +customElements.define('search-panel', SearchPanel); +export {}; diff --git a/src/frontend/components/settings-panel.js b/src/frontend/components/settings-panel.js new file mode 100644 index 0000000..729c784 --- /dev/null +++ b/src/frontend/components/settings-panel.js @@ -0,0 +1,500 @@ +import { + loadSettingsFromFile, + saveSettingsToFile, + escapeHtml, + DEFAULTS, + SETTINGS_KEY, +} from '../js/shared.js'; + +/** 標準フォルダのカテゴリ名と既定説明(バックエンドと一致) */ +const STANDARD_FOLDER_CATEGORIES = [ + ['汎用ルール', 'エディタや AI エージェント用のルールを格納します。'], + ['汎用スキル', 'AI エージェント用のスキル(SKILL.md など)を格納します。'], + ['汎用ツール', 'MCP ツール定義やスクリプト(ソースコード)を格納します。監視対象の拡張子は設定(watch_extensions)で指定し、**MCP からも変更可能**にします。'], + ['汎用ナレッジ', '調べものや参照用のナレッジを格納します。'], +]; +const STANDARD_CATEGORY_NAMES = new Set(STANDARD_FOLDER_CATEGORIES.map(([name]) => name)); + +class SettingsPanel extends HTMLElement { + constructor() { + super(); + this._initialized = false; + this._monitorPaths = []; + this._removeFromIndexPaths = new Set(); + this._editingMonitorPathIndex = null; + } + + connectedCallback() { + if (this._initialized) return; + this._initialized = true; + this.className = 'panel panel-settings'; + this.id = 'panel-settings'; + this.innerHTML = ` +

設定

+
+
+ 検索 +
+ + +
+
+ + +
+
+
+ ログイン時自動起動 +
+ + + OS にサインインしたときに TelosDB を自動で起動します +
+
+
+ 標準フォルダ +
+ + + 汎用ルール・汎用スキル・汎用ツール・汎用ナレッジの4フォルダを監視に追加します(パスは編集可能) +
+
+ + + + + + + + + + +
パスカテゴリ説明操作
+
+
+
+ 監視フォルダ +
+ + + + + + + + + + +
パスカテゴリ説明操作
+ +
+ +
+ +
+ + カンマ区切り(例: txt, md, json)。空の場合は既定の拡張子を使用 +
+
+
+
+ + + + + +
+
+ `; + + const renderOneRow = (p, i) => { + const path = (p && p.path) || ''; + const pathDisplay = path || '(保存後に確定)'; + const category = (p && p.category) || ''; + const desc = (p && p.description) || ''; + const descPreview = desc.length > 40 ? desc.slice(0, 40) + '…' : (desc || '—'); + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${escapeHtml(pathDisplay)} + ${escapeHtml(category)} + ${escapeHtml(descPreview)} + + + + + `; + return tr; + }; + + const renderMonitorPathsTable = () => { + const standardTbody = this.querySelector('#settings-standard-paths-tbody'); + const customTbody = this.querySelector('#settings-custom-paths-tbody'); + const standardWrap = this.querySelector('#settings-standard-folders-wrap'); + const standardFoldersEl = this.querySelector('#setting-standard-folders-enabled'); + const arr = Array.isArray(this._monitorPaths) ? this._monitorPaths : []; + const enabled = standardFoldersEl ? standardFoldersEl.checked : false; + if (standardWrap) standardWrap.style.display = enabled ? '' : 'none'; + if (standardTbody) { + standardTbody.innerHTML = ''; + arr.forEach((p, i) => { + if (STANDARD_CATEGORY_NAMES.has((p && p.category) || '')) standardTbody.appendChild(renderOneRow(p, i)); + }); + } + if (customTbody) { + customTbody.innerHTML = ''; + arr.forEach((p, i) => { + if (!STANDARD_CATEGORY_NAMES.has((p && p.category) || '')) customTbody.appendChild(renderOneRow(p, i)); + }); + } + }; + + const renderMonitorPathsList = (paths) => { + const arr = Array.isArray(paths) ? paths : []; + this._monitorPaths = arr.map((p) => { + if (typeof p === 'object' && p !== null) { + return { path: String(p.path || ''), category: String(p.category || ''), description: String(p.description || '') }; + } + return { path: String(p || ''), category: '', description: '' }; + }); + renderMonitorPathsTable(); + }; + + const loadSettingsIntoForm = (settingsOverrides) => { + try { + const s = settingsOverrides != null + ? { ...DEFAULTS, ...settingsOverrides } + : (() => { + const raw = localStorage.getItem(SETTINGS_KEY); + return raw ? { ...DEFAULTS, ...JSON.parse(raw) } : DEFAULTS; + })(); + const minScoreEl = this.querySelector('#setting-min-score'); + const limitEl = this.querySelector('#setting-limit'); + const runOnLoginEl = this.querySelector('#setting-run-on-login'); + if (minScoreEl) minScoreEl.value = String(Number(s.min_score)); + if (limitEl) limitEl.value = String(Number(s.limit)); + if (runOnLoginEl) runOnLoginEl.checked = Boolean(s.run_on_login); + const standardFoldersEl = this.querySelector('#setting-standard-folders-enabled'); + if (standardFoldersEl) standardFoldersEl.checked = Boolean(s.standard_folders_enabled); + renderMonitorPathsList(s.monitor_paths); + const extEl = this.querySelector('#setting-watch-extensions'); + if (extEl) extEl.value = Array.isArray(s.watch_extensions) ? s.watch_extensions.join(', ') : (s.watch_extensions || ''); + } catch (e) { + const minScoreEl = this.querySelector('#setting-min-score'); + const limitEl = this.querySelector('#setting-limit'); + const runOnLoginEl = this.querySelector('#setting-run-on-login'); + if (minScoreEl) minScoreEl.value = String(DEFAULTS.min_score); + if (limitEl) limitEl.value = String(DEFAULTS.limit); + if (runOnLoginEl) runOnLoginEl.checked = DEFAULTS.run_on_login; + const standardFoldersEl = this.querySelector('#setting-standard-folders-enabled'); + if (standardFoldersEl) standardFoldersEl.checked = DEFAULTS.standard_folders_enabled; + renderMonitorPathsList(DEFAULTS.monitor_paths); + const extEl = this.querySelector('#setting-watch-extensions'); + if (extEl) extEl.value = DEFAULTS.watch_extensions.join(', '); + } + }; + + this.loadForm = async (data) => { + let fromFile = data; + if (fromFile == null) { + fromFile = await loadSettingsFromFile(); + if (fromFile && window.__TAURI__?.core?.invoke) { + try { + const invoke = window.__TAURI__.core.invoke; + const osEnabled = await invoke('autostart_is_enabled'); + if (fromFile.run_on_login !== osEnabled) { + fromFile.run_on_login = osEnabled; + } + } catch (_) {} + } + } + if (fromFile) { + loadSettingsIntoForm(fromFile); + localStorage.setItem(SETTINGS_KEY, JSON.stringify(fromFile)); + } else { + loadSettingsIntoForm(); + } + }; + + const modalEl = this.querySelector('#settings-monitor-path-modal'); + const modalPath = this.querySelector('#modal-monitor-path'); + const modalCategory = this.querySelector('#modal-monitor-category'); + const modalDescription = this.querySelector('#modal-monitor-description'); + + const openMonitorPathModal = (index) => { + this._editingMonitorPathIndex = index; + const titleEl = this.querySelector('#settings-monitor-path-modal-title'); + if (titleEl) titleEl.textContent = index === null ? 'フォルダを追加' : 'フォルダを編集'; + if (modalPath) modalPath.value = index !== null && this._monitorPaths[index] ? this._monitorPaths[index].path : ''; + if (modalCategory) modalCategory.value = index !== null && this._monitorPaths[index] ? this._monitorPaths[index].category : ''; + if (modalDescription) modalDescription.value = index !== null && this._monitorPaths[index] ? this._monitorPaths[index].description : ''; + if (modalEl) { + modalEl.hidden = false; + modalPath?.focus(); + } + }; + + const closeMonitorPathModal = () => { + if (modalEl) modalEl.hidden = true; + this._editingMonitorPathIndex = null; + }; + + this.querySelector('#settings-monitor-path-add')?.addEventListener('click', (e) => { + e.preventDefault(); + openMonitorPathModal(null); + }); + + this.addEventListener('click', (e) => { + if (e.target.classList.contains('setting-monitor-edit')) { + const i = parseInt(e.target.dataset.index, 10); + if (!Number.isNaN(i) && i >= 0) openMonitorPathModal(i); + return; + } + if (e.target.classList.contains('setting-monitor-remove')) { + const i = parseInt(e.target.dataset.index, 10); + if (Number.isNaN(i) || i < 0) return; + const path = (this._monitorPaths[i] && this._monitorPaths[i].path) || ''; + if (path) this._removeFromIndexPaths.add(path); + this._monitorPaths.splice(i, 1); + renderMonitorPathsTable(); + } + }); + + this.querySelector('#settings-monitor-path-modal-cancel')?.addEventListener('click', () => closeMonitorPathModal()); + this.querySelector('#settings-monitor-path-modal-save')?.addEventListener('click', () => { + const path = (modalPath?.value || '').trim(); + const category = (modalCategory?.value || '').trim(); + const description = (modalDescription?.value || '').trim(); + const idx = this._editingMonitorPathIndex; + if (idx === null) { + if (path) this._monitorPaths.push({ path, category, description }); + } else if (typeof idx === 'number' && this._monitorPaths[idx] !== undefined) { + this._monitorPaths[idx] = { path, category, description }; + } + renderMonitorPathsTable(); + closeMonitorPathModal(); + }); + modalEl?.addEventListener('click', (e) => { + if (e.target === modalEl) closeMonitorPathModal(); + }); + + this.querySelector('#setting-run-on-login')?.addEventListener('change', () => { + const runOnLoginCheckbox = this.querySelector('#setting-run-on-login'); + if (runOnLoginCheckbox) { + console.log('[TelosDB] 自動起動:', runOnLoginCheckbox.checked ? 'オン' : 'オフ'); + } + }); + + // 標準フォルダONで一覧に4行を即表示(path は空で保存時にバックエンドが実パスを付与) + const standardFoldersToggle = this.querySelector('#setting-standard-folders-enabled'); + if (standardFoldersToggle) { + standardFoldersToggle.addEventListener('change', () => { + const enabled = standardFoldersToggle.checked; + const arr = Array.isArray(this._monitorPaths) ? [...this._monitorPaths] : []; + const standardNames = new Set(STANDARD_FOLDER_CATEGORIES.map(([name]) => name)); + if (enabled) { + for (const [category, description] of STANDARD_FOLDER_CATEGORIES) { + const has = arr.some((p) => (p && p.category) === category); + if (!has) arr.push({ path: '', category, description }); + } + } else { + for (let i = arr.length - 1; i >= 0; i--) { + if (standardNames.has((arr[i] && arr[i].category) || '')) arr.splice(i, 1); + } + } + this._monitorPaths = arr; + renderMonitorPathsTable(); + }); + } + + const saveBtn = this.querySelector('#settings-save-btn'); + const feedbackEl = this.querySelector('#settings-feedback'); + if (saveBtn && feedbackEl) { + saveBtn.addEventListener('click', async () => { + const minScoreEl = this.querySelector('#setting-min-score'); + const limitEl = this.querySelector('#setting-limit'); + const runOnLoginEl = this.querySelector('#setting-run-on-login'); + const min_score = Math.max(0, Math.min(1, parseFloat(minScoreEl?.value) || DEFAULTS.min_score)); + const limit = Math.max(1, Math.min(100, parseInt(limitEl?.value, 10) || DEFAULTS.limit)); + const run_on_login = runOnLoginEl ? runOnLoginEl.checked : DEFAULTS.run_on_login; + const standardFoldersEl = this.querySelector('#setting-standard-folders-enabled'); + const standard_folders_enabled = standardFoldersEl ? standardFoldersEl.checked : DEFAULTS.standard_folders_enabled; + const monitor_paths = (Array.isArray(this._monitorPaths) ? this._monitorPaths : []) + .map((p) => { + const path = (p && p.path || '').trim(); + const category = (p && p.category || '').trim(); + const description = (p && p.description || '').trim(); + return path ? { path, category, ...(description ? { description } : {}) } : null; + }) + .filter(Boolean); + const extInput = this.querySelector('#setting-watch-extensions'); + const watch_extensions = (extInput?.value || '') + .split(/[\s,]+/) + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + try { + let autostartError = null; + try { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) throw new Error('Tauri API が利用できません'); + if (run_on_login) { + await invoke('autostart_enable'); + console.log('[TelosDB] autostart: 有効化成功'); + } else { + await invoke('autostart_disable'); + console.log('[TelosDB] autostart: 無効化成功'); + } + } catch (autoErr) { + autostartError = String(autoErr?.message || autoErr); + console.error('[TelosDB] autostart 設定失敗:', autoErr); + } + const remove_from_index_paths = Array.from(this._removeFromIndexPaths || []); + const payload = { + min_score, + limit, + run_on_login, + standard_folders_enabled, + monitor_paths, + watch_extensions: watch_extensions.length ? watch_extensions : DEFAULTS.watch_extensions, + remove_from_index_paths, + }; + console.log('[TelosDB] 保存: run_on_login =', run_on_login, 'monitor_paths =', monitor_paths.length, 'watch_extensions =', payload.watch_extensions.length, 'remove_from_index_paths =', remove_from_index_paths.length); + const saved = await saveSettingsToFile(payload); + if (saved) { + this._removeFromIndexPaths?.clear(); + let fromFile = await loadSettingsFromFile(); + if (!fromFile) { + await new Promise((r) => setTimeout(r, 300)); + fromFile = await loadSettingsFromFile(); + } + if (fromFile) { + loadSettingsIntoForm(fromFile); + localStorage.setItem(SETTINGS_KEY, JSON.stringify(fromFile)); + } else { + const toStore = { min_score, limit, run_on_login, standard_folders_enabled, monitor_paths, watch_extensions: payload.watch_extensions }; + localStorage.setItem(SETTINGS_KEY, JSON.stringify(toStore)); + if (minScoreEl) minScoreEl.value = String(min_score); + if (limitEl) limitEl.value = String(limit); + if (runOnLoginEl) runOnLoginEl.checked = run_on_login; + if (standardFoldersEl) standardFoldersEl.checked = standard_folders_enabled; + renderMonitorPathsList(monitor_paths); + } + if (autostartError) { + feedbackEl.textContent = '保存しました(自動起動の登録に失敗: ' + autostartError + ')'; + feedbackEl.classList.add('error'); + } else { + feedbackEl.textContent = '保存しました'; + feedbackEl.classList.remove('error'); + } + setTimeout(() => { feedbackEl.textContent = ''; }, autostartError ? 5000 : 2000); + } else { + feedbackEl.textContent = '保存に失敗しました(ファイルへの書き込みに失敗しました)'; + feedbackEl.classList.add('error'); + } + } catch (e) { + feedbackEl.textContent = '保存に失敗しました'; + feedbackEl.classList.add('error'); + } + }); + } + + this.querySelector('#settings-backup-btn')?.addEventListener('click', async () => { + const feedbackEl = this.querySelector('#settings-feedback'); + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !feedbackEl) return; + try { + const date = new Date(); + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const path = await invoke('plugin:dialog|save', { + options: { + defaultPath: `telosdb-settings-${yyyy}-${mm}-${dd}.json`, + filters: [{ name: 'JSON', extensions: ['json'] }], + title: '設定のバックアップ先を選択', + }, + }); + if (path == null) return; + await invoke('export_settings_to_file', { path }); + feedbackEl.textContent = 'バックアップしました'; + feedbackEl.classList.remove('error'); + setTimeout(() => { feedbackEl.textContent = ''; }, 2000); + } catch (e) { + feedbackEl.textContent = 'バックアップに失敗しました: ' + (e?.message || e); + feedbackEl.classList.add('error'); + } + }); + + this.querySelector('#settings-restore-btn')?.addEventListener('click', async () => { + const feedbackEl = this.querySelector('#settings-feedback'); + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !feedbackEl) return; + try { + const path = await invoke('plugin:dialog|open', { + options: { + multiple: false, + filters: [{ name: 'JSON', extensions: ['json'] }], + title: '復元する設定ファイルを選択', + }, + }); + if (path == null) return; + const pathStr = Array.isArray(path) ? path[0] : path; + await invoke('import_settings_from_file', { path: pathStr }); + const fromFile = await loadSettingsFromFile(); + if (fromFile) { + loadSettingsIntoForm(fromFile); + localStorage.setItem(SETTINGS_KEY, JSON.stringify(fromFile)); + } + feedbackEl.textContent = '設定を復元しました。自動起動を変更する場合は保存を押してください。'; + feedbackEl.classList.remove('error'); + setTimeout(() => { feedbackEl.textContent = ''; }, 4000); + } catch (e) { + feedbackEl.textContent = '復元に失敗しました: ' + (e?.message || e); + feedbackEl.classList.add('error'); + } + }); + + this.querySelector('#settings-open-data-folder-btn')?.addEventListener('click', async () => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) return; + try { + await invoke('open_app_data_folder'); + } catch (e) { + const feedbackEl = this.querySelector('#settings-feedback'); + if (feedbackEl) { + feedbackEl.textContent = 'フォルダを開けませんでした: ' + (e?.message || e); + feedbackEl.classList.add('error'); + setTimeout(() => { feedbackEl.textContent = ''; feedbackEl.classList.remove('error'); }, 4000); + } + } + }); + } +} + +customElements.define('settings-panel', SettingsPanel); +export {}; diff --git a/src/frontend/index.html b/src/frontend/index.html index 2e540b1..9c87763 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -47,8 +47,19 @@ + + + - +