diff --git a/.agent/rules/documents.md b/.agent/rules/documents.md index 48e4d47..238c6ad 100644 --- a/.agent/rules/documents.md +++ b/.agent/rules/documents.md @@ -1,12 +1,25 @@ --- trigger: always_on glob: -description: +description: 設計参照・計測・ジャーナル・コミット・ログ確認。他ルールは配布ビルド前→distribution-build.md、Issue→issue_management.md。 --- +0. **着手前**: タスク着手前に、タスク種別に応じて関連ルールを確認してから作業を開始すること。推測や慣習に頼らず、ルールを読んでから着手する。 + - ジャーナル・アーカイブ → 本ファイル 5–6、CONTRIBUTING の「6. ジャーナル記録」「6.1 ジャーナルのアーカイブ」 + - コミット → 本ファイル 5–7、CONTRIBUTING(pre-commit・protected-files) + - 配布ビルド → distribution-build.md、docs/specification/05_development_guide.md の本番ビルド + - Issue 同期・編集 → issue_management.md + 1. toolsは.gitignoreだがアクセスは許可されています。 2. 詳細な設計がdocumentsにあるので、改造を計画する際に参照すること。 +2.1 **調べるときは search_text も使う**: 「どこでやってるか」「どういう仕様か」「他プロジェクトのやり方」を調べる文脈では、リポジトリの grep/read に加えて MCP の `search_text` でインデックスを検索すること。インデックスにモニタや add_item_text で取り込んだドキュメントがあれば、横断的にヒットする。自然な質問(例: 「PDF のソースはどこ」「アーカイブの手順」)で検索するとよい。 3. ソースコードの編集が完了したら、`tools/count_loc.cjs` と `tools/nesting_depth.cjs` を使用して計測を行うこと。 4. 計測の結果、ソースコードが600行以上、またはネストが7階層以上のコードについては、リファクタリングを検討すること。 5. コミットする前にジャーナルを書くこと。 +6. コミットするときは、そのジャーナルが記録している変更(コード・仕様・計画など)とジャーナルをまとめて add し、同一コミットに含めること。ジャーナルだけ・変更だけのコミットで文脈が分かれることを避ける。 +7. コミット直前に `tmp/` を確認し、不要な一時ファイル・ログを削除すること。成果物が `tmp/` にある場合は適切な場所へ移してからコミットする(.gitignore で tmp/* は追跡外)。 +8. 不具合・起動失敗・挙動おかしいときは、つまらない質問をせず必ずログを確認すること。アプリ起動・MCP 待ち・E2E 失敗・indexing が終わらない等は、ターミナル・tauri-driver・バックエンドの標準出力・標準エラーを読んでから原因を判断する。 +9. E2E で失敗しているスペックを直すときは、そのスペックだけを `npm run test:e2e:spec -- --spec tests/e2e/specs/xxx.spec.js` で実行してパッチ当てし、通ったら全件 E2E で再確認すること。test-and-heal も E2E もリトライループは行わない。失敗したら原因を直し(介入し)、再度実行する。 + +**禁止**: (a) ルールを確認せずに作業を開始すること。(b) コミット時に `journals/` のみ add し、当該変更(コード・仕様など)を add しないこと。(c) 週次アーカイブでリンク一覧だけで済ませ、週サマリー・日付別要約などの本文を書かずに集約元を削除すること。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fc181d1..db7a3ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,14 @@ 6. ジャーナル記録 - 重要な作業(破壊的操作など)は `journals/` に記録し、コミットに関連付けることを推奨。 + - 記述の質: 各作業について「受けた指示・依頼内容」と「実施の背景・理由」を書く。誰が何をしたか主体を明示する(例: 「〜を修正した」「〜を追加した」)。 + +6.1 ジャーナルのアーカイブ(週次集約) + - 週をまたいだ作業がたまったら、当該週の日次ジャーナルを 1 本の週次アーカイブに集約する。 + - 手順: + 1. `journals/` に週次アーカイブを新規作成する。ファイル名例: `YYYYWW-週次アーカイブ.md`(例: `202603-010-第10週アーカイブ.md`。YYYY=年、WW=週番号)。 + 2. 中身に「週全体のサマリー」「日付別の要約」(必要ならガントチャート等)を書き、末尾に「集約元ファイル(アーカイブ後に削除)」として元のジャーナルファイル名のリストを記載する。 + 3. アーカイブファイルをコミットに含める。同一コミットで、リストに挙げた元ファイルを `journals/` から削除してもよい(アーカイブに内容が取り込まれていることを確認してから削除すること)。 --- diff --git a/docs/specification/03_database_specification.md b/docs/specification/03_database_specification.md index 602ec2e..1b22863 100644 --- a/docs/specification/03_database_specification.md +++ b/docs/specification/03_database_specification.md @@ -33,7 +33,7 @@ blob embedding "50d(Community) or 768d(Pro)" } items_fts { - rowid PK + int rowid PK text content "FTS5 trigram" } items_lsa { @@ -56,12 +56,18 @@ - ベクトルが使えない場合(Pro でモデル未ロード等)は FTS のみで検索可能。 -## 4. 同期・ヒーリング +## 4. ベクトル次元不一致時の vec_items 再作成 + +- **起動時**に `initialize_database` で現在の vec_items のテーブル定義(sqlite_master)を確認する。 +- 期待する次元(Community: 50、Pro: 768)と `FLOAT[n]` が一致しなければ、**vec_items を DROP してから正しい次元で再 CREATE** する。 +- 再作成後は vec_items が空になるため、RE-INDEX(lsa_retrain)またはヒーリングで再ベクトル化する。実装: `db::check_and_init_vector_table`。 + +## 5. 同期・ヒーリング - **起動時**: items に対応する items_fts / vec_items(および Community の items_lsa)の不足を補完。 - **手動**: `GET /heal` で FTS の不足行を同期。Pro の vec_items 不足は RE-INDEX(lsa_retrain)で補完。 -## 5. 内部管理 +## 6. 内部管理 - **internal_metadata**: スキーマバージョン等。マイグレーションで参照。 diff --git a/docs/specification/05_development_guide.md b/docs/specification/05_development_guide.md index 337aadf..cef6110 100644 --- a/docs/specification/05_development_guide.md +++ b/docs/specification/05_development_guide.md @@ -9,13 +9,17 @@ | `tests/` | テスト一式(原則ルート直下に集約)。API/MCP は `test_mcp_client.mjs`、E2E は `tests/e2e/`。Rust の結合テスト**ファイル**だけは Cargo の都合で `src/backend/tests/` に配置。詳細は `tests/README.md`。 | | `docs/specification/` | 本仕様書群。 | | `.agent/rules/` | AI エージェント用プロジェクト運用ルール。 | +| `tools/` | 開発用スクリプト。計測(count_loc, nesting_depth)、Mermaid 構文チェック(check-mermaid)、起動・テスト用など。 | + +- **Mermaid 構文チェック**: Markdown 内の mermaid コードブロックを検証するには `npm run check-mermaid` を実行する。対象は `docs/` と `journals/`(引数でファイル指定も可)。要 `@mermaid-js/mermaid-cli`(mmdc)。 ## 2. ビルド・起動 ### 開発時 -- **Community**: `npm run dev`(Tauri 1 プロセスで 8474 + 3001) -- **Pro**: `npm run dev:pro`。`embedding_model/` に ONNX モデルと vocab を配置すること。 +- **Community**: `npm run dev`(F5 などでそのまま起動できる)。起動時に LSA/HNSW 学習あり。 +- **Community(速い起動)**: `npm run dev:fast`。`TELOS_SKIP_BOOT_TRAIN=1` で起動時学習をスキップ。検索が必要なら UI の「RE-INDEX」を実行。 +- **Pro**: `npm run dev:pro` または `npm run dev:fast:pro`。`embedding_model/` に ONNX モデルと vocab を配置すること。 ### 本番ビルド(2 エディション) @@ -61,3 +65,11 @@ - ゴール・KPI: **08_embedding_tract_goals_and_kpi.md** - 検証手順: **09_embedding_tract_implementation_and_tests.md** - UI テスト方針: **12_ui_testing_options.md** +- **E2E**: `npm run test:e2e` はバイナリが既にあればビルドをスキップする。永遠に待たないようにするには `npm run test:e2e:timed`(既定 8 分で打ち切り)を使う。打ち切り時はコンソール出力をログとして確認する。 +- **失敗しているスペックだけ回す**: 全件 E2E で失敗したスペックが分かったら、そのファイルだけを繰り返し実行してパッチ当てする。例: `npm run test:e2e:spec -- --spec tests/e2e/specs/screenshot-docs-modal.spec.js`。複数指定する場合は `--spec` を複数並べるか、`--spec "tests/e2e/specs/foo.spec.js" "tests/e2e/specs/bar.spec.js"` のようにする。 +- **E2E(1 回・打ち切り付き)**: `npm run test:e2e:timed` で 1 回だけ実行(既定 8 分で打ち切り)。リトライはしない。失敗したら原因を直してから再実行する。 + +## 6. デバッグ + +- **不具合時は質問より先にログを確認する**。アプリ起動失敗・MCP が応答しない・E2E タイムアウト・indexing が終わらない等は、ターミナル・tauri-driver・バックエンドの標準出力・標準エラーおよび `log::info!` 等を読んでから原因を判断する。 +- **ログをファイルに残す**: `npm run dev:log` で起動すると標準出力・標準エラーが `tmp/dev.log` に追記される。不具合再現後に `tmp/dev.log` を開いて確認する。 diff --git a/docs/specification/06_ui_design_spec.md b/docs/specification/06_ui_design_spec.md index a001153..e8f704e 100644 --- a/docs/specification/06_ui_design_spec.md +++ b/docs/specification/06_ui_design_spec.md @@ -16,8 +16,7 @@ ## 3. 設定パネル -- **表示する項目**: 検索まわり(スコア足切り 0〜1、取得件数)のみ。保存は localStorage(キー `telosdb_settings`)。 -- **非表示**: ログイン時起動・フォルダモニタリングのパネルは UI に含めない(将来用のため実装は残す場合あり)。 +- **表示する項目**: 検索まわり(スコア足切り 0〜1、取得件数)、**ログイン時自動起動**(計画 auto_start)、**モニター先フォルダ**(計画 folder_monitor)。保存はアプリ設定(settings.json)および localStorage(キー `telosdb_settings`)。 ## 4. ステータス・アクティビティ diff --git "a/journals/202603-011-\347\254\25411\351\200\261\343\202\242\343\203\274\343\202\253\343\202\244\343\203\226.md" "b/journals/202603-011-\347\254\25411\351\200\261\343\202\242\343\203\274\343\202\253\343\202\244\343\203\226.md" new file mode 100644 index 0000000..8dc1697 --- /dev/null +++ "b/journals/202603-011-\347\254\25411\351\200\261\343\202\242\343\203\274\343\202\253\343\202\244\343\203\226.md" @@ -0,0 +1,54 @@ +# 2026年第011週 (3/8 頃) 作業アーカイブ: 計画整備・リファクタ・UI・バージョン・ルール明文化 + +## 週全体のサマリー + +3月第2週にかけ、計画ドキュメントの整備(folder_monitor / auto_start / lda の分割・0.3.3 目標)、リファクタリング Phase 1–5 の実施と仕様反映・refactor 計画削除、文書編集モーダルの UI 改善とチャンク分割仕様の確認、バージョン 0.3.2 リビルドと取得件数・バージョン表示まわり、およびルール対比に基づく着手前・ジャーナル記述・アーカイブ手順の明文化を行った。 + +--- + +## 計画整備・0.3.3・LDA・自動起動分割 (202603-011) + +- **バージョン**: 0.3.2 → 0.3.3 に更新。README / package.json / Cargo.toml / tauri.conf 等を 0.3.3 に統一。 +- **計画ドキュメント**: `docs/plans/` を計画ごとにサブフォルダ化。folder_monitor / auto_start / lda を 01 スコープ〜06 参照に分割。v0.3.3 Windows でフォルダ監視 Phase 1–3、自動起動(tauri-plugin-autostart)、LDA(Community 専用・128 次元・UI 改造)の目標を明記。 +- **ルール**: `.cursor/rules/markdown-mermaid.mdc` を追加。docs/README.md の plans 構成説明を更新。 + +--- + +## リファクタリング実施と仕様反映・計画削除 (202603-012) + +- **Phase 1–5**: MCP 状態構築の分離(`build_app_state`)、doc_count 共通化(`db::get_document_count`)、ツール一覧の registry 化(`mcp/tools/registry.rs`)、DB ラップ(`get_item_content_with_doc`・`get_document_id_by_path`)、検索クエリベクトル取得の 1 箇所集約(`get_query_vector`)。 +- **仕様反映**: `02_architecture_design.md`(バックエンド構成)、`05_development_guide.md`(バックエンド開発)に追記。 +- **計画削除**: `docs/plans/refactor/` を削除。docs/README.md から refactor 記載を除去。 + +--- + +## 文書編集モーダルUI改善とチャンク分割確認 (202603-013) + +- **UI**: 保存・キャンセルボタンの視認性向上、Toast UI ツールバーアイコンの表示修正(`.panel button` の background で上書きされないよう除外)、モーダル・エディタ枠のスタイル調整。 +- **チャンク分割**: モーダル保存時も `add_item_text` 経由で 800 文字固定長チャンクであることを確認。文・段落単位ではない。 + +--- + +## バージョン 0.3.2 リビルドと取得件数・バージョン表示 (202603-014) + +- **0.3.2 一時戻し**: package.json / Cargo.toml / tauri.conf を 0.3.2 に統一し、test:all 成功後に Community・Pro の配布ビルドを完了。 +- **検索の既定取得件数**: 10 件 → 5 件に統一(バックエンド・フロント・registry)。 +- **フッター**: API 取得失敗時は「v?」にフォールバック。バージョン取得リトライ 35 回に延長。E2E と結合テストでビルドバージョンと GUI/API の一致を検証。 + +--- + +## ルール対比とジャーナル・アーカイブ手順の明文化 (202603-015) + +- **CONTRIBUTING**: 6.1 ジャーナルのアーカイブ(週次集約)の手順を追加。6. ジャーナル記録に「記述の質」(受けた指示・背景・理由・主体の明示)を追加。 +- **documents.md**: 0. 着手前(タスク種別ごとに関連ルールを確認してから着手)と参照先一覧、禁止事項(ルール未確認での着手、journals/ のみ add、週次アーカイブのリンクのみで本文省略)を追加。 +- **search_text**: MCP で実行し、他プロジェクト(Aintigravity)のルールがヒットすることを確認。その内容を TelosDB に取り込む形で上記を整備。 + +--- + +## 集約元ファイル(アーカイブ後に削除) + +- 202603-011-計画整備・0.3.3・LDA・自動起動分割.md +- 202603-012-リファクタリング実施と仕様反映・計画削除.md +- 202603-013-文書編集モーダルUI改善とチャンク分割確認.md +- 202603-014-バージョン0.3.2リビルドと取得件数・バージョン表示まわり.md +- 202603-015-ルール対比とジャーナル・アーカイブ手順の明文化.md diff --git "a/journals/202603-011-\350\250\210\347\224\273\346\225\264\345\202\231\343\203\2730.3.3\343\203\273LDA\343\203\273\350\207\252\345\213\225\350\265\267\345\213\225\345\210\206\345\211\262.md" "b/journals/202603-011-\350\250\210\347\224\273\346\225\264\345\202\231\343\203\2730.3.3\343\203\273LDA\343\203\273\350\207\252\345\213\225\350\265\267\345\213\225\345\210\206\345\211\262.md" deleted file mode 100644 index 2cec667..0000000 --- "a/journals/202603-011-\350\250\210\347\224\273\346\225\264\345\202\231\343\203\2730.3.3\343\203\273LDA\343\203\273\350\207\252\345\213\225\350\265\267\345\213\225\345\210\206\345\211\262.md" +++ /dev/null @@ -1,31 +0,0 @@ -# 2026年 計画整備・バージョン0.3.3・LDA・自動起動計画の分割 - -## サマリー - -- **バージョン**: 0.3.2 → **0.3.3** に更新(README、package.json、Cargo.toml、tauri.conf.json、04_mcp_api_specification、package-lock.json)。 -- **計画ドキュメント**: `docs/plans/` を計画ごとにサブフォルダ化し、フォルダ監視・自動起動・LDA の各計画を検討事項別に番号付きファイルへ分割。 -- **フォルダ監視**: `folder_monitor/` に 01 スコープ〜06 参照を分割。**v0.3.3 Windows 版**で Phase 1〜3(単一フォルダ監視・DB 連携・設定 UI)を実装する目標を明記。 -- **自動起動**: ログイン時自動起動の計画を `auto_start/` に作成し、01 スコープ〜06 参照に分割。tauri-plugin-autostart、設定連携、UI 改造(トグル表示)を含む。 -- **LDA**: LSA と LDA を切り替えて使う計画を `lda/` に作成。**Community 版専用**、規定 128 次元・ユーザー指定で再構成、**v0.3.3 Community 版**で実装する目標。01 スコープ〜06 参照に分割し、**03 UI 改造**で設定パネル(ベクトル化 LSA/LDA、LDA 次元数 K、再構成ボタン)を記載。 -- **ルール**: `.cursor/rules/markdown-mermaid.mdc` を追加。Markdown 作成時に必要に応じて Mermaid 図を入れるようルール化。 -- **docs/README.md**: plans の構成説明を更新(各計画のサブフォルダ・分割方針を記載)。 - ---- - -## 主な変更ファイル - -| 種別 | パス | -|------|------| -| バージョン | README.md, package.json, package-lock.json, src/backend/Cargo.toml, src/backend/tauri.conf.json, docs/specification/04_mcp_api_specification.md | -| 計画トップ・目次 | docs/plans/folder_monitor/folder_monitor.md, docs/plans/auto_start/auto_start.md, docs/plans/lda/lda.md | -| 計画 01〜06 | docs/plans/**/***_01_scope.md 〜 **_06_references.md(各計画) | -| ルール | .cursor/rules/markdown-mermaid.mdc | -| ドキュメント索引 | docs/README.md | - ---- - -## 計画の構成(現時点) - -- **plans/auto_start/** — ログイン時自動起動。検討事項別 01〜06。 -- **plans/folder_monitor/** — 指定フォルダ監視。v0.3.3 Windows で Phase 1〜3。01〜06。 -- **plans/lda/** — LDA 対応。v0.3.3 Community、規定 128 次元・UI 改造含む。01〜06。 diff --git "a/journals/202603-012-\343\203\252\343\203\225\343\202\241\343\202\257\343\202\277\343\203\252\343\203\263\343\202\260\345\256\237\346\226\275\343\201\250\344\273\225\346\247\230\345\217\215\346\230\240\343\203\273\350\250\210\347\224\273\345\211\212\351\231\244.md" "b/journals/202603-012-\343\203\252\343\203\225\343\202\241\343\202\257\343\202\277\343\203\252\343\203\263\343\202\260\345\256\237\346\226\275\343\201\250\344\273\225\346\247\230\345\217\215\346\230\240\343\203\273\350\250\210\347\224\273\345\211\212\351\231\244.md" deleted file mode 100644 index 8f2fbc5..0000000 --- "a/journals/202603-012-\343\203\252\343\203\225\343\202\241\343\202\257\343\202\277\343\203\252\343\203\263\343\202\260\345\256\237\346\226\275\343\201\250\344\273\225\346\247\230\345\217\215\346\230\240\343\203\273\350\250\210\347\224\273\345\211\212\351\231\244.md" +++ /dev/null @@ -1,41 +0,0 @@ -# 2026年 リファクタリング実施と仕様反映・計画削除 - -## サマリー - -- **リファクタリング実施**: 計画に沿って Phase 1〜5 を実行。MCP 状態構築の分離、doc_count 共通化、ツール一覧の registry 化、DB ラップ、検索クエリベクトル取得の 1 箇所集約を行った。 -- **仕様への反映**: 実施結果を `02_architecture_design.md`(バックエンド構成)と `05_development_guide.md`(バックエンド開発)に追記。 -- **計画の削除**: `docs/plans/refactor/` を削除し、`docs/README.md` から refactor 計画の記載を除去。 - ---- - -## リファクタリング内容 - -| Phase | 内容 | -|-------|------| -| 1 | **MCP 状態構築の分離**: `build_app_state(...)` を切り出し、`run_server` はオーケストレーション(状態構築 → LSA/HNSW 同期 → listen)に限定。 | -| 2 | **doc_count 共通化**: `db::get_document_count(pool)` を追加。handlers・tools/items・system で利用。 | -| 3 | **ツール一覧の一覧化**: `mcp/tools/registry.rs` を新設し `tool_list()` で MCP ツール定義を一元管理。`tools/list` はここから生成。 | -| 4 | **DB ラップ**: `get_item_content_with_doc`・`get_document_id_by_path` を db に追加。search の 3 箇所と items の get/add で利用。 | -| 5 | **検索クエリベクトル集約**: `get_query_vector(state, text)` を search.rs に追加。Community は LSA、Pro は埋め込みで 1 箇所で切り替え。ベクトル検索処理を共通化。 | - ---- - -## 主な変更ファイル - -| 種別 | パス | -|------|------| -| 仕様 | docs/specification/02_architecture_design.md(§5 バックエンド構成追加), 05_development_guide.md(§3 更新) | -| 索引 | docs/README.md(refactor 計画の記載削除) | -| MCP | src/backend/src/mcp/mod.rs(build_app_state, run_server), handlers.rs(doc_count を db 経由に), tools/mod.rs(registry 利用・コメント), tools/registry.rs(新規), tools/items.rs(get_item_content_with_doc, get_document_id_by_path 利用), tools/search.rs(get_query_vector, DB ラップ利用) | -| DB | src/backend/src/db/mod.rs(get_document_count, get_item_content_with_doc, get_document_id_by_path) | -| その他 | src/backend/src/mcp/system.rs(get_document_count 利用), Cargo.lock | -| 削除 | docs/plans/refactor/(refactor.md, refactor_01〜06) | - ---- - -## 計画の現状 - -- **plans/auto_start/** — ログイン時自動起動(変更はテスト・注意事項の追記等) -- **plans/folder_monitor/** — 指定フォルダ監視 -- **plans/lda/** — LDA 対応(v0.3.3 Community) -- **plans/refactor/** — 削除済み(内容は仕様に反映済み) diff --git "a/journals/202603-013-\346\226\207\346\233\270\347\267\250\351\233\206\343\203\242\343\203\274\343\203\200\343\203\253UI\346\224\271\345\226\204\343\201\250\343\203\201\343\203\243\343\203\263\343\202\257\345\210\206\345\211\262\347\242\272\350\252\215.md" "b/journals/202603-013-\346\226\207\346\233\270\347\267\250\351\233\206\343\203\242\343\203\274\343\203\200\343\203\253UI\346\224\271\345\226\204\343\201\250\343\203\201\343\203\243\343\203\263\343\202\257\345\210\206\345\211\262\347\242\272\350\252\215.md" deleted file mode 100644 index 4294bb5..0000000 --- "a/journals/202603-013-\346\226\207\346\233\270\347\267\250\351\233\206\343\203\242\343\203\274\343\203\200\343\203\253UI\346\224\271\345\226\204\343\201\250\343\203\201\343\203\243\343\203\263\343\202\257\345\210\206\345\211\262\347\242\272\350\252\215.md" +++ /dev/null @@ -1,36 +0,0 @@ -# 2026年 文書編集モーダルUI改善とチャンク分割確認 - -## サマリー - -- **文書編集モーダルUI**: 保存・キャンセルボタンの視認性向上、Toast UI ツールバーアイコン非表示の修正、モーダル・エディタ枠のスタイル調整を行った。 -- **ツールバーアイコン**: `.panel button` に `background` を当てていたため Toast UI のスプライト(background-image)が消えていた。ツールバーアイコン用ボタンを `:not(.toastui-editor-toolbar-icons):not([class*="toolbar-icons"])` で除外し、アイコンが表示されるようにした。 -- **チャンク分割**: モーダル保存時も `add_item_text` 経由で 800 文字固定長のチャンク分割が行われていることを確認。文・段落単位の分割にはなっていない。 - ---- - -## UI 変更内容 - -| 対象 | 内容 | -|------|------| -| 保存・キャンセル | `.docs-edit-modal .setting-actions .secondary-btn` および `#docs-save-btn` の背景・枠・色を調整し、ダークテーマで視認しやすくした。 | -| ツールバーアイコン | `styles.css` の `.panel button` を「Toast UI ツールバーアイコン用」に当たらないよう除外。`background` ショートハンドで `background-image` が上書きされないようにした。 | -| 枠・モーダル | `.docs-edit-modal-box` の border / border-radius / box-shadow、`.docs-editor-container .toastui-editor-defaultUI` の枠、設定アクション区切りの線を控えめに統一。 | -| 文書パネルツールバー | 「新規登録」「一覧更新」の `.docs-toolbar .secondary-btn` のスタイルを調整。 | - ---- - -## 主な変更ファイル - -| 種別 | パス | -|------|------| -| スタイル | src/frontend/styles.css(.panel button の除外、モーダル・エディタ・ボタンまわり) | -| E2E | tests/e2e/specs/screenshot-docs-modal.spec.js(編集モーダル開いてスクショ保存)、docs-edit-drawing.spec.js(描画・ボタン表示の検証) | -| スクリプト | package.json(test:e2e:screenshot 追加) | - ---- - -## チャンク分割の仕様(確認結果) - -- **処理**: `add_item_text`(`src/backend/src/mcp/tools/items.rs`)で本文を `chars.chunks(800)` により **800 文字ごと** に分割。 -- **モーダル保存**: フロントの「保存」で `callMcp('add_item_text', { path, content })` が呼ばれ、上記と同じチャンク分割が適用される。 -- **注意**: 文の長さや段落には依存しておらず、固定長 800 文字で切りているだけ。文・段落単位にしたい場合は別ロジックの検討が必要。 diff --git "a/journals/202603-014-\343\203\220\343\203\274\343\202\270\343\203\247\343\203\2630.3.2\343\203\252\343\203\223\343\203\253\343\203\211\343\201\250\345\217\226\345\276\227\344\273\266\346\225\260\343\203\273\343\203\220\343\203\274\343\202\270\343\203\247\343\203\263\350\241\250\347\244\272\343\201\276\343\202\217\343\202\212.md" "b/journals/202603-014-\343\203\220\343\203\274\343\202\270\343\203\247\343\203\2630.3.2\343\203\252\343\203\223\343\203\253\343\203\211\343\201\250\345\217\226\345\276\227\344\273\266\346\225\260\343\203\273\343\203\220\343\203\274\343\202\270\343\203\247\343\203\263\350\241\250\347\244\272\343\201\276\343\202\217\343\202\212.md" deleted file mode 100644 index 64a8377..0000000 --- "a/journals/202603-014-\343\203\220\343\203\274\343\202\270\343\203\247\343\203\2630.3.2\343\203\252\343\203\223\343\203\253\343\203\211\343\201\250\345\217\226\345\276\227\344\273\266\346\225\260\343\203\273\343\203\220\343\203\274\343\202\270\343\203\247\343\203\263\350\241\250\347\244\272\343\201\276\343\202\217\343\202\212.md" +++ /dev/null @@ -1,39 +0,0 @@ -# 2026年 バージョン 0.3.2 リビルドと取得件数・バージョン表示まわり - -## サマリー - -- **バージョン 0.3.2 への一時戻し**: package.json / Cargo.toml / tauri.conf.json を 0.3.2 に統一し、test:all 成功後に Community・Pro の配布ビルドを完了した。 -- **検索の既定取得件数**: 10 件 → 5 件に変更(バックエンド・フロント・設定の既定値・registry スキーマをすべて 5 に統一)。 -- **フッターのバージョン表示**: API 取得失敗時は別バージョンを出さないようフォールバックを「v?」に変更。E2E で MCP 準備が遅れても取得できるようリトライを 35 回に延長。 -- **バージョン一致の検証**: ビルドバージョンと GUI/API の一致を、E2E(フッター vs package.json)と結合テスト(/version vs package.json)で検証するテストを追加。 - ---- - -## 変更内容 - -| 対象 | 内容 | -|------|------| -| バージョン | package.json, src/backend/Cargo.toml, src/backend/tauri.conf.json を 0.3.2 に設定(のちに 0.3.3 へ上げ予定)。 | -| 既定取得件数 | handlers.rs, lib.rs, registry.rs, search.rs, main-panel.js, index.html の limit 既定値を 10 → 5 に変更。 | -| フッター | フォールバックを「0.3.2」→「?」に変更(誤ったバージョン表示を防止)。_fetchVersion のリトライを 5 回→35 回に延長。 | -| E2E | app.spec.js に「フッターのバージョンが package.json と一致する」テストを追加。package.json を読み期待バージョンと比較。 | -| 結合 | test_mcp_client.mjs で /version が package.json の version と一致することをアサート。 | -| ドキュメント | tests/README.md に上記バージョン検証の記載を追記。 | - ---- - -## 主な変更ファイル - -| 種別 | パス | -|------|------| -| バージョン | package.json, src/backend/Cargo.toml, src/backend/tauri.conf.json | -| バックエンド | src/backend/src/mcp/handlers.rs, lib.rs, mcp/tools/registry.rs, mcp/tools/search.rs | -| フロント | src/frontend/components/site-footer.js, main-panel.js, index.html | -| テスト | tests/e2e/specs/app.spec.js, tests/test_mcp_client.mjs, tests/README.md | - ---- - -## ビルド結果(0.3.2) - -- **test:all**: test:rust, test:rust:pro, test:e2e, test:e2e:pro すべて成功。 -- **配布**: TelosDB-Community_0.3.2_x64-setup.exe, TelosDB-Pro_0.3.2_x64-setup.exe を target/release/bundle/nsis に出力。 diff --git "a/journals/202603-016-\343\203\236\343\202\244\343\202\260\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\344\277\256\346\255\243\343\201\250\344\270\241\343\202\250\343\203\207\343\202\243\343\202\267\343\203\247\343\203\263\351\205\215\345\270\203\343\203\223\343\203\253\343\203\211.md" "b/journals/202603-016-\343\203\236\343\202\244\343\202\260\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\344\277\256\346\255\243\343\201\250\344\270\241\343\202\250\343\203\207\343\202\243\343\202\267\343\203\247\343\203\263\351\205\215\345\270\203\343\203\223\343\203\253\343\203\211.md" new file mode 100644 index 0000000..d920077 --- /dev/null +++ "b/journals/202603-016-\343\203\236\343\202\244\343\202\260\343\203\254\343\203\274\343\202\267\343\203\247\343\203\263\344\277\256\346\255\243\343\201\250\344\270\241\343\202\250\343\203\207\343\202\243\343\202\267\343\203\247\343\203\263\351\205\215\345\270\203\343\203\223\343\203\253\343\203\211.md" @@ -0,0 +1,13 @@ +# 2026-03-08 マイグレーション修正と両エディション配布ビルド + +## 実施内容 + +- **DB マイグレーション**: 新規 DB で `migrate_normalize_document_paths` が `documents` を参照して失敗していた問題を修正。`documents` テーブルが存在しない場合(init_schema でこれから作られる場合)は正規化マイグレーションをスキップするようにした(`src/backend/src/db/migration.rs`)。 +- **配布ビルド**: 上記修正後、`npm run test:rust` / `test:rust:pro` / `test:unit` を通過した状態で `npm run build:community` と `npm run build:pro` を実行。両方とも成功し、`target/release/bundle/nsis/` に TelosDB-Community_0.3.3_x64-setup.exe と TelosDB-Pro_0.3.3_x64-setup.exe を出力した。 +- **その他**: 本コミットに含まれる変更には、ルール・CONTRIBUTING の追記、第11週アーカイブ、Mermaid 構文チェック用スクリプト(check-mermaid)の追加(package.json と 05_development_guide)、仕様・E2E・フロント等の既存変更が含まれる。 + +## 主な変更ファイル + +- src/backend/src/db/migration.rs(正規化マイグレーションのスキップ条件) +- journals/202603-011-第11週アーカイブ.md(追加)、011〜015 の各週次ジャーナル(アーカイブに集約済みのため削除) +- その他、.agent/rules/documents.md、CONTRIBUTING.md、docs/specification/*、package.json、backend/frontend/tests の変更一式 diff --git a/package.json b/package.json index 0132d26..e003f29 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,18 @@ "scripts": { "tauri": "tauri", "dev": "node tools/kill-ports.mjs && tauri dev --no-dev-server-wait -- --no-default-features --features community", + "dev:log": "node tools/run-dev-with-log.mjs", + "dev:fast": "node tools/run-dev-fast.mjs", "dev:pro": "node tools/kill-ports.mjs && tauri dev --no-dev-server-wait -- --no-default-features --features pro", + "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:pro": "node tools/ensure-embedding-model.mjs && tauri build -c src/backend/tauri.pro.conf.json -- --no-default-features --features pro", "test:rust": "cd src/backend && cargo test", "test:rust:pro": "cd src/backend && cargo test --no-default-features --features pro", - "test:all": "npm run test:rust && npm run test:rust:pro && npm run test:e2e && npm run test:e2e:pro", + "test:unit": "node --test \"tests/unit/**/*.test.mjs\"", + "test:all": "npm run test:rust && npm run test:rust:pro && npm run test:unit && npm run test:e2e && npm run test:e2e:pro", "test:integration:rust": "cd src/backend && cargo test --test search_api -- --ignored", "test:mcp": "node tools/run-test.mjs", "test:mcp:pro": "node tools/run-test.mjs --pro", @@ -24,12 +28,15 @@ "test-monolithic-dev": "node tools/test-monolithic-dev.mjs", "test-monolithic-dev:pro": "node tools/test-monolithic-dev.mjs --pro", "test:e2e": "wdio run wdio.conf.js", + "test:e2e:timed": "node tools/run-e2e-with-timeout.mjs", + "test:e2e:spec": "wdio run wdio.conf.js", "test:e2e:community": "wdio run wdio.conf.js", "test:e2e:pro": "npm run build:e2e:pro && node tools/run-e2e-pro.mjs", "test:e2e:screenshot": "wdio run wdio.conf.js --spec tests/e2e/specs/screenshot-docs-modal.spec.js", "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" + "kill-ports": "node tools/kill-ports.mjs", + "check-mermaid": "node tools/check-mermaid.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", @@ -45,6 +52,7 @@ "sqlite-vec-windows-x64": "^0.1.7-alpha.2" }, "devDependencies": { + "@mermaid-js/mermaid-cli": "^11.6.0", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", "@wdio/cli": "^9.19.0", diff --git a/src/backend/Cargo.lock b/src/backend/Cargo.lock index 96cdac0..b7c2e54 100644 --- a/src/backend/Cargo.lock +++ b/src/backend/Cargo.lock @@ -180,7 +180,7 @@ [[package]] name = "app" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "axum", @@ -192,6 +192,8 @@ "hnsw_rs", "log", "ndarray 0.15.6", + "notify", + "notify-debouncer-mini", "ort", "reqwest 0.12.28", "rusqlite", @@ -1583,6 +1585,15 @@ ] [[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] name = "funty" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2511,6 +2522,26 @@ ] [[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] name = "instant" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2727,6 +2758,26 @@ ] [[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] name = "kstring" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3063,6 +3114,18 @@ [[package]] name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" @@ -3244,6 +3307,36 @@ ] [[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43" +dependencies = [ + "crossbeam-channel", + "log", + "notify", +] + +[[package]] name = "num-bigint" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6385,7 +6478,7 @@ dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "parking_lot", "pin-project-lite", "signal-hook-registry", diff --git a/src/backend/Cargo.toml b/src/backend/Cargo.toml index 5396f8c..8125de7 100644 --- a/src/backend/Cargo.toml +++ b/src/backend/Cargo.toml @@ -61,6 +61,9 @@ # Pro 版: ONNX 埋め込み (tract 優先、失敗時 ort でフォールバック) tract-onnx = { version = "0.22", optional = true } ort = { version = "2.0.0-rc.12", optional = true, default-features = false, features = ["std", "download-binaries", "tls-rustls"] } +# フォルダ監視(計画 folder_monitor) +notify = "6.1" +notify-debouncer-mini = "0.4" [dev-dependencies] tempfile = "3.10" diff --git a/src/backend/src/db/migration.rs b/src/backend/src/db/migration.rs index 6f85d2e..f69fb86 100644 --- a/src/backend/src/db/migration.rs +++ b/src/backend/src/db/migration.rs @@ -5,9 +5,8 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), String> { // v0.2.5 -> v0.3.0 migrate_025_to_030(pool).await?; - - // 今後、バージョンが増えるごとにここに追加 - // migrate_030_to_040(pool).await?; + // path 正規化(\ → /、末尾 / 除去)。照合を一貫させるため。 + migrate_normalize_document_paths(pool).await?; Ok(()) } @@ -125,3 +124,65 @@ Ok(()) } + +/// documents.path を正規化(\ → /、末尾 / 除去)。既存DBの path を吸収用の一形式に揃える。 +async fn migrate_normalize_document_paths(pool: &SqlitePool) -> Result<(), String> { + use std::collections::HashMap; + + let has_docs = sqlx::query_scalar::<_, i64>("SELECT 1 FROM sqlite_master WHERE type='table' AND name='documents'") + .fetch_optional(pool) + .await + .map_err(|e| e.to_string())?; + if has_docs.is_none() { + return Ok(()); // 新規DBでは documents はまだ無い(init_schema で後から作られる) + } + + let rows: Vec<(i64, String)> = sqlx::query_as("SELECT id, path FROM documents WHERE path IS NOT NULL") + .fetch_all(pool) + .await + .map_err(|e| e.to_string())?; + + if rows.is_empty() { + return Ok(()); + } + + fn norm(p: &str) -> String { + let t = p.trim().replace('\\', "/"); + let t = t.trim_end_matches('/'); + t.to_string() + } + + let normalized: Vec<(i64, String)> = rows.iter().map(|(id, p)| (*id, norm(p))).collect(); + let mut by_norm: HashMap> = HashMap::new(); + for (id, n) in &normalized { + by_norm.entry(n.clone()).or_default().push(*id); + } + + let mut tx = pool.begin().await.map_err(|e| e.to_string())?; + + // 同一正規化 path が複数ある場合は 1 件だけ残し他は削除(CASCADE で items も消える) + for (_npath, ids) in by_norm.iter().filter(|(_, ids)| ids.len() > 1) { + let (_keep, remove) = (ids[0], &ids[1..]); + for id in remove.iter().copied() { + sqlx::query("DELETE FROM documents WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())?; + } + } + + // 全件 path を正規化で更新(重複は上で解消済み) + for (id, npath) in normalized { + sqlx::query("UPDATE documents SET path = ? WHERE id = ?") + .bind(&npath) + .bind(id) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())?; + } + + tx.commit().await.map_err(|e| e.to_string())?; + log::info!("Migration: normalized document paths (backslash → forward slash)."); + Ok(()) +} diff --git a/src/backend/src/db/mod.rs b/src/backend/src/db/mod.rs index 11cbb03..39fc374 100644 --- a/src/backend/src/db/mod.rs +++ b/src/backend/src/db/mod.rs @@ -205,16 +205,45 @@ })) } -/// path で document の id を取得。無ければ None。 +/// path で document の id を取得。無ければ None。照合時は正規化した path を使う。 pub async fn get_document_id_by_path(pool: &SqlitePool, path: &str) -> Result, String> { + let path = normalize_document_path(path); let row = sqlx::query_scalar("SELECT id FROM documents WHERE path = ?") - .bind(path) + .bind(&path) .fetch_optional(pool) .await .map_err(|e| e.to_string())?; Ok(row) } +/// ドキュメント path の正規化(保存・照合で一貫して使用)。区切りは常に / に統一する。 +pub fn normalize_document_path(path: &str) -> String { + let t = path.trim().replace('\\', "/"); + let t = t.trim_end_matches('/'); + t.to_string() +} + +/// 指定ディレクトリ配下の document の id 一覧を返す(path が prefix と一致するか、prefix の直下・サブディレクトリにあるもの)。 +/// path は normalize_document_path で正規化された形式(/ 区切り)で格納・照合する。 +fn escape_like(s: &str) -> String { + s.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_") +} + +pub async fn get_document_ids_under_path_prefix(pool: &SqlitePool, path_prefix: &str) -> Result, String> { + let prefix = normalize_document_path(path_prefix); + if prefix.is_empty() { + return Ok(vec![]); + } + let like_pattern = format!("{}/%", escape_like(&prefix)); + let rows = sqlx::query_scalar::<_, i64>("SELECT id FROM documents WHERE path = ? OR path LIKE ? ESCAPE '\\'") + .bind(&prefix) + .bind(&like_pattern) + .fetch_all(pool) + .await + .map_err(|e| e.to_string())?; + Ok(rows) +} + async fn check_and_init_vector_table(pool: &SqlitePool, dimension: usize) -> Result<(), String> { // 現在のテーブル定義を確認 let row = sqlx::query("SELECT sql FROM sqlite_master WHERE type='table' AND name='vec_items'") diff --git a/src/backend/src/lib.rs b/src/backend/src/lib.rs index 91f8f68..e893be6 100644 --- a/src/backend/src/lib.rs +++ b/src/backend/src/lib.rs @@ -25,7 +25,8 @@ "min_score": 0.3, "limit": 5, "run_on_login": false, - "monitor_paths": [] + "monitor_paths": [], + "watch_extensions": ["txt", "md", "json", "html", "css", "js", "mjs", "ts", "rs"] }); if !path.exists() { log::info!("get_app_settings: no file at {:?}, returning default", path); @@ -46,11 +47,16 @@ .and_then(|v| v.as_array()) .cloned() .unwrap_or_else(|| vec![]); + let watch_extensions = obj.get("watch_extensions") + .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 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, - "monitor_paths": monitor_paths + "monitor_paths": monitor_paths, + "watch_extensions": watch_extensions }); log::info!("get_app_settings: returning run_on_login={}", run_on_login); Ok(merged) @@ -62,7 +68,7 @@ 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() { + 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() { settings.clone() } else if let Some(inner) = settings.get("settings").and_then(|v| v.as_object()) { serde_json::to_value(inner).unwrap_or(settings) diff --git a/src/backend/src/mcp/handlers.rs b/src/backend/src/mcp/handlers.rs index 0e9869c..77d753e 100644 --- a/src/backend/src/mcp/handlers.rs +++ b/src/backend/src/mcp/handlers.rs @@ -39,7 +39,12 @@ 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 })) + let ingestion = state + .watch_ingestion_status + .read() + .map(|g| g.clone()) + .unwrap_or_default(); + Json(serde_json::json!({ "status": status, "ingestion": ingestion })) } pub async fn version_handler() -> impl IntoResponse { @@ -51,7 +56,8 @@ "min_score": 0.3, "limit": 5, "run_on_login": false, - "monitor_paths": [] + "monitor_paths": [], + "watch_extensions": ["txt", "md", "json", "html", "css", "js", "mjs", "ts", "rs"] }) } @@ -78,11 +84,21 @@ .and_then(|v| v.as_array()) .cloned() .unwrap_or_else(|| vec![]); + let watch_extensions: Vec = obj.get("watch_extensions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_else(|| { + crate::mcp::watch::DEFAULT_WATCH_EXTENSIONS + .iter() + .map(|s| serde_json::Value::String((*s).to_string())) + .collect() + }); let merged = 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, - "monitor_paths": monitor_paths + "monitor_paths": monitor_paths, + "watch_extensions": watch_extensions }); log::info!("settings_get: returning run_on_login={}", run_on_login); Json(merged) @@ -107,13 +123,17 @@ 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 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() { + 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() { payload.clone() } else if let Some(inner) = payload.get("settings").and_then(|v| v.as_object()) { - serde_json::to_value(inner).unwrap_or(payload) + serde_json::to_value(inner).unwrap_or_else(|_| payload.clone()) } else { - payload + payload.clone() }; + // remove_from_index_paths は保存しない(解除時の一回限りの指示) + if let Some(obj) = to_write.as_object_mut() { + obj.remove("remove_from_index_paths"); + } let path = dir.join("settings.json"); let s = match serde_json::to_string_pretty(&to_write) { Ok(s) => s, @@ -127,6 +147,51 @@ 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")); + + // フォルダ監視: 保存した monitor_paths と watch_extensions をワッチャーに送り再起動(Phase 3) + if let Some(ref tx) = state.watcher_restart_tx { + let monitor_paths: Vec = to_write + .get("monitor_paths") + .and_then(|a| a.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(std::path::PathBuf::from)) + .collect() + }) + .unwrap_or_default(); + let watch_extensions: Vec = to_write + .get("watch_extensions") + .and_then(|a| a.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_else(|| { + crate::mcp::watch::DEFAULT_WATCH_EXTENSIONS + .iter() + .map(|s| s.to_string()) + .collect() + }); + let config = crate::mcp::types::WatcherConfig { + paths: monitor_paths.clone(), + extensions: watch_extensions, + }; + 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()); + } + } + + // モニター解除時に「インデックスからも削除」を選んだパスを処理 + 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), + } + } + } + } + (axum::http::StatusCode::OK, Json(serde_json::json!({"ok": true}))) } @@ -190,3 +255,41 @@ Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// 計画 auto_start: デフォルトは自動起動オフ(01 スコープ) + #[test] + fn settings_default_has_run_on_login_false() { + let d = settings_default(); + assert_eq!( + d.get("run_on_login").and_then(|v| v.as_bool()), + Some(false), + "初回・既存ユーザーアップデート後は自動起動はオフ" + ); + } + + /// 計画 folder_monitor: デフォルトは監視パスなし(01 スコープ) + #[test] + fn settings_default_has_monitor_paths_empty() { + let d = settings_default(); + let arr = d.get("monitor_paths").and_then(|v| v.as_array()); + assert!(arr.is_some(), "monitor_paths は配列"); + assert_eq!( + arr.unwrap().len(), + 0, + "初回・既存ユーザーアップデート後は監視パスは空" + ); + } + + /// 取込対象拡張子のデフォルトが設定されている + #[test] + fn settings_default_has_watch_extensions() { + let d = settings_default(); + let arr = d.get("watch_extensions").and_then(|v| v.as_array()); + assert!(arr.is_some(), "watch_extensions は配列"); + assert!(!arr.unwrap().is_empty(), "デフォルトで拡張子が入っている"); + } +} diff --git a/src/backend/src/mcp/mod.rs b/src/backend/src/mcp/mod.rs index 9edbb2e..a5d086d 100644 --- a/src/backend/src/mcp/mod.rs +++ b/src/backend/src/mcp/mod.rs @@ -2,6 +2,7 @@ pub mod handlers; pub mod system; pub mod tools; +pub mod watch; pub use types::AppState; use axum::{ @@ -41,6 +42,7 @@ model_name: String, edition: String, #[cfg(feature = "pro")] embedding_model_dir: Option, + watcher_restart_tx: Option, ) -> AppState { let (tx, _rx) = broadcast::channel(100); let tokenizer = Arc::new(crate::utils::tokenizer::JapaneseTokenizer::new().unwrap()); @@ -85,10 +87,13 @@ changes_since_train: Arc::new(AtomicU64::new(0)), retrain_scheduled: Arc::new(AtomicBool::new(false)), indexing_status: Arc::new(RwLock::new("idle".to_string())), + watch_ingestion_status: Arc::new(std::sync::RwLock::new(String::new())), + watcher_restart_tx, } } -/// 起動オーケストレーション: 状態構築 → LSA/HNSW 完了待ち → listen。 +/// 起動オーケストレーション: 状態構築 → 即 listen → バックグラウンドで LSA/HNSW 構築。 +/// GUI はすぐ 3001 に接続でき「LSA学習中」等を表示。検索は準備完了まで FTS のみ等で応答。 pub async fn run_server( port: u16, app_data_dir: std::path::PathBuf, @@ -100,14 +105,75 @@ let mcp_start = std::time::Instant::now(); log::info!("[BOOT] MCP: run_server started (port={})", port); + let (watcher_tx, watcher_rx) = std::sync::mpsc::channel(); #[cfg(feature = "community")] - let state = build_app_state(app_data_dir, db_pool, model_name, edition); + let state = build_app_state(app_data_dir.clone(), db_pool, model_name, edition, Some(watcher_tx)); #[cfg(feature = "pro")] - let state = build_app_state(app_data_dir, db_pool, model_name, edition, embedding_model_dir); + let state = build_app_state(app_data_dir.clone(), db_pool, model_name, edition, embedding_model_dir, Some(watcher_tx)); - // 近似近傍検索を使うため、listen 前にインデックス構築を完了させる(spawn だと検索時に HNSW が空で 0.4 固定になる) - system::train_lsa_and_sync_hnsw(state.clone()).await; - log::info!("[BOOT] MCP: LSA/HNSW index ready ({}ms)", mcp_start.elapsed().as_millis()); + { + let mut st = state.indexing_status.write().await; + *st = "training".to_string(); + } + let _ = state.tx.send("indexing:training".to_string()); + + let watcher_config: crate::mcp::types::WatcherConfig = { + let path = app_data_dir.join("settings.json"); + let (monitor_paths, extensions) = if path.exists() { + if let Ok(s) = std::fs::read_to_string(&path) { + if let Ok(v) = serde_json::from_str::(&s) { + let paths: Vec = v + .get("monitor_paths") + .and_then(|a| a.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(std::path::PathBuf::from)) + .collect() + }) + .unwrap_or_default(); + let exts: Vec = v + .get("watch_extensions") + .and_then(|a| a.as_array()) + .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_else(|| { + watch::DEFAULT_WATCH_EXTENSIONS + .iter() + .map(|s| s.to_string()) + .collect() + }); + (paths, exts) + } else { + (vec![], watch::DEFAULT_WATCH_EXTENSIONS.iter().map(|s| s.to_string()).collect()) + } + } else { + (vec![], watch::DEFAULT_WATCH_EXTENSIONS.iter().map(|s| s.to_string()).collect()) + } + } else { + (vec![], watch::DEFAULT_WATCH_EXTENSIONS.iter().map(|s| s.to_string()).collect()) + }; + crate::mcp::types::WatcherConfig { paths: monitor_paths, extensions } + }; + if let Some(ref tx) = state.watcher_restart_tx { + let _ = tx.send(watcher_config); + } + let state_arc = std::sync::Arc::new(state.clone()); + let runtime_handle = tokio::runtime::Handle::current(); + watch::spawn_folder_watcher(runtime_handle, state_arc, watcher_rx); + + let skip_boot_train = std::env::var_os("TELOS_SKIP_BOOT_TRAIN") + .map(|v| v == "1" || v == "true" || v == "yes") + .unwrap_or(false); + if skip_boot_train { + log::info!("[BOOT] TELOS_SKIP_BOOT_TRAIN=1: skipping LSA/HNSW on startup (use RE-INDEX when needed)"); + *state.indexing_status.write().await = "idle".to_string(); + let _ = state.tx.send("indexing:idle".to_string()); + } else { + let state_for_train = state.clone(); + tokio::task::spawn(async move { + system::train_lsa_and_sync_hnsw(state_for_train).await; + log::info!("[BOOT] MCP: LSA/HNSW background task finished"); + }); + } let app = create_mcp_app(state); let bind_addr = format!("127.0.0.1:{}", port); diff --git a/src/backend/src/mcp/system.rs b/src/backend/src/mcp/system.rs index 58a58e5..e1d1772 100644 --- a/src/backend/src/mcp/system.rs +++ b/src/backend/src/mcp/system.rs @@ -187,7 +187,7 @@ log::info!("[BOOT] LSA: training in blocking thread..."); let state_inner = state.clone(); - let result = tokio::task::spawn_blocking(move || { + let join_result = tokio::task::spawn_blocking(move || { let mut builder = crate::utils::lsa::TermDocumentMatrixBuilder::new(); for row in rows { let content: String = row.get(0); @@ -196,7 +196,17 @@ } let (matrix, idfs) = builder.build_matrix(); LsaModel::train(&matrix, builder.vocabulary, idfs, 50).map(|m| (m, builder.counts.len())) - }).await.unwrap(); + }).await; + + let result = match join_result { + Ok(r) => r, + Err(e) => { + log::error!("[BOOT] LSA: training task panicked: {} ({}ms)", e, boot_start.elapsed().as_millis()); + *state.indexing_status.write().await = "idle".to_string(); + let _ = state.tx.send("indexing:idle".to_string()); + return; + } + }; match result { Ok((model, doc_count)) => { @@ -209,7 +219,7 @@ log::info!("[BOOT] HNSW: building 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(); @@ -278,7 +288,7 @@ let model_inner = model.clone(); let state_inner = state.clone(); - let synced_data = tokio::task::spawn_blocking(move || { + let synced_data = match tokio::task::spawn_blocking(move || { let mut results = Vec::new(); for (id, content) in to_sync { let mut query_counts = HashMap::new(); @@ -300,7 +310,13 @@ } } results - }).await.unwrap(); + }).await { + Ok(data) => data, + Err(e) => { + log::error!("[BOOT] HNSW: sync vector computation task panicked: {}", e); + return; + } + }; let mut count = 0; for (id, proj_f32) in synced_data { diff --git a/src/backend/src/mcp/tools/items.rs b/src/backend/src/mcp/tools/items.rs index 7af23c8..834d8c1 100644 --- a/src/backend/src/mcp/tools/items.rs +++ b/src/backend/src/mcp/tools/items.rs @@ -28,12 +28,14 @@ args: &serde_json::Map, ) -> Option { let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); - let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or("unknown"); + let path_str = crate::db::normalize_document_path( + args.get("path").and_then(|v| v.as_str()).unwrap_or("unknown"), + ); let mut mime_str = args.get("mime").and_then(|v| v.as_str()).map(|s| s.to_string()); // MIMEタイプが未指定なら拡張子から推測 if mime_str.is_none() && path_str != "unknown" { - let path = Path::new(path_str); + let path = Path::new(&path_str); if let Some(ext) = path.extension().and_then(|e| e.to_str()) { mime_str = Some(match ext.to_lowercase().as_str() { "md" | "markdown" => "text/markdown".to_string(), @@ -52,7 +54,7 @@ log::info!( "Executing add_item_text (LSA-only): content length={}, path='{}', mime='{:?}'", content.chars().count(), - path_str, + &path_str, mime_str ); @@ -66,7 +68,7 @@ let mut results = Vec::new(); // 1. ドキュメントレコードの取得または作成 - let doc_id_res = match crate::db::get_document_id_by_path(&state.db_pool, path_str).await { + let doc_id_res = match crate::db::get_document_id_by_path(&state.db_pool, &path_str).await { Ok(Some(id)) => { if let Some(m) = &mime_str { let _ = sqlx::query("UPDATE documents SET mime = ? WHERE id = ? AND (mime IS NULL OR mime != ?)") @@ -80,7 +82,7 @@ }, Ok(None) => { match sqlx::query("INSERT INTO documents (path, mime) VALUES (?, ?)") - .bind(path_str) + .bind(&path_str) .bind(mime_str) .execute(&state.db_pool) .await @@ -583,3 +585,69 @@ "content": [{ "type": "text", "text": format!("Successfully deleted document {} ({} chunks)", doc_id, item_ids.len()) }] })) } + +// --------------------------------------------------------------------------- +// フォルダ監視用: ファイルパスから取り込み・パス指定削除(計画 folder_monitor) +// --------------------------------------------------------------------------- + +/// 指定パスのファイルを UTF-8 テキストとして読み、add_item_text と同様に DB に取り込む。 +pub async fn ingest_file_path(state: &AppState, path: &Path) -> Result<(), String> { + let path_str = path.to_string_lossy().to_string(); + let content = std::fs::read_to_string(path).map_err(|e| format!("read file: {}", e))?; + let mut args = serde_json::Map::new(); + args.insert("content".to_string(), serde_json::Value::String(content)); + args.insert("path".to_string(), serde_json::Value::String(path_str.clone())); + match handle_add_item_text(state, &args).await { + Some(v) if v.get("isError").and_then(|x| x.as_bool()).unwrap_or(false) => { + let msg = v + .get("content") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + .and_then(|o| o.get("text")) + .and_then(|t| t.as_str()) + .unwrap_or("unknown error"); + Err(msg.to_string()) + } + Some(_) => Ok(()), + None => Err("add_item_text returned None".to_string()), + } +} + +/// パスに紐づくドキュメントを削除する。存在しなければ Ok(())。 +pub async fn delete_document_by_path(state: &AppState, path: &str) -> Result<(), String> { + let doc_id = match crate::db::get_document_id_by_path(&state.db_pool, path).await? { + Some(id) => id, + None => return Ok(()), + }; + let mut args = serde_json::Map::new(); + args.insert("document_id".to_string(), serde_json::Value::Number(serde_json::Number::from(doc_id))); + if let Some(v) = handle_delete_document(state, &args).await { + if v.get("isError").and_then(|x| x.as_bool()).unwrap_or(false) { + let msg = v + .get("content") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + .and_then(|o| o.get("text")) + .and_then(|t| t.as_str()) + .unwrap_or("unknown error"); + return Err(msg.to_string()); + } + } + Ok(()) +} + +/// 指定ディレクトリ配下のドキュメントをすべてインデックスから削除する(モニター解除時に「インデックスからも削除」で使用)。 +pub async fn delete_documents_under_path_prefix(state: &AppState, path_prefix: &str) -> Result { + let doc_ids = crate::db::get_document_ids_under_path_prefix(&state.db_pool, path_prefix).await?; + let mut count = 0u32; + for doc_id in doc_ids { + let mut args = serde_json::Map::new(); + args.insert("document_id".to_string(), serde_json::Value::Number(serde_json::Number::from(doc_id))); + if let Some(v) = handle_delete_document(state, &args).await { + if !v.get("isError").and_then(|x| x.as_bool()).unwrap_or(false) { + count += 1; + } + } + } + Ok(count) +} diff --git a/src/backend/src/mcp/types.rs b/src/backend/src/mcp/types.rs index 94bacd5..ed097bf 100644 --- a/src/backend/src/mcp/types.rs +++ b/src/backend/src/mcp/types.rs @@ -9,6 +9,16 @@ use std::sync::Arc; use tokio::sync::{broadcast, mpsc, RwLock}; +/// フォルダ監視の設定(パス一覧と取込対象拡張子)。設定保存時にワッチャーへ送る(計画 folder_monitor)。 +#[derive(Clone, Default)] +pub struct WatcherConfig { + pub paths: Vec, + pub extensions: Vec, +} + +/// 設定保存時にワッチャーを再起動する用(Phase 3) +pub type WatcherRestartSender = std::sync::mpsc::Sender; + #[derive(Clone)] pub struct AppState { pub app_data_dir: PathBuf, @@ -35,6 +45,10 @@ pub retrain_scheduled: Arc, /// インデックス化の状態(idle / training / syncing)。GUI 表示用。 pub indexing_status: Arc>, + /// フォルダ監視の取込/削除中の表示用(例: "取込中: file.txt")。同期スレッドから更新するため std::sync::RwLock。 + pub watch_ingestion_status: Arc>, + /// 設定保存時にモニター先パスを送るとワッチャーが再起動する(Phase 3) + pub watcher_restart_tx: Option, } #[derive(Serialize, Deserialize)] diff --git a/src/backend/src/mcp/watch.rs b/src/backend/src/mcp/watch.rs new file mode 100644 index 0000000..4458051 --- /dev/null +++ b/src/backend/src/mcp/watch.rs @@ -0,0 +1,217 @@ +//! フォルダ監視(計画 folder_monitor)。notify + debouncer で Create/Modify/Remove を検知し DB に反映する。 +//! debouncer-mini は Any/AnyContinuous のみ区別するため、パスがファイルとして存在すれば取り込み、存在しなければインデックスから削除する。 +//! モニター追加時は既存ファイルを走査し、未インデックスのものだけ取り込む(初期スキャン)。 + +use crate::db; +use crate::mcp::tools::items::{delete_document_by_path, ingest_file_path}; +use crate::mcp::types::{AppState, WatcherConfig}; +use notify_debouncer_mini::{new_debouncer, DebounceEventResult}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::runtime::Handle; + +/// 取込対象拡張子のデフォルト(設定未指定時) +pub const DEFAULT_WATCH_EXTENSIONS: &[&str] = &["txt", "md", "json", "html", "css", "js", "mjs", "ts", "rs"]; + +fn is_watched_file(path: &Path, extensions: &[String]) -> bool { + if extensions.is_empty() { + return false; + } + path.extension() + .and_then(|e| e.to_str()) + .map(|ext| extensions.iter().any(|e| e.as_str().eq_ignore_ascii_case(ext))) + .unwrap_or(false) +} + +fn process_event(state: &AppState, path: &Path, handle: &Handle, extensions: &[String]) { + if !is_watched_file(path, extensions) { + return; + } + let path_str = path.to_string_lossy().to_string(); + let display_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path_str.clone()); + if path.is_file() { + if let Ok(mut guard) = state.watch_ingestion_status.write() { + *guard = format!("取込中: {}", display_name); + } + if let Err(e) = handle.block_on(ingest_file_path(state, path)) { + log::warn!("[watch] ingest {:?}: {}", path, e); + } else { + log::info!("[watch] ingested: {}", path_str); + } + if let Ok(mut guard) = state.watch_ingestion_status.write() { + guard.clear(); + } + } else { + if let Ok(mut guard) = state.watch_ingestion_status.write() { + *guard = format!("削除中: {}", display_name); + } + if let Err(e) = handle.block_on(delete_document_by_path(state, &path_str)) { + log::warn!("[watch] delete {:?}: {}", path, e); + } else { + log::info!("[watch] deleted from index: {}", path_str); + } + if let Ok(mut guard) = state.watch_ingestion_status.write() { + guard.clear(); + } + } +} + +/// 指定ディレクトリを再帰走査し、対象拡張子のファイルのうち未インデックスのものだけ取り込む(モニター追加時の初期スキャン)。 +fn initial_scan_directory(state: &AppState, dir: &Path, extensions: &[String], handle: &Handle) { + let read_dir = match std::fs::read_dir(dir) { + Ok(r) => r, + Err(e) => { + log::warn!("[watch] initial_scan read_dir {:?}: {}", dir, e); + return; + } + }; + for entry in read_dir.flatten() { + let path: PathBuf = entry.path(); + if path.is_dir() { + initial_scan_directory(state, &path, extensions, handle); + } else if path.is_file() && is_watched_file(&path, extensions) { + let path_str = path.to_string_lossy().to_string(); + let display_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path_str.clone()); + let already = handle.block_on(db::get_document_id_by_path(&state.db_pool, &path_str)); + match already { + Ok(Some(_)) => { /* 既にインデックス済み */ } + Ok(None) => { + if let Ok(mut guard) = state.watch_ingestion_status.write() { + *guard = format!("取込中: {}", display_name); + } + if let Err(e) = handle.block_on(ingest_file_path(state, &path)) { + log::warn!("[watch] initial_scan ingest {:?}: {}", path, e); + } else { + log::info!("[watch] initial_scan ingested: {}", path_str); + } + if let Ok(mut guard) = state.watch_ingestion_status.write() { + guard.clear(); + } + } + Err(e) => log::warn!("[watch] initial_scan get_doc_id {:?}: {}", path, e), + } + } + } +} + +/// 指定ディレクトリを再帰監視し、イベントを DB に反映する。別スレッドでデバウンスを動かす。 +/// `handle`: Tokio ランタイムの Handle(std スレッド内では Handle::current() が使えないため呼び出し元から渡す)。 +/// `rx` から WatcherConfig(パス一覧・取込対象拡張子)を受け取り監視を開始。設定保存で新しい config が送られると再起動する(Phase 3)。 +pub fn spawn_folder_watcher(handle: Handle, state: Arc, rx: std::sync::mpsc::Receiver) { + use std::sync::mpsc::RecvTimeoutError; + std::thread::spawn(move || { + let state = state.clone(); + let mut config = match rx.recv() { + Ok(c) => c, + Err(_) => return, + }; + loop { + if config.paths.is_empty() { + log::info!("[watch] no paths, waiting for next config"); + match rx.recv() { + Ok(c) => config = c, + Err(_) => break, + } + continue; + } + let extensions = if config.extensions.is_empty() { + DEFAULT_WATCH_EXTENSIONS.iter().map(|s| s.to_string()).collect::>() + } else { + config.extensions.clone() + }; + let state_for_debouncer = state.clone(); + let handle_for_cb = handle.clone(); + let extensions_for_cb = extensions.clone(); + let mut debouncer = match new_debouncer(Duration::from_secs(2), move |res: DebounceEventResult| { + match res { + Ok(events) => { + for e in events { + process_event(state_for_debouncer.as_ref(), &e.path, &handle_for_cb, &extensions_for_cb); + } + } + Err(e) => { + log::warn!("[watch] debouncer error: {:?}", e); + } + } + }) { + Ok(d) => d, + Err(e) => { + log::error!("[watch] failed to create debouncer: {}", e); + match rx.recv() { + Ok(c) => config = c, + Err(_) => break, + } + continue; + } + }; + + for path in &config.paths { + if path.is_dir() { + if let Err(e) = debouncer.watcher().watch(path, notify::RecursiveMode::Recursive) { + log::warn!("[watch] watch {:?}: {}", path, e); + } else { + log::info!("[watch] watching: {}", path.display()); + } + } else { + log::warn!("[watch] skip (not a directory): {}", path.display()); + } + } + + // モニター追加時: 既存ファイルのうち未インデックスのものを取り込む + for path in &config.paths { + if path.is_dir() { + log::info!("[watch] initial_scan: {}", path.display()); + initial_scan_directory(state.as_ref(), path, &extensions, &handle); + } + } + + // 設定保存で新しい config が送られるまで待つ(recv_timeout で 1 秒ごとにチェック) + loop { + match rx.recv_timeout(Duration::from_secs(1)) { + Ok(new_config) => { + config = new_config; + log::info!("[watch] restarting with new config"); + break; + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => return, + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn is_watched_file_matches_configured_extensions() { + let exts = vec!["txt".to_string(), "md".to_string()]; + assert!(is_watched_file(Path::new("/a/b/c.txt"), &exts)); + assert!(is_watched_file(Path::new("file.md"), &exts)); + assert!(!is_watched_file(Path::new("/a/b/c.rs"), &exts)); + assert!(!is_watched_file(Path::new("/a/b/noext"), &exts)); + } + + #[test] + fn is_watched_file_extension_case_insensitive() { + let exts = vec!["TXT".to_string()]; + assert!(is_watched_file(Path::new("a.TXT"), &exts)); + assert!(is_watched_file(Path::new("a.txt"), &exts)); + } + + #[test] + fn is_watched_file_empty_extensions_returns_false() { + let exts: Vec = vec![]; + assert!(!is_watched_file(Path::new("/a/b/c.txt"), &exts)); + } +} diff --git a/src/frontend/components/main-panel.js b/src/frontend/components/main-panel.js index a5ae9ba..b2df1f2 100644 --- a/src/frontend/components/main-panel.js +++ b/src/frontend/components/main-panel.js @@ -87,11 +87,33 @@ -
- - + +
+ ログイン時自動起動 +
+ + + OS にサインインしたときに TelosDB を自動で起動します
+
+ モニター先フォルダ +
+
+ +
+
+ +
+ + カンマ区切り(例: txt, md, json)。空の場合は既定の拡張子を使用 +
+
+
+
+ + +
`; @@ -104,7 +126,9 @@ // Expose showPanel as method const SETTINGS_KEY = 'telosdb_settings'; - const DEFAULTS = { min_score: 0.3, limit: 5, run_on_login: false, monitor_paths: [] }; + 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'); @@ -139,6 +163,8 @@ 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'); @@ -147,6 +173,8 @@ 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(', '); } }; @@ -163,6 +191,7 @@ 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 (_) { @@ -177,6 +206,7 @@ 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 (_) {} @@ -185,16 +215,30 @@ }; 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: payload }); + 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(payload), + body: JSON.stringify(toPersist), }); return res.ok; } catch (_) { @@ -277,6 +321,11 @@ const monitor_paths = Array.from(pathInputs) .map((el) => (el.value || '').trim()) .filter(Boolean); + const extInput = this.querySelector('#setting-watch-extensions'); + const watch_extensions = (extInput?.value || '') + .split(/[\s,]+/) + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); try { // Tauri autostart(OSログイン時自動起動)を反映 try { @@ -289,11 +338,14 @@ } catch (_) { // ブラウザなど Tauri 外では無視 } - const payload = { min_score, limit, run_on_login, monitor_paths }; - console.log('[TelosDB] 保存: run_on_login =', run_on_login, 'monitor_paths =', monitor_paths.length); + 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) { - localStorage.setItem(SETTINGS_KEY, JSON.stringify(payload)); + 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; @@ -329,7 +381,14 @@ if (e.target.classList.contains('setting-monitor-path-remove')) { e.preventDefault(); const row = e.target.closest('.setting-monitor-path-row'); - if (row) row.remove(); + 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(); } }); diff --git a/src/frontend/components/site-footer.js b/src/frontend/components/site-footer.js index a95578e..83b35f2 100644 --- a/src/frontend/components/site-footer.js +++ b/src/frontend/components/site-footer.js @@ -10,7 +10,8 @@ this.innerHTML = ` `; this._fetchVersion(); diff --git a/src/frontend/index.html b/src/frontend/index.html index 2cb6df6..f77ba49 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -53,6 +53,10 @@ const API_BASE = "http://127.0.0.1:3001"; let currentEdition = "community"; + /** バックエンド未応答が続いた回数。成功時に 0 にリセット。起動直後は「接続待ち」表示のため Err にしない。 */ + let connectionFailCount = 0; + /** この回数失敗するまでは「接続待ち…」表示。超過で「Err」表示(MCP 起動に数十秒かかることがあるため)。 */ + const CONNECTION_GRACE_ATTEMPTS = 12; function setEditionBadge(edition) { const el = document.getElementById("edition-badge"); @@ -65,6 +69,7 @@ try { const res = await fetch(`${API_BASE}/edition`); const data = await res.json(); + connectionFailCount = 0; currentEdition = data.edition || "community"; setEditionBadge(currentEdition); const badge = document.getElementById("indexing-status-badge"); @@ -87,6 +92,7 @@ try { const res = await fetch(`${API_BASE}/indexing_status`); const data = await res.json(); + connectionFailCount = 0; const status = (data.status || "idle").toLowerCase(); if (status === "idle") { @@ -100,8 +106,10 @@ if (textTop) textTop.textContent = getIdleSearchLabel(); } } catch { - if (dotTop) dotTop.style.background = "#ef4444"; - if (textTop) textTop.textContent = "Err"; + connectionFailCount += 1; + const showErr = connectionFailCount > CONNECTION_GRACE_ATTEMPTS; + if (dotTop) dotTop.style.background = showErr ? "#ef4444" : "#f59e0b"; + if (textTop) textTop.textContent = showErr ? "Err" : "接続待ち…"; } } @@ -133,6 +141,7 @@ try { const res = await fetch(`${API_BASE}/model_name`); const data = await res.json(); + connectionFailCount = 0; showConnectionErrorHint(false); const modelTop = document.getElementById("model-name-top"); if (modelTop) modelTop.textContent = data.model_name || "不明"; @@ -166,9 +175,14 @@ try { const res = await fetch(`${API_BASE}/indexing_status`); const data = await res.json(); + connectionFailCount = 0; setIndexingStatus(data.status || "idle"); + const footerIngestion = document.getElementById("footer-ingestion-status"); + if (footerIngestion) footerIngestion.textContent = data.ingestion || ""; } catch { setIndexingStatus("idle"); + const footerIngestion = document.getElementById("footer-ingestion-status"); + if (footerIngestion) footerIngestion.textContent = ""; } } @@ -352,7 +366,8 @@ updateIndexingStatus(); })(); setInterval(updateSearchStatus, 3000); - setInterval(updateIndexingStatus, 3000); + // indexing 中は早めに idle を反映するため 1.5 秒ごとにポール + setInterval(updateIndexingStatus, 1500); // MCP は起動に 15〜20 秒かかることがあるため、接続エラー時も定期的にリトライする setInterval(updateModelName, 3000); setInterval(updateDocCount, 5000); diff --git a/src/frontend/js/connection-status.js b/src/frontend/js/connection-status.js new file mode 100644 index 0000000..5dfc955 --- /dev/null +++ b/src/frontend/js/connection-status.js @@ -0,0 +1,20 @@ +/** + * バックエンド接続状態の表示ロジック(Err / 接続待ち の切り替え)。 + * MCP 起動に数十秒かかることがあるため、一定回数までは「接続待ち…」、超過で「Err」とする。 + */ + +/** この回数まで失敗しても「接続待ち…」。超過で「Err」表示。 */ +export const CONNECTION_GRACE_ATTEMPTS = 12; + +/** + * @param {number} failCount - 連続失敗回数 + * @returns {"接続待ち…" | "Err"} + */ +export function getConnectionStatusLabel(failCount) { + return failCount > CONNECTION_GRACE_ATTEMPTS ? "Err" : "接続待ち…"; +} + +if (typeof window !== "undefined") { + window.CONNECTION_GRACE_ATTEMPTS = CONNECTION_GRACE_ATTEMPTS; + window.getConnectionStatusLabel = getConnectionStatusLabel; +} diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 98b680b..8040765 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -381,6 +381,33 @@ .setting-monitor-path-row .setting-monitor-path-remove { flex-shrink: 0; } +.setting-row-watch-extensions { + align-items: flex-start; +} +.setting-row-watch-extensions .setting-watch-extensions-cell { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + max-width: 100%; +} +.setting-watch-extensions { + width: 100%; + min-width: 24em; + max-width: 100%; + padding: 6px 10px; + background: var(--bg-surface); + border: 1px solid var(--border-base); + border-radius: 4px; + color: var(--text-primary); + font-size: 0.9rem; + box-sizing: border-box; +} +.setting-hint-block { + display: block; + margin-top: 0; +} /* 文書管理パネル */ .docs-toolbar { @@ -735,10 +762,18 @@ width: 100%; display: flex; justify-content: space-between; + align-items: center; font-size: 0.75rem; color: var(--text-dim); } +.footer-ingestion-status { + flex: 1; + text-align: center; + min-height: 1em; + color: var(--text-secondary, var(--text-dim)); +} + /* Buttons and UI */ .actions { flex-shrink: 0; diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 96cc767..61ad527 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -53,6 +53,8 @@ - `tests/e2e/specs/app.spec.js` … ウィンドウタイトル、ヘッダー、検索 UI の表示と検索実行の確認。 - `tests/e2e/specs/docs-edit-drawing.spec.js` … 文書編集モーダル・ツールバーの描画確認。 - `tests/e2e/specs/screenshot-docs-modal.spec.js` … 編集モーダルのスクリーンショット取得(`npm run test:e2e:screenshot`)。 +- `tests/e2e/specs/settings-autostart.spec.js` … 設定パネル「ログイン時自動起動」の表示・トグル・保存・永続化(計画 auto_start)。 +- `tests/e2e/specs/settings-folder-monitor.spec.js` … 設定パネル「モニター先フォルダ」の表示・追加・保存・永続化・削除。挙動として「監視フォルダに .txt を追加すると取り込まれ検索でヒットする」を検証(計画 folder_monitor)。 ## 検索の可否をログで確認する diff --git a/tests/e2e/helpers/wait-for-app.js b/tests/e2e/helpers/wait-for-app.js index 128039f..12ade67 100644 --- a/tests/e2e/helpers/wait-for-app.js +++ b/tests/e2e/helpers/wait-for-app.js @@ -8,19 +8,28 @@ * @param {{ uiTimeout?: number, mcpTimeout?: number, mcpInterval?: number }} [opts] */ export async function waitForAppReady(browser, opts = {}) { - const uiTimeout = opts.uiTimeout ?? 20000; - const mcpMaxAttempts = Math.floor((opts.mcpTimeout ?? 20000) / (opts.mcpInterval ?? 500)); + const uiTimeout = opts.uiTimeout ?? 25000; + const mcpMaxAttempts = Math.floor((opts.mcpTimeout ?? 90000) / (opts.mcpInterval ?? 500)); const mcpIntervalMs = opts.mcpInterval ?? 500; const query = await browser.$('#query'); await query.waitForDisplayed({ timeout: uiTimeout }); + const fetchTimeoutMs = 8000; for (let i = 0; i < mcpMaxAttempts; i++) { - const ok = await browser.executeAsync(function (done) { + const ok = await browser.executeAsync(function (timeoutMs, done) { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + done(result); + }; + const t = setTimeout(() => finish(false), timeoutMs); fetch('http://127.0.0.1:3001/edition') - .then((r) => done(r.ok)) - .catch(() => done(false)); - }); + .then((r) => finish(r.ok)) + .catch(() => finish(false)) + .finally(() => clearTimeout(t)); + }, fetchTimeoutMs); if (ok) return; await browser.pause(mcpIntervalMs); } diff --git a/tests/e2e/screenshots/docs-edit-modal.png b/tests/e2e/screenshots/docs-edit-modal.png index d82c10a..40e2d6a 100644 --- a/tests/e2e/screenshots/docs-edit-modal.png +++ b/tests/e2e/screenshots/docs-edit-modal.png Binary files differ diff --git a/tests/e2e/specs/app.spec.js b/tests/e2e/specs/app.spec.js index 82da144..b4d324f 100644 --- a/tests/e2e/specs/app.spec.js +++ b/tests/e2e/specs/app.spec.js @@ -48,7 +48,7 @@ describe('TelosDB', () => { before(async function () { - this.timeout(45000); + this.timeout(120000); await waitForAppReady(browser); }); @@ -83,6 +83,10 @@ it('フッターにバージョンが表示される', async () => { const versionEl = await $('.site-footer .footer-version'); await expect(versionEl).toBeDisplayed(); + await browser.waitUntil( + async () => /^v[\d.]+/.test(await versionEl.getText()), + { timeout: 10000, interval: 500 } + ); const text = await versionEl.getText(); expect(text).toMatch(/^v[\d.]+/); }); @@ -98,6 +102,20 @@ expect(text).toBe(`v${EXPECTED_APP_VERSION}`); }); + it('MCP 接続後、ヘッダーのステータスが接続済み(Err・接続待ちでない)である', async () => { + const statusText = await $('#llama-status-text-top'); + const modelName = await $('#model-name-top'); + await expect(statusText).toBeDisplayed(); + await expect(modelName).toBeDisplayed(); + const text = await statusText.getText(); + const model = await modelName.getText(); + expect(text).not.toBe('Err'); + expect(text).not.toBe('接続待ち…'); + expect(model).not.toBe('接続を待っています…'); + const allowedStatuses = ['LSA Search', 'Pro 検索', 'Indexing...']; + expect(allowedStatuses).toContain(text); + }); + it('検索パネル初期表示で結果エリアに empty-state が表示される', async () => { const resultPanel = await $('#result'); await expect(resultPanel).toBeDisplayed(); diff --git a/tests/e2e/specs/docs-edit-drawing.spec.js b/tests/e2e/specs/docs-edit-drawing.spec.js index a65e3dc..ecfc138 100644 --- a/tests/e2e/specs/docs-edit-drawing.spec.js +++ b/tests/e2e/specs/docs-edit-drawing.spec.js @@ -29,7 +29,7 @@ }; before(async function () { - this.timeout(45000); + this.timeout(120000); await waitForAppReady(browser); await openDocsPanel(); await openEditModal(); diff --git a/tests/e2e/specs/panels.spec.js b/tests/e2e/specs/panels.spec.js index ec7c38e..da187be 100644 --- a/tests/e2e/specs/panels.spec.js +++ b/tests/e2e/specs/panels.spec.js @@ -6,7 +6,7 @@ describe('パネル表示', () => { before(async function () { - this.timeout(45000); + this.timeout(120000); await waitForAppReady(browser); }); diff --git a/tests/e2e/specs/screenshot-docs-modal.spec.js b/tests/e2e/specs/screenshot-docs-modal.spec.js index 813dbe8..92d93f5 100644 --- a/tests/e2e/specs/screenshot-docs-modal.spec.js +++ b/tests/e2e/specs/screenshot-docs-modal.spec.js @@ -17,7 +17,7 @@ describe('編集モーダル スクリーンショット取得', () => { before(async function () { - this.timeout(45000); + this.timeout(120000); await waitForAppReady(browser); const navDocs = await $('button[data-panel="docs"]'); await navDocs.click(); @@ -33,7 +33,11 @@ it('スクリーンショットを保存する', async () => { fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); - await browser.saveScreenshot(SCREENSHOT_PATH); + const raw = await browser.takeScreenshot(); + const base64 = typeof raw === 'string' ? raw : (raw && raw.value) || ''; + const buf = Buffer.from(base64, 'base64'); + fs.writeFileSync(SCREENSHOT_PATH, buf); console.log('[screenshot] 保存先:', SCREENSHOT_PATH); + expect(buf.length).toBeGreaterThanOrEqual(1); }); }); diff --git a/tests/e2e/specs/settings-autostart.spec.js b/tests/e2e/specs/settings-autostart.spec.js new file mode 100644 index 0000000..3410468 --- /dev/null +++ b/tests/e2e/specs/settings-autostart.spec.js @@ -0,0 +1,74 @@ +/** + * 設定パネル「ログイン時自動起動」の E2E テスト(計画 auto_start に基づく)。 + * テストファースト: 表示・トグル・保存・永続化を検証する。 + * + * 実行: npm run test:e2e + * このスペックのみ: npx wdio run wdio.conf.js --spec tests/e2e/specs/settings-autostart.spec.js + */ + +import { waitForAppReady } from '../helpers/wait-for-app.js'; + +const openSettingsPanel = async () => { + const navSettings = await $('button[data-panel="settings"]'); + await navSettings.click(); + const panel = await $('#panel-settings'); + await panel.waitForDisplayed({ timeout: 5000 }); + await expect(panel).not.toHaveElementClass('hidden'); +}; + +describe('設定パネル ログイン時自動起動', () => { + before(async function () { + this.timeout(120000); + await waitForAppReady(browser); + }); + + it('設定パネルに「ログイン時自動起動」の枠とチェックボックスが表示される', async () => { + await openSettingsPanel(); + const legends = await $$('legend'); + const autostartLegend = await Promise.all(Array.from(legends).map(async (el) => ({ el, text: await el.getText() }))).then((arr) => arr.find((x) => x.text === 'ログイン時自動起動')); + expect(autostartLegend).toBeDefined(); + await expect(autostartLegend.el).toBeDisplayed(); + const checkbox = await $('#setting-run-on-login'); + await expect(checkbox).toBeDisplayed(); + }); + + it('ログイン時自動起動をオンにして保存すると「保存しました」が表示される', async () => { + await openSettingsPanel(); + const checkbox = await $('#setting-run-on-login'); + const saveBtn = await $('#settings-save-btn'); + await expect(checkbox).toBeDisplayed(); + await expect(saveBtn).toBeDisplayed(); + if (!(await checkbox.isSelected())) await checkbox.click(); + await saveBtn.click(); + const feedback = await $('#settings-feedback'); + await expect(feedback).toHaveText('保存しました'); + }); + + it('オンで保存後、いったん検索パネルに切り替えてから設定を再表示するとトグルがオンのままである', async () => { + await openSettingsPanel(); + const checkbox = await $('#setting-run-on-login'); + if (!(await checkbox.isSelected())) await checkbox.click(); + await (await $('#settings-save-btn')).click(); + await browser.pause(500); + await (await $('button[data-panel="search"]')).click(); + await browser.pause(300); + await openSettingsPanel(); + const checkboxAgain = await $('#setting-run-on-login'); + await expect(checkboxAgain).toBeDisplayed(); + await expect(checkboxAgain).toBeSelected(); + }); + + it('オフで保存後、いったん検索パネルに切り替えてから設定を再表示するとトグルがオフのままである', async () => { + await openSettingsPanel(); + const checkbox = await $('#setting-run-on-login'); + if (await checkbox.isSelected()) await checkbox.click(); + await (await $('#settings-save-btn')).click(); + await browser.pause(500); + await (await $('button[data-panel="search"]')).click(); + await browser.pause(300); + await openSettingsPanel(); + const checkboxAgain = await $('#setting-run-on-login'); + await expect(checkboxAgain).toBeDisplayed(); + await expect(checkboxAgain).not.toBeSelected(); + }); +}); diff --git a/tests/e2e/specs/settings-folder-monitor.spec.js b/tests/e2e/specs/settings-folder-monitor.spec.js new file mode 100644 index 0000000..52ec60c --- /dev/null +++ b/tests/e2e/specs/settings-folder-monitor.spec.js @@ -0,0 +1,135 @@ +/** + * 設定パネル「モニター先フォルダ」の E2E テスト(計画 folder_monitor に基づく)。 + * - UI・保存・永続化・削除の検証 + * - 挙動: 監視フォルダにファイルを追加すると取り込まれ検索でヒットする + * + * 実行: npm run test:e2e + * このスペックのみ: npx wdio run wdio.conf.js --spec tests/e2e/specs/settings-folder-monitor.spec.js + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { waitForAppReady } from '../helpers/wait-for-app.js'; + +const openSettingsPanel = async () => { + const navSettings = await $('button[data-panel="settings"]'); + await navSettings.click(); + const panel = await $('#panel-settings'); + await panel.waitForDisplayed({ timeout: 5000 }); + await expect(panel).not.toHaveElementClass('hidden'); +}; + +describe('設定パネル モニター先フォルダ', () => { + before(async function () { + this.timeout(120000); + await waitForAppReady(browser); + }); + + it('設定パネルにモニター先フォルダの追加ボタンとリストが表示される', async () => { + await openSettingsPanel(); + const addBtn = await $('#settings-monitor-path-add'); + await expect(addBtn).toBeDisplayed(); + const list = await $('#settings-monitor-paths-list'); + await expect(list).toBeDisplayed(); + }); + + it('追加ボタンで1行追加しパスを入力して保存すると「保存しました」が表示される', async () => { + await openSettingsPanel(); + const addBtn = await $('#settings-monitor-path-add'); + await addBtn.click(); + const pathInput = await $('.setting-monitor-path'); + await pathInput.waitForDisplayed({ timeout: 3000 }); + await pathInput.setValue('C:\\Test\\Watch'); + await (await $('#settings-save-btn')).click(); + const feedback = await $('#settings-feedback'); + await expect(feedback).toHaveText('保存しました'); + }); + + it('パスを1件保存後、いったん検索パネルに切り替えてから設定を再表示すると入力したパスが表示される', async () => { + await openSettingsPanel(); + const addBtn = await $('#settings-monitor-path-add'); + await addBtn.click(); + const pathInput = await $('.setting-monitor-path'); + await pathInput.waitForDisplayed({ timeout: 3000 }); + await pathInput.setValue('D:\\Documents\\Notes'); + await (await $('#settings-save-btn')).click(); + await browser.pause(500); + await (await $('button[data-panel="search"]')).click(); + await browser.pause(300); + await openSettingsPanel(); + // 非同期で設定を読み込むため、パス入力が表示されるまで待つ + await browser.waitUntil( + async () => (await $$('.setting-monitor-path')).length > 0, + { timeout: 5000 } + ); + const pathInputs = await $$('.setting-monitor-path'); + const values = await Promise.all(Array.from(pathInputs).map((el) => el.getValue())); + expect(values).toContain('D:\\Documents\\Notes'); + }); + + it('削除ボタンで行を削除して保存し、再表示すると行が消えている', async () => { + await openSettingsPanel(); + const addBtn = await $('#settings-monitor-path-add'); + await addBtn.click(); + const pathInput = await $('.setting-monitor-path'); + await pathInput.waitForDisplayed({ timeout: 3000 }); + await pathInput.setValue('E:\\ToRemove'); + await (await $('#settings-save-btn')).click(); + await browser.pause(500); + const removeBtn = await $('.setting-monitor-path-remove'); + await removeBtn.click(); + await (await $('#settings-save-btn')).click(); + await browser.pause(500); + await (await $('button[data-panel="search"]')).click(); + await browser.pause(300); + await openSettingsPanel(); + // 設定再読み込み後、リストが更新されるまで待つ(空になるか他行のみになる) + await browser.pause(800); + const pathInputs = await $$('.setting-monitor-path'); + const values = await Promise.all(Array.from(pathInputs).map((el) => el.getValue())); + expect(values.some((v) => v === 'E:\\ToRemove')).toBe(false); + }); + + it('監視フォルダに .txt を追加すると取り込まれ検索でヒットする(挙動確認)', async function () { + this.timeout(25000); + const watchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'telosdb-e2e-watch-')); + const uniquePhrase = `E2E_FOLDER_MONITOR_${Date.now()}_取り込み`; + const testFilePath = path.join(watchDir, 'e2e-watched-file.txt'); + try { + await openSettingsPanel(); + const addBtn = await $('#settings-monitor-path-add'); + await addBtn.click(); + const pathInput = await $('.setting-monitor-path'); + await pathInput.waitForDisplayed({ timeout: 3000 }); + await pathInput.setValue(watchDir); + await (await $('#settings-save-btn')).click(); + const feedback = await $('#settings-feedback'); + await expect(feedback).toHaveText('保存しました'); + await browser.pause(500); + + fs.writeFileSync(testFilePath, uniquePhrase, 'utf8'); + + // デバウンス 2 秒 + インデックス反映の余裕 + await browser.pause(5500); + + await (await $('button[data-panel="search"]')).click(); + await browser.pause(500); + const searchInput = await $('#query'); + const searchBtn = await $('.search-btn'); + await searchInput.setValue(uniquePhrase); + await searchBtn.click(); + await browser.pause(3000); + + const resultCards = await $$('.result-card'); + expect(resultCards.length).toBeGreaterThanOrEqual(1); + const firstCardText = await resultCards[0].getText(); + expect(firstCardText).toContain(uniquePhrase); + } finally { + try { + fs.unlinkSync(testFilePath); + fs.rmdirSync(watchDir); + } catch (_) {} + } + }); +}); diff --git a/tests/test_mcp_client.mjs b/tests/test_mcp_client.mjs index 0761e63..8f30f4d 100644 --- a/tests/test_mcp_client.mjs +++ b/tests/test_mcp_client.mjs @@ -147,7 +147,8 @@ async function testMcpCrud(axiosInstance) { console.log("\n[5] get_item_by_id with invalid id returns error..."); const badItemRes = await postMessage(axiosInstance, "tools/call", { name: "get_item_by_id", arguments: { id: -1 } }); - if (!badItemRes.isError) throw new Error("get_item_by_id(-1): expected isError true"); + const isError = badItemRes?.result?.isError === true || badItemRes?.error != null; + if (!isError) throw new Error("get_item_by_id(-1): expected isError true"); console.log(" get_item_by_id(-1) isError OK."); const path = "integration-test-" + Date.now() + ".txt"; diff --git a/tests/unit/connection-status.test.mjs b/tests/unit/connection-status.test.mjs new file mode 100644 index 0000000..f57ceb1 --- /dev/null +++ b/tests/unit/connection-status.test.mjs @@ -0,0 +1,32 @@ +/** + * 接続状態表示ロジックのユニットテスト。 + * index.html の「接続待ち…」/「Err」切り替えが CONNECTION_GRACE_ATTEMPTS に従うことを検証する。 + */ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + CONNECTION_GRACE_ATTEMPTS, + getConnectionStatusLabel, +} from "../../src/frontend/js/connection-status.js"; + +describe("connection-status", () => { + it("CONNECTION_GRACE_ATTEMPTS は 12 である", () => { + assert.strictEqual(CONNECTION_GRACE_ATTEMPTS, 12); + }); + + it("失敗 0〜12 回は「接続待ち…」を返す", () => { + for (let i = 0; i <= CONNECTION_GRACE_ATTEMPTS; i++) { + assert.strictEqual( + getConnectionStatusLabel(i), + "接続待ち…", + `failCount=${i}` + ); + } + }); + + it("失敗 13 回以降は「Err」を返す", () => { + assert.strictEqual(getConnectionStatusLabel(13), "Err"); + assert.strictEqual(getConnectionStatusLabel(20), "Err"); + assert.strictEqual(getConnectionStatusLabel(100), "Err"); + }); +}); diff --git a/wdio.conf.js b/wdio.conf.js index ac347f2..fc13670 100644 --- a/wdio.conf.js +++ b/wdio.conf.js @@ -8,6 +8,7 @@ * * 実行: npm run test:e2e * 初回はビルドに時間がかかります。事前に npm run build:e2e でビルドしても可。 + * 失敗しているスペックだけ回す: npm run test:e2e:spec -- --spec tests/e2e/specs/xxx.spec.js */ import fs from 'fs'; import os from 'os'; @@ -79,16 +80,20 @@ framework: 'mocha', mochaOpts: { ui: 'bdd', - timeout: 60000, + timeout: 130000, }, onPrepare: () => { killE2ePorts(); - if (!process.env.TELOS_E2E_PRO) { + const binaryExists = candidates.some((p) => fs.existsSync(p)); + if (!binaryExists && !process.env.TELOS_E2E_PRO) { + console.log('[E2E] バイナリがないためビルドを実行します…'); spawnSync('npm', ['run', 'build:e2e'], { cwd: root, stdio: 'inherit', shell: true, }); + } else if (binaryExists) { + console.log('[E2E] 既存バイナリを使用(ビルドスキップ)。再ビルドする場合は npm run build:e2e を先に実行。'); } }, onComplete: () => { @@ -96,6 +101,7 @@ killE2ePorts(); }, beforeSession: async () => { + if (tauriDriver) return; let edgeDriverPath = nativeDriverPath; if (isWin && !edgeDriverPath) { try { @@ -128,7 +134,7 @@ }); }, afterSession: () => { - closeTauriDriver(); + // 全 spec で 1 つの tauri-driver を共有するため、ここでは閉じない(onComplete で閉じる) }, };