diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2cc744126..4e4f60bdf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -184,7 +184,7 @@ iOS / Android と sidecar が無い開発時は in-process の transport (コマ #### A-2b. PiP ウィンドウ(常前面フローティングカラム) -**場所**: `usePipWindow.ts` + `src/views/PipPage.vue` +**場所**: `src/windows/pip.ts` + `src/views/PipPage.vue` - デッキのカラムを常前面フローティングウィンドウとして切り離し - 375×700px(リサイズ可能)、`alwaysOnTop`、複数同時起動(動的ラベル `pip-*`) @@ -600,7 +600,7 @@ Vue 3.6 で導入予定の Vapor モード(仮想DOMレス)への移行準 - **account 単位の共有**: `pinnedReactionsStore`(in-flight dedup) - **Rust 側の stream dedup**: `stream_connect(accountId)` は idempotent — 物理 WebSocket 接続は account 単位で 1 本 - **Viewport 制御**: `useColumnMount`(IntersectionObserver + 遅延 unmount)、`maxLiveColumns`(active 近傍のみ live)、非表示カラムの streaming batch pause -- **スナップショット復元**: `useSnapshotStore`(ID-only、TTL eviction、再マウント時の即時復元) +- **スナップショット復元**: `stores/snapshots`(ID-only、TTL eviction、再マウント時の即時復元) **解消済みの column-centric な課題**: @@ -1025,7 +1025,7 @@ column は `createQuerySubscription()`(`adapters/misskey/query.ts`)で query | 同時 live 数制限 | `useColumnMount` の `maxLiveColumns` budget | **実装済み** | active からの距離順に budget 配分 | | 可視性制御 | `useColumnMount` + `maxLiveColumns` | **実装済み** | — | | off-screen pause | `useNoteColumn` で streaming batch 処理を pause | **実装済み** | 変更不要 | -| snapshot 復元 | `useSnapshotStore`(ID-only, TTL、インメモリ = 再起動を跨がない) + shell preview | **実装済み** | — | +| snapshot 復元 | `stores/snapshots`(ID-only, TTL、インメモリ = 再起動を跨がない) + shell preview | **実装済み** | — | | note invalidation | `noteStore`(RAF batch + skipTrigger) | **実装済み** | 変更不要(計測の結果、コスト無視できる) | --- @@ -1264,7 +1264,7 @@ graph TB - **SQLite `chat_messages_cache`** (Layer 1, 永続) — REST/WS で受信した全メッセージを upsert。eviction を担う - **`chatMessageStore`** (Layer 0, frontend 正規化ストア) — 唯一の実体。Pinia store として `noteStore` ([src/stores/notes.ts](src/stores/notes.ts)) のシグネチャを流用。キーは `noteStore` と同じ「取得元アカウント + メッセージ ID」の `VariantKey` (notedeck#1270、Misskey の ID はサーバー内でしか一意でない)。`put()` / `update(key, msg)` / `applyUpdate(event)` で in-place 更新 → UI 自動伝播。LRU 風 eviction (`chatMessageStoreMax`、既定値は `src/defaults/performance.json5`) -- **SnapshotStore** (Layer 2, ID-only) — タブ切替・カラム再マウント時の即時復元用。`noteStore` と同じ TTL eviction 機構 ([src/composables/useSnapshotStore.ts](src/composables/useSnapshotStore.ts)) を共有 (cacheKey で namespace 分離) +- **SnapshotStore** (Layer 2, ID-only) — タブ切替・カラム再マウント時の即時復元用。`noteStore` と同じ TTL eviction 機構 ([src/stores/snapshots.ts](src/stores/snapshots.ts)) を共有 (cacheKey で namespace 分離) - **DeckChatColumn.vue** (UI) — `threads: Ref` と `messages: Ref` のみ保持。実体は `chatMessageStore.resolve()` で取得 ### データフロー @@ -1449,7 +1449,7 @@ DB::open_with_eviction(path, notes_cfg, chat_cfg) ### 課題 5: プラグイン境界 -`aiscript/plugin-api.ts` の `launchPlugin` でプラグインが `commandStore` への直接参照を取得し、`notedeck-api.ts` の `Nd:register_command` で store に直接作用する。カラム操作(旧 `Nd:addColumn` / `Nd:removeColumn`)は廃止し、capability registry 経由の `Nd:call('column.add', ...)` 等で行う。 +`aiscript/plugin-api.ts` の `launchPlugin` でプラグインが `commandStore` への直接参照を取得し、`notedeck-api.ts` の `Nd:register_command` でパレットの `Command` を登録する (options で `aiTool` と `signature` を宣言したものは `Capability` としても別に登録、#1098 §9)。カラム操作(旧 `Nd:addColumn` / `Nd:removeColumn`)は廃止し、capability registry 経由の `Nd:call('column.add', ...)` 等で行う。 **現状の評価**: 拡張性は高いが、第三者プラグインを増やす場合、store への直接アクセスは保守負債になる。 diff --git a/CODEMAP.md b/CODEMAP.md index ce13b2531..4be364807 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -125,6 +125,7 @@ store は「購読 + キャッシュ + UI 状態」だけを持つ。純ロジ | service | テスト | |---|---| +| [`src/services/accountScope.ts`](src/services/accountScope.ts) | — | | [`src/services/achievements.ts`](src/services/achievements.ts) | [`src/services/achievements.test.ts`](src/services/achievements.test.ts) | | [`src/services/acpPreapproval.ts`](src/services/acpPreapproval.ts) | [`src/services/acpPreapproval.test.ts`](src/services/acpPreapproval.test.ts) | | [`src/services/aiSessionCodec.ts`](src/services/aiSessionCodec.ts) | [`src/services/aiSessionCodec.test.ts`](src/services/aiSessionCodec.test.ts) | diff --git a/Cargo.lock b/Cargo.lock index 1cf9c2516..41e6d72d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3368,7 +3368,7 @@ dependencies = [ [[package]] name = "notedeck" -version = "1.98.0" +version = "1.98.1" dependencies = [ "async-trait", "axum", @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "notemaid" -version = "1.98.0" +version = "1.98.1" dependencies = [ "chrono", "clap", diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 003322f48..270f9db83 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -436,7 +436,7 @@ iOS / Android は in-process の transport (コマンド面は同じ) - notecore 側のモジュールは `crates/notecore` に置く (2026-09-23 にクレート化済み)。notecore は `tauri` を参照せず、Cargo.toml に tauri 系を足さない (`tests/lint/rustCoreBoundary.test.ts`)。手元側 (WebView / managed state / OS 統合) が要る処理は trait (`FrontendBridge` / `AiChatSink`) で受け取り、Tauri 側 (`src-tauri/`) が実装を渡す。app dir のような値は `&Path` で受ける - **データ系コマンドは notecore のコマンド表に載せる** (`crates/notecore/src/commands/table.rs`、#1106 §4.1。2026-09-23 に既存のデータ系は全件移行済み。跨り (mixed) も「データ側は notecore の関数 (`export_service` / `backup_service` / `commands::admin` 等)、手元側は dialog や保存先の解決だけの薄い包み」に分けたので、`src-tauri/src/commands/` に `#[tauri::command]` で残るのは local と authz だけ)。本体は `&Core` と引数を取る関数として `crates/notecore/src/commands/.rs` に書き、表に 1 行足す。表から Tauri ラッパー (`src-tauri/src/commands/table.rs`)、JSON アダプタ (`dispatch`)、フィクスチャが生成され、属性検査 (許可ウィンドウ) は型付き経路でも JSON 経路でも本体の前に通る。全コマンドを JSON 経路で往復させるテストが notecore にあり、引数の型は `Default` を要求する。手元側 (OS 統合) と認可境界のコマンドは従来どおり `#[tauri::command]` で書く -- **行数の多い DB の読み書き (取り込み / キャッシュ検索 / 一括削除) は `Core::blocking` を通す** (notecli の Database は同期の rusqlite なので、async の本体から直接呼ぶと tokio の worker を塞ぐ)。1 行のキー引きや統計は直接呼んでよい。プール化や async API を notecli 側に持たせる判断 (#1098) はこの境界の裏で差し替える +- **DB の読み書きはすべて `Core::blocking` / `with_archive` / `spawn_archive_write` を通す** (notecli の Database は同期の rusqlite なので、async の本体から直接呼ぶと tokio の worker を塞ぐ)。生のハンドル (`Core::db()` / `try_db()`) は notecore の中だけで、コマンド本体やアプリ側からは取れない (#1098 §7、2026-10-27)。資格情報は `Core::credentials` / `credentials_or_anon` (口座の所在をキャッシュ越しに引く。miss 時の 1 行読みだけ worker で許容)。notecli に async API を足す案は不採用: 接続が writer / reader 1 本ずつの設計では中身が spawn_blocking になるだけで並行度が上がらず、CLI に runtime を持ち込む。プール化するなら notes キャッシュ再設計 (notecli#30) の中で、この境界の裏で差し替える - 表に載らない `#[tauri::command]` は直前の行に種別マーカー `// nd-command: ` を持つ (`tests/lint/rustCommandKinds.test.ts`)。種別は `data` (データ系、notecore で実行できる) / `local` (OS 統合、手元に残る) / `authz` (認可境界を動かす操作: 権限ファイル / 信頼設定 / 公開 API トークン / Vault secret / アカウント資格情報。) / `mixed` (data と local が同居、段階 0b で分割)。優先順位は authz > mixed > local > data。認可境界に触れる本体は denylist で二重に検査され、authz 以外なら落ちる - Kotlin から直接呼ぶ Rust の入口 (JNI の `extern "system" fn Java_…`、Android の通知ブリッジやワーカーが使う) は `#[tauri::command]` ではないので、直前の行に `// nd-entry: local` を持つ (同じ lint)。OS 統合なので種別は local 固定。入口の中は release の `panic = "abort"` を前提に Result 一本で書き、`ndk_context` に触らない (#330 §4.1) - 逆向き (Rust から JNI で Kotlin を呼ぶ) のメソッドは、Kotlin からは誰も呼ばないので release の R8 に消される。**呼び先を足したら `src-tauri/android/proguard-jni.pro` にも足す** (`sync.sh` が gen の app 直下に置く)。debug ビルドは縮小しないので手元では動き、release 版だけで静かに壊れる (v1.92.0〜v1.95.1 は OS 通知の描画・push・ステータスバーの明暗が release で動いていなかった)。`tests/lint/androidJniKeep.test.ts` が Rust 側の呼び先と突き合わせる @@ -530,7 +530,7 @@ src-tauri/src/ # Rust backend (Tauri 固有部分 = 手元側) components → composables → stores → services → adapters → bindings ``` -services は store / composables / components / Vue / Pinia を runtime import しない (純ロジック)。stores は composables を、adapters は stores を import しない。components は IPC (`tauriInvoke` / `bindings`) と adapter の factory を直接叩かず composable を通す。既存の違反は lint の凍結一覧に理由つきで載っていて、直したら消す。`import type` は依存に数えない。 +services は store / composables / components / Vue / Pinia を runtime import しない (純ロジック)。stores は composables を、adapters は stores を import しない。components は IPC (`tauriInvoke` / `bindings`) と adapter の factory を直接叩かず composable を通す (#1258 で凍結ゼロ): Misskey の API は adapter (`ApiAdapter` に無いメソッドは足す) をカラムなら `useColumnSetup` / `useNoteColumn` の `initAdapter`、ウィンドウやポップアップなら `useMultiAccountAdapters` の `forView` / `apiFor` で引き、クエリ購読やキャッシュ・バックアップ・画像などの手元の command は `src/composables/` の用途別の小さなモジュール (`querySubscriptions` / `archiveSearch` / `cacheMaintenance` / `backupFiles` / `imageFiles` / `httpApiServer` / `appDiagnostics` / `login` / `chatStream`) を通す。adapters の factory は stores を知らず、サーバー情報の取得と絵文字 / ピン留めの温めは起動時に `wireAdapterFactory` (stores 側) で注入する。services がアカウントを要るときは引数で受ける (`resolveNoteUriFor`)。層依存と循環 import の凍結は 2026-10-27 にすべて解消した (offlineMode ↔ realtimeMode は、輸送路の再適用を streaming store に置いて一方向にした)。`import type` は依存に数えない。 Misskey API クライアント・DB・モデル・ストリーミングコアは `notecli` クレートにある。`src-tauri/` が抱えていた Tauri に依存しないドメイン (OGP 抽出とサイト別プラグイン / Secret Vault / クエリランタイム / 画像キャッシュ / HTTP API サーバー / カラムクエリの QIR 評価器) は notecore に、AI SSE クライアントは notemaid に移した (上のツリー)。 @@ -1050,7 +1050,7 @@ const { activate, deactivate } = useMenuKeyboard({ **ファイル:** `src/capabilities/` -`Capability` は `Command` を拡張した構造 (`signature` / `permissions` / `requiresConfirmation` / `aiTool`) で、**コマンドパレット / HTTP API / CLI / AiScript (`Nd:call`) / AI tool calling** の 5 経路が同じ registry を共有する。 +`Capability` (`src/capabilities/types.ts`) は「実行できる操作」の単位で、**HTTP API / パレットの CLI と slash / AiScript (`Nd:call`) / AI tool calling** が同じ registry を dispatcher 越しに共有する。コマンドパレットの `Command` (`src/commands/registry.ts`) は人が押す入口 (ラベル / アイコン / ショートカット / 露出 / `execute()`) で、型としては別物 (#1098 §9、2026-10-27)。Command が capability を呼ぶかどうかは中身の自由で、`useCommandStore.register` は capability registry に何もしない。id の名前空間も分かれる (Command は kebab `note-react`、capability は dotted `notes.react`)。パレットの CLI (`src/commands/cliHandlers.ts`) のうち API を叩く動詞 (react / renote / delete / follow / favorite など) は `dispatchCapability` を user principal で呼ぶので、slash と同じ確認ダイアログを通る。capability に無い引数を持つ `post` (`--local-only`) と `update` だけが IPC 直呼びで残る。 #### 宣言の正本と生成物 @@ -1100,7 +1100,7 @@ notecore 側のファイルは AI (notemaid) とデバイスの両方が書く 別プロセスの notemaid (`crates/notemaid/src/daemon/`) は AI 系 (notemaid の表) だけを RPC で受け、HEARTBEAT timer を持つ。起動役は上の「目指す構成」のとおり (子プロセスが既定、常駐は AI 設定のトグル = `notemaid service`)。SQLite は開かず、口座の所在は notecore の `AccountStore` trait (アプリは notecli.db が実装、notemaid は `SyncedAccounts` = 接続したアプリが `notemaid.accounts` で写した一覧をメモリと小さなファイルに持ち、口座が変わると `core_sync_accounts` で写し直す)。資格情報の解決 (`credentials.rs`) と口座一覧 (`account_service::list_public_from`) はこの trait だけを見るので、`Core` は DB なし (`initialize_client` + `set_account_store`) でも AI のターンを回せる。トークンは OS キーチェーンから同じ id で読み、キーチェーンが無い環境 (WSL2 など) ではアプリの DB と同じくトークン列が経路になる (写しに含める。保護水準はアプリの DB と同じ)。子プロセスが繋がる前に死んだ / 答えないときは in-process に退避する。 -**DB なしの Core で待ち続けない規則**: `Core::ready()` は廃止し `authed*()` / `client()` を使う。取得系が索引 (notes キャッシュ) へ書くのは `Core::with_archive` (索引があるときだけ回し、無ければ素通し) と `try_db()` で、`blocking` は DB の無いプロセスでは即 Err。手元の索引を読む `notes.searchArchive` は `FrontendBridge::archive_search` (型付き。wire は `archive/search`) で端末に聞き、アプリの Rust (`query_bridge::answer_archive_search`) が自分の Core で答える (WebView は通らない)。server_info の検出結果は DB があれば DB、無ければメモリ (`ServerInfoService::new_in_memory`)。この規則は `context.rs` の `client_only_core_never_waits_for_a_database` と notemaid の `search_archive_asks_the_device_through_the_bridge` が押さえる。 +**DB なしの Core で待ち続けない規則**: `Core::ready()` は廃止し `authed*()` / `client()` を使う。取得系が索引 (notes キャッシュ) へ書くのは `Core::with_archive` (索引があるときだけ回し、無ければ素通し) と `spawn_archive_write` (待たない) で、`blocking` は DB の無いプロセスでは即 Err。手元の索引を読む `notes.searchArchive` は `FrontendBridge::archive_search` (型付き。wire は `archive/search`) で端末に聞き、アプリの Rust (`query_bridge::answer_archive_search`) が自分の Core で答える (WebView は通らない)。server_info の検出結果は DB があれば DB、無ければメモリ (`ServerInfoService::new_in_memory`)。この規則は `context.rs` の `client_only_core_never_waits_for_a_database` と notemaid の `search_archive_asks_the_device_through_the_bridge` が押さえる。 #### アプリと notemaid の間 (クライアント層と橋) @@ -1165,7 +1165,7 @@ healthcheck は doctor とキャッシュ統計を notecore の `health_core` **AiScript からの拡張:** - AiScript の実行環境 (Mk:* / Nd:* / Ui:* + interpreter) は `src/aiscript/sandbox.ts` の `createAiScriptSandbox` 1 本で組む (#1099)。プラグイン / ウィジェット / Play / Page / スクラッチパッドはすべてここを通り、principal は必須引数で 1 回だけ渡す — Mk:api の endpoint gate、Nd:call / Nd:http の dispatcher 判定、登録 ID の名前空間、呼び出し元の AND 判定に使う `callers` が同じ値を見る。実行面を足すときも `createAiScriptEnv` / `createNoteDeckEnv` を直接組まない -- `Nd:register_command(id, label, fn, options)` の `options` に `signature` / `permissions` / `aiTool` / `requiresConfirmation` を渡すと **capability registry にもミラー登録**され、即 5 経路に公開される +- `Nd:register_command(id, label, icon, fn, options)` はパレットの `Command` を登録する。`options` に `aiTool: true` と `signature` を渡したものだけ **別に `Capability` としても登録**され (permissions / requiresConfirmation も options から)、HTTP API / `Nd:call` / AI tool calling から呼べる。停止時は両方の登録を disposers が外す - `Nd:capabilities()` で registry にある capability の宣言情報を列挙 (プラグインの自己発見) - `Nd:on(name, handler)` で `column:added` / `column:removed` / `streaming:status` / `note:new` / `notification:new` を購読。`note:new` / `notification:new` は queryDelta を `core/queryRegistry`(queryId → flavor/accountId)で振り分けて fan-out する @@ -1270,7 +1270,7 @@ graph LR ``` - `src/engine/telemetry/frameTelemetry.ts` — P95 計測、自動品質調整(low/balanced/high) -- `src/composables/useAdaptiveQuality.ts` — デバイス検出(CPU, RAM, prefers-reduced-motion) +- `src/utils/adaptiveQuality.ts` — デバイス検出(CPU, RAM, prefers-reduced-motion) - 開発時のみ `DevFrameOverlay`(Ctrl+Shift+F)で FPS/フレーム時間を可視化 #### ディレクトリ構成 @@ -1433,7 +1433,7 @@ AiScript / AI / プラグインが**任意の外部サービス** (GitHub / Line #### AI 統合 -- `vault.fetch` は capability registry に登録 (`aiTool: true`, permission `vault.use`, `requiresConfirmation: true`)。AI / AiScript / コマンドパレットから呼べる +- `vault.fetch` は capability registry に登録 (`aiTool: true`, permission `vault.use`, `requiresConfirmation: true`)。AI / AiScript / slash から呼べる - 開示は principal クラス別 (#712 §6.1 / #759): `exposedTo: ('ai' | 'plugin' | 'external')[]` (接続単位、default 空 = 非開示)。「AI に見せる」「プラグイン・ウィジェットに見せる」「外部アプリに見せる」は別の同意で、`vault.fetch` capability は呼び出し principal のクラスに開示された接続のみ `connectionRef` (name / id) で解決。plugin クラスは接続ごとの明示 opt-in (#759 — secret 自体は Rust 側注入のまま AiScript に露出しない) - 「今後確認なし」(`trustedFor`) もクラス別 — 外部アプリでの確認同意が AI の trust に化けない。plugin クラスのみクラス一括ではなく**個体単位** (`trustedPlugins: {id, name}[]`) — 1 ウィジェットへの同意が全プラグイン / Play / Page に波及しない。取り消しは接続編集の信頼済み一覧から - AI の system prompt に `` ブロックを注入 (Ai クラスに開示された接続の name / baseUrl / auth のみ、secret / id は出さない) diff --git a/PERFORMANCE.md b/PERFORMANCE.md index e7cdb0ae1..4abe629d3 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -21,7 +21,7 @@ NoteDeck に入っている最適化と、検討したうえで採用しない | OGP inflight dedup | 同時リクエストの重複排除 | `crates/notecore/src/ogp/mod.rs` | | コード分割 | `defineAsyncComponent` による per-component 分割。`manualChunks` は書かない (#985: rolldown-vite では名前付きグループが共有モジュールを吸収して entry を肥大化させる)。出力は CI の `check:dist` で予算検査 | `vite.config.ts`, `scripts/check-dist-budget.mjs` | | Multi-tier cache | メモリ LRU → ディスクキャッシュ → ネットワーク | `crates/notecore/src/image_cache.rs` | -| 適応的品質 | CPU / メモリから low / balanced / high を自動判定 | `src/composables/useAdaptiveQuality.ts` | +| 適応的品質 | CPU / メモリから low / balanced / high を自動判定 | `src/utils/adaptiveQuality.ts` | | WebSocket 共有 | accountId 毎に 1 接続、subscriptionId で多重化 | `src/adapters/misskey/streaming.ts` | | 2 段階初期化 | Phase 1 で最小構成を立ち上げ、Phase 2 を並行実行 | `src-tauri/src/lib.rs` | | カラム遅延表示 | IntersectionObserver + paused 制御 | `src/composables/useColumnMount.ts` | diff --git a/crates/notecore/src/auth_service.rs b/crates/notecore/src/auth_service.rs index 9813e4d4f..dcaf20e9f 100644 --- a/crates/notecore/src/auth_service.rs +++ b/crates/notecore/src/auth_service.rs @@ -109,23 +109,32 @@ pub async fn complete_and_save( software, }; - db.upsert_account(&account)?; - - let saved = db - .get_account_by_host_user(host, &auth_result.user.id)? - .ok_or_else(|| NoteDeckError::Internal("account missing right after upsert".to_string()))?; - - if keychain::store_token(&saved.id, &token).is_ok() - && keychain::get_token(&saved.id).ok().flatten().is_some() - && keychain::is_persistent() - { - let _ = db.clear_token(&saved.id); - } + // DB の書込とキーチェーンは同期 IO なので blocking スレッドへ (#1098 §7) + let db = Arc::clone(db); + let host_owned = host.to_string(); + let user_id = auth_result.user.id.clone(); + let saved = tokio::task::spawn_blocking(move || -> Result { + db.upsert_account(&account)?; + let saved = db + .get_account_by_host_user(&host_owned, &user_id)? + .ok_or_else(|| { + NoteDeckError::Internal("account missing right after upsert".to_string()) + })?; + if keychain::store_token(&saved.id, &token).is_ok() + && keychain::get_token(&saved.id).ok().flatten().is_some() + && keychain::is_persistent() + { + let _ = db.clear_token(&saved.id); + } + token.zeroize(); + Ok(saved) + }) + .await + .map_err(|e| NoteDeckError::Internal(format!("blocking task failed: {e}")))??; // 同じ host + user の再ログインは id を維持する (upsert) ので、資格情報の // キャッシュに古いトークンが残る。新しいトークンを置いた後に無効化する // (先に無効化すると、置くまでの間に古い値を読み直して載せ直されうる) invalidate_credentials(&saved.id); - token.zeroize(); Ok(AccountPublic::new(&saved, true)) // account, saved が drop → token が zeroize される diff --git a/crates/notecore/src/backup_service.rs b/crates/notecore/src/backup_service.rs index 3c225a095..473e239e5 100644 --- a/crates/notecore/src/backup_service.rs +++ b/crates/notecore/src/backup_service.rs @@ -129,12 +129,10 @@ pub async fn create( // DB スナップショット (トークンは常に除去) let db_bytes = if include_db { let db_path = dir.join(DB_FILE); - let db = core.db().await; { let db_path = db_path.clone(); - tokio::task::spawn_blocking(move || db.backup_to(&db_path, true)) - .await - .map_err(|e| NoteDeckError::InvalidInput(e.to_string()))??; + core.blocking(move |db| db.backup_to(&db_path, true)) + .await?; } Some(std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0)) } else { diff --git a/crates/notecore/src/commands/admin.rs b/crates/notecore/src/commands/admin.rs index 53def4337..f8bbd09f0 100644 --- a/crates/notecore/src/commands/admin.rs +++ b/crates/notecore/src/commands/admin.rs @@ -21,18 +21,9 @@ pub struct CacheStats { pub db_size_bytes: i64, } -// 以下の cache 系コマンドは writer lock を長時間 (秒〜分オーダー) 保持し得るため -// spawn_blocking で退避する。writer は std::sync::Mutex のため async runtime 直呼びは -// tokio worker の連鎖枯渇を招く (前例: messaging.rs)。 - -/// writer lock を長時間保持する DB 操作を blocking スレッドで実行する。 -async fn run_blocking( - f: impl FnOnce() -> std::result::Result + Send + 'static, -) -> Result { - tokio::task::spawn_blocking(f) - .await - .map_err(|e| NoteDeckError::Internal(format!("blocking task failed: {e}")))? -} +// DB を触るコマンドはすべて `Core::blocking` で blocking スレッドへ寄せる (#1098 §7)。 +// writer は std::sync::Mutex のため async runtime 直呼びは tokio worker の連鎖枯渇を +// 招く (前例: messaging.rs)。cache 系は writer lock を秒〜分オーダーで保持し得る。 // --- Chat cache management --- @@ -48,13 +39,11 @@ pub struct ChatCacheStats { // --- Server detections (SWR キャッシュは notecli::server_info、#782) --- pub async fn load_accounts(core: &Core) -> Result> { - let db = core.db().await; - account_service::list_public(&db) + core.blocking(account_service::list_public).await } pub async fn cache_stats(core: &Core) -> Result { - let db = core.db().await; - let (note_count, db_size_bytes) = db.cache_stats()?; + let (note_count, db_size_bytes) = core.blocking(|db| db.cache_stats()).await?; Ok(CacheStats { note_count, db_size_bytes, @@ -67,13 +56,12 @@ pub async fn account_cache_count(core: &Core, account_id: String) -> Result } pub async fn clear_account_cache(core: &Core, account_id: String) -> Result { - let db = core.db().await; - run_blocking(move || db.clear_account_cache(&account_id)).await + core.blocking(move |db| db.clear_account_cache(&account_id)) + .await } pub async fn clear_all_cache(core: &Core) -> Result { - let db = core.db().await; - run_blocking(move || { + core.blocking(|db| { let notes = db.clear_all_notes_cache()?; let _ogp = db.clear_ogp_cache()?; Ok(notes) @@ -84,8 +72,8 @@ pub async fn clear_all_cache(core: &Core) -> Result { /// ユーザーが UI で選んだ eviction config を即時適用する。 戻り値は削除件数。 /// JS 側で settings.cacheEviction を変更したタイミングで呼ぶ想定。 pub async fn apply_eviction_config(core: &Core, config: EvictionConfig) -> Result { - let db = core.db().await; - run_blocking(move || db.cleanup_with_eviction(&config)).await + core.blocking(move |db| db.cleanup_with_eviction(&config)) + .await } /// notecli の `EvictionConfig::default()` を取得する。 アプリの「バランス」 @@ -95,8 +83,7 @@ pub async fn default_eviction_config(_core: &Core) -> Result { } pub async fn chat_cache_stats(core: &Core) -> Result { - let db = core.db().await; - let (message_count, bytes) = db.chat_cache_stats()?; + let (message_count, bytes) = core.blocking(|db| db.chat_cache_stats()).await?; Ok(ChatCacheStats { message_count, bytes, @@ -115,8 +102,8 @@ pub async fn clear_chat_cache_for_account(core: &Core, account_id: String) -> Re /// chat 用の eviction config を即時適用する。`apply_eviction_config` (notes 用) と並列。 pub async fn apply_chat_eviction_config(core: &Core, config: ChatEvictionConfig) -> Result { - let db = core.db().await; - run_blocking(move || db.cleanup_chat_with_eviction(&config)).await + core.blocking(move |db| db.cleanup_chat_with_eviction(&config)) + .await } pub async fn default_chat_eviction_config(_core: &Core) -> Result { @@ -129,9 +116,10 @@ pub async fn create_guest_account( host: String, software: String, ) -> Result { - let db = core.db().await; let host = validate_host(&host)?; - let account = account_service::create_guest(&db, host, software)?; + let account = core + .blocking(move |db| account_service::create_guest(db, host, software)) + .await?; Ok(AccountPublic::new(&account, false)) } @@ -166,11 +154,8 @@ pub fn validate_sqlite_file(path: &std::path::Path) -> Result<()> { /// notecli.db の整合したスナップショットを `dest` に書く (トークンは常に除去)。 /// 手元側の「DB をエクスポート」(保存 dialog) が呼ぶ。 pub async fn snapshot_db_to(core: &Core, dest: &std::path::Path) -> Result<()> { - let db = core.db().await; let dest = dest.to_path_buf(); - tokio::task::spawn_blocking(move || db.backup_to(&dest, true)) - .await - .map_err(|e| NoteDeckError::InvalidInput(e.to_string()))? + core.blocking(move |db| db.backup_to(&dest, true)).await } /// `src` を検証してから notecli.db として app dir に置き、WAL / SHM を消す @@ -190,9 +175,8 @@ pub async fn delete_account(core: &Core, id: String) -> Result<()> { if let Ok(streaming) = core.streaming() { streaming.disconnect(&id).await; } - let db = core.db().await; - crate::account_service::delete(&db, &id)?; - Ok(()) + core.blocking(move |db| crate::account_service::delete(db, &id)) + .await } /// ログアウト: トークンだけ消し、アカウントの記録とカラムは残す @@ -200,9 +184,8 @@ pub async fn logout_account(core: &Core, id: String) -> Result<()> { if let Ok(streaming) = core.streaming() { streaming.disconnect(&id).await; } - let db = core.db().await; - crate::account_service::logout(&db, &id)?; - Ok(()) + core.blocking(move |db| crate::account_service::logout(db, &id)) + .await } #[cfg(test)] diff --git a/crates/notecore/src/commands/content.rs b/crates/notecore/src/commands/content.rs index fecc45d70..e260fc11e 100644 --- a/crates/notecore/src/commands/content.rs +++ b/crates/notecore/src/commands/content.rs @@ -8,7 +8,6 @@ use notecli::models::{GalleryPost, Page, ServerEmoji}; use crate::commands::{typed_request, validate_host}; use crate::context::Core; -use crate::credentials::get_credentials_or_anon; use crate::error::Result; // --- Server metadata --- @@ -127,10 +126,7 @@ pub async fn api_get_server_emojis( if !refresh { // host の解決は DB (Stage 1) だけで済ませ、命中時は full-ready を待たない - let host = { - let db = core.db().await; - get_credentials_or_anon(&db, &account_id)?.0 - }; + let host = core.credentials_or_anon(&account_id)?.0; let dir = app_dir.clone(); let cached = tokio::task::spawn_blocking(move || cache::read(&dir, &host)) .await diff --git a/crates/notecore/src/commands/enrichment.rs b/crates/notecore/src/commands/enrichment.rs index 626373a15..7c80400b7 100644 --- a/crates/notecore/src/commands/enrichment.rs +++ b/crates/notecore/src/commands/enrichment.rs @@ -6,7 +6,6 @@ use base64::Engine; use notecli::error::NoteDeckError; use crate::context::Core; -use crate::credentials::get_credentials; use crate::error::Result; // --- OGP Preview --- @@ -19,7 +18,6 @@ pub async fn fetch_ogp( account_id: Option, ) -> Result { let ogp_cache = core.ogp_cache()?; - let db = core.db().await; if url.len() > 2048 { return Err(NoteDeckError::InvalidInput("URL too long".to_string())); } @@ -27,7 +25,7 @@ pub async fn fetch_ogp( // With server context: plugins → server → direct HTML parse // Without: plugins → direct HTML parse let result = if let Some(ref aid) = account_id { - if let Ok((host, token)) = get_credentials(&db, aid) { + if let Ok((host, token)) = core.credentials(aid) { ogp_cache.get_ogp_via_server(&url, &host, &token).await } else { ogp_cache.get_ogp(&url).await diff --git a/crates/notecore/src/commands/health.rs b/crates/notecore/src/commands/health.rs index 72a413159..0b17ff40c 100644 --- a/crates/notecore/src/commands/health.rs +++ b/crates/notecore/src/commands/health.rs @@ -17,12 +17,18 @@ pub struct CoreHealth { } pub async fn health_core(core: &Core) -> Result { - let db = core.db().await; let db_path = core.app_dir()?.join("notecli.db"); - // doctor のチェックを丸ごと再利用 (account_spec = None で全アカウント対象) + // doctor のチェックを丸ごと再利用 (account_spec = None で全アカウント対象)。 + // diagnose は notecli の async 関数で DB も中で触る (自己診断の 1 回きり) + let db = core.db().await; let mut doctor = notecli::commands::doctor::diagnose(db.as_ref(), &db_path, None).await?; - strip_guest_credential_checks(&mut doctor, &db.load_accounts().unwrap_or_default()); - let (note_cache_count, db_size_bytes) = db.cache_stats()?; + drop(db); + let accounts = core + .blocking(|db| db.load_accounts()) + .await + .unwrap_or_default(); + strip_guest_credential_checks(&mut doctor, &accounts); + let (note_cache_count, db_size_bytes) = core.blocking(|db| db.cache_stats()).await?; Ok(CoreHealth { doctor, note_cache_count, diff --git a/crates/notecore/src/commands/messaging.rs b/crates/notecore/src/commands/messaging.rs index 27fe81efc..b470deea7 100644 --- a/crates/notecore/src/commands/messaging.rs +++ b/crates/notecore/src/commands/messaging.rs @@ -1,43 +1,31 @@ //! messaging のデータ系コマンド本体 (#1106 段階 0b)。各関数は `&Core` と引数を取り、 //! コマンド表 (commands/table.rs) から呼ばれる。 -use std::sync::Arc; - use notecli::db::Database; use notecli::models::{ChatMessage, NormalizedNotification, TimelineOptions}; use crate::context::Core; use crate::error::Result; -/// REST レスポンスで取得した chat メッセージを fire-and-forget で DB に upsert する。 +/// REST レスポンスで取得した chat メッセージを DB に upsert する。呼び出し側は +/// `Core::spawn_archive_write` で blocking スレッドに乗せ、返事を待たせない。 /// `cache` フラグが false なら何もしない (`chat.cacheEnabled = false` 時の opt-out)。 fn cache_chat_response( - db: &Arc, + db: &Database, msgs: &[ChatMessage], account_id: &str, host: &str, cache: Option, -) { - if !cache.unwrap_or(true) { - return; - } - if msgs.is_empty() { - return; +) -> Result<()> { + if !cache.unwrap_or(true) || msgs.is_empty() { + return Ok(()); } // user_id が取れない (account 削除直後等) は skip let Ok(Some(account)) = db.get_account(account_id) else { - return; + return Ok(()); }; - let db = db.clone(); - let msgs = msgs.to_vec(); - let account_id = account_id.to_string(); - let host = host.to_string(); - let user_id = account.user_id.clone(); - tokio::task::spawn_blocking(move || { - if let Err(e) = db.cache_chat_messages(&msgs, &account_id, &user_id, &host) { - tracing::warn!(error = %e, "failed to cache chat REST response"); - } - }); + db.cache_chat_messages(msgs, account_id, &account.user_id, host)?; + Ok(()) } // --- Notifications --- @@ -136,9 +124,8 @@ pub async fn api_get_chat_history( .get_chat_history(&host, &token, limit.unwrap_or(100), room.unwrap_or(false)) .await?; ChatMessage::fill_account_id(&mut msgs, &account_id); - if let Some(db) = core.try_db() { - cache_chat_response(&db, &msgs, &account_id, &host, cache); - } + let cached = msgs.clone(); + core.spawn_archive_write(move |db| cache_chat_response(db, &cached, &account_id, &host, cache)); Ok(msgs) } @@ -163,9 +150,8 @@ pub async fn api_get_chat_user_messages( ) .await?; ChatMessage::fill_account_id(&mut msgs, &account_id); - if let Some(db) = core.try_db() { - cache_chat_response(&db, &msgs, &account_id, &host, cache); - } + let cached = msgs.clone(); + core.spawn_archive_write(move |db| cache_chat_response(db, &cached, &account_id, &host, cache)); Ok(msgs) } @@ -190,9 +176,8 @@ pub async fn api_get_chat_room_messages( ) .await?; ChatMessage::fill_account_id(&mut msgs, &account_id); - if let Some(db) = core.try_db() { - cache_chat_response(&db, &msgs, &account_id, &host, cache); - } + let cached = msgs.clone(); + core.spawn_archive_write(move |db| cache_chat_response(db, &cached, &account_id, &host, cache)); Ok(msgs) } @@ -230,9 +215,10 @@ pub async fn api_create_chat_message( }; msg.account_id = account_id.clone(); // 送信メッセージも DB に書く (WS で同じ msg が往復するが UPSERT で冪等)。索引が無ければ省く - if let Some(db) = core.try_db() { - cache_chat_response(&db, std::slice::from_ref(&msg), &account_id, &host, None); - } + let cached = msg.clone(); + core.spawn_archive_write(move |db| { + cache_chat_response(db, std::slice::from_ref(&cached), &account_id, &host, None) + }); Ok(msg) } diff --git a/crates/notecore/src/commands/query.rs b/crates/notecore/src/commands/query.rs index 465c05efc..bbfca7f02 100644 --- a/crates/notecore/src/commands/query.rs +++ b/crates/notecore/src/commands/query.rs @@ -9,7 +9,6 @@ use crate::query_runtime::{ }; use crate::context::Core; -use crate::credentials::get_credentials; use crate::error::Result; // query_open (QueryKey を invoke 引数に取る汎用オープン) は削除した。 @@ -25,8 +24,7 @@ pub async fn query_subscribe_timeline( ) -> Result { let streaming = core.streaming()?; let runtime = core.query_runtime()?; - let db = core.db().await; - let (host, token) = get_credentials(&db, &account_id)?; + let (host, token) = core.credentials(&account_id)?; streaming.connect(&account_id, &host, &token).await?; // 境界アダプタ: ('user-list', listId) → canonical キー合成 (api_get_timeline と同規約) @@ -64,8 +62,7 @@ pub async fn query_subscribe_antenna( ) -> Result { let streaming = core.streaming()?; let runtime = core.query_runtime()?; - let db = core.db().await; - let (host, token) = get_credentials(&db, &account_id)?; + let (host, token) = core.credentials(&account_id)?; streaming.connect(&account_id, &host, &token).await?; let tl_key = TimelineKey::Antenna { @@ -90,8 +87,7 @@ pub async fn query_subscribe_channel( ) -> Result { let streaming = core.streaming()?; let runtime = core.query_runtime()?; - let db = core.db().await; - let (host, token) = get_credentials(&db, &account_id)?; + let (host, token) = core.credentials(&account_id)?; streaming.connect(&account_id, &host, &token).await?; let tl_key = TimelineKey::Channel { @@ -116,8 +112,7 @@ pub async fn query_subscribe_role( ) -> Result { let streaming = core.streaming()?; let runtime = core.query_runtime()?; - let db = core.db().await; - let (host, token) = get_credentials(&db, &account_id)?; + let (host, token) = core.credentials(&account_id)?; streaming.connect(&account_id, &host, &token).await?; let tl_key = TimelineKey::Role { @@ -138,8 +133,7 @@ pub async fn query_subscribe_role( pub async fn query_subscribe_mentions(core: &Core, account_id: String) -> Result { let streaming = core.streaming()?; let runtime = core.query_runtime()?; - let db = core.db().await; - let (host, token) = get_credentials(&db, &account_id)?; + let (host, token) = core.credentials(&account_id)?; streaming.connect(&account_id, &host, &token).await?; // mentions は QueryKey 識別子としてのみ Timeline に折り畳む。 @@ -164,8 +158,7 @@ pub async fn query_subscribe_notifications( ) -> Result { let streaming = core.streaming()?; let runtime = core.query_runtime()?; - let db = core.db().await; - let (host, token) = get_credentials(&db, &account_id)?; + let (host, token) = core.credentials(&account_id)?; streaming.connect(&account_id, &host, &token).await?; let opened = runtime.open(QueryKey::Notifications { @@ -188,8 +181,7 @@ pub async fn query_subscribe_chat_user( ) -> Result { let streaming = core.streaming()?; let runtime = core.query_runtime()?; - let db = core.db().await; - let (host, token) = get_credentials(&db, &account_id)?; + let (host, token) = core.credentials(&account_id)?; streaming.connect(&account_id, &host, &token).await?; let opened = runtime.open(QueryKey::ChatUser { @@ -213,8 +205,7 @@ pub async fn query_subscribe_chat_room( ) -> Result { let streaming = core.streaming()?; let runtime = core.query_runtime()?; - let db = core.db().await; - let (host, token) = get_credentials(&db, &account_id)?; + let (host, token) = core.credentials(&account_id)?; streaming.connect(&account_id, &host, &token).await?; let opened = runtime.open(QueryKey::ChatRoom { diff --git a/crates/notecore/src/commands/streaming.rs b/crates/notecore/src/commands/streaming.rs index 87eb8c004..43a0175c0 100644 --- a/crates/notecore/src/commands/streaming.rs +++ b/crates/notecore/src/commands/streaming.rs @@ -1,11 +1,9 @@ //! streaming のデータ系コマンド本体 (#1106 段階 0b)。各関数は `&Core` と引数を取り、 //! コマンド表 (commands/table.rs) から呼ばれる。 -use notecli::db::Database; use notecli::streaming::StreamingManager; use crate::context::Core; -use crate::credentials::get_credentials; use crate::error::Result; /// Ensure the streaming WebSocket is connected for the given account. @@ -14,18 +12,17 @@ use crate::error::Result; /// 返す (invoke の resolve は接続確立を意味しない)。接続の生死は /// stream-status イベントでフロントへ伝わる。 async fn ensure_stream_connected( - db: &Database, + core: &Core, streaming: &StreamingManager, account_id: &str, ) -> Result<()> { - let (host, token) = get_credentials(db, account_id)?; + let (host, token) = core.credentials(account_id)?; streaming.connect(account_id, &host, &token).await } pub async fn stream_connect(core: &Core, account_id: String) -> Result<()> { let streaming = core.streaming()?; - let db = core.db().await; - ensure_stream_connected(&db, streaming, &account_id).await + ensure_stream_connected(core, streaming, &account_id).await } /// アカウントの main チャンネルを張る。main はアカウント単位の共有チャンネルで、 @@ -34,8 +31,7 @@ pub async fn stream_connect(core: &Core, account_id: String) -> Result<()> { /// 通知などは配らない (チャットを開いただけで OS 通知が出ないように、#1223) pub async fn stream_subscribe_main(core: &Core, account_id: String) -> Result<()> { let streaming = core.streaming()?; - let db = core.db().await; - ensure_stream_connected(&db, streaming, &account_id).await?; + ensure_stream_connected(core, streaming, &account_id).await?; streaming.subscribe_main_chat(&account_id).await?; Ok(()) } @@ -44,8 +40,7 @@ pub async fn stream_subscribe_main(core: &Core, account_id: String) -> Result<() /// Rust なので、カラムの有無に関わらず口座セッションが張る (#330 §3.1) pub async fn subscribe_main_for_account(core: &Core, account_id: &str) -> Result<()> { let streaming = core.streaming()?; - let db = core.db().await; - ensure_stream_connected(&db, streaming, account_id).await?; + ensure_stream_connected(core, streaming, account_id).await?; streaming.subscribe_main(account_id).await?; Ok(()) } @@ -79,8 +74,7 @@ pub async fn stream_set_mode( interval_ms: Option, ) -> Result<()> { let streaming = core.streaming()?; - let db = core.db().await; - let (host, token) = get_credentials(&db, &account_id)?; + let (host, token) = core.credentials(&account_id)?; streaming .set_mode(&account_id, &host, &token, &mode, interval_ms) .await diff --git a/crates/notecore/src/commands/timeline.rs b/crates/notecore/src/commands/timeline.rs index 218ddbf07..3816257ba 100644 --- a/crates/notecore/src/commands/timeline.rs +++ b/crates/notecore/src/commands/timeline.rs @@ -155,17 +155,18 @@ pub async fn api_update_antenna( // ため spawn_blocking で退避しつつ、完了は待ってから返す — detach すると // フロントの再フェッチが ingest した直後に破棄が走り、再構築したばかりの // バケットを消すレースになる - let db = core.db().await; let account_id_owned = account_id.clone(); let key = TimelineKey::Antenna { antenna_id: updated.id.clone(), }; - let cleared = - tokio::task::spawn_blocking(move || db.clear_timeline(&account_id_owned, &key)).await; - match cleared { - Ok(Err(e)) => tracing::warn!("[cache] failed to clear antenna bucket: {e}"), - Err(e) => tracing::warn!("[cache] clear antenna bucket task failed: {e}"), - Ok(Ok(_)) => {} + if let Err(e) = core + .with_archive((), move |db, ()| { + db.clear_timeline(&account_id_owned, &key)?; + Ok(()) + }) + .await + { + tracing::warn!("[cache] failed to clear antenna bucket: {e}"); } Ok(updated) } @@ -181,10 +182,9 @@ pub async fn api_clear_timeline_cache( ) -> Result { // parse Err は Err 返却 (フロントのキー組み間違いを顕在化) let key = TimelineKey::parse(&timeline_key)?; - let db = core.db().await; - let removed = tokio::task::spawn_blocking(move || db.clear_timeline(&account_id, &key)) - .await - .map_err(|e| NoteDeckError::Internal(format!("clear_timeline task failed: {e}")))??; + let removed = core + .blocking(move |db| db.clear_timeline(&account_id, &key)) + .await?; Ok(removed.min(u32::MAX as u64) as u32) } diff --git a/crates/notecore/src/commands/user.rs b/crates/notecore/src/commands/user.rs index 4e1958269..49099e50c 100644 --- a/crates/notecore/src/commands/user.rs +++ b/crates/notecore/src/commands/user.rs @@ -394,19 +394,18 @@ pub async fn api_remove_user_from_list( // 次回フェッチで再構築 (失敗は warn + Ok)。破棄の完了は待ってから返す — // detach するとフロントの再フェッチ ingest 後に破棄が走るレースになる。 // 索引を持たないプロセス (notemaid) では何もしない - let Some(db) = core.try_db() else { - return Ok(()); - }; let account_id_owned = account_id.clone(); let key = TimelineKey::UserList { list_id: list_id.clone(), }; - let cleared = - tokio::task::spawn_blocking(move || db.clear_timeline(&account_id_owned, &key)).await; - match cleared { - Ok(Err(e)) => tracing::warn!("[cache] failed to clear user-list bucket: {e}"), - Err(e) => tracing::warn!("[cache] clear user-list bucket task failed: {e}"), - Ok(Ok(_)) => {} + if let Err(e) = core + .with_archive((), move |db, ()| { + db.clear_timeline(&account_id_owned, &key)?; + Ok(()) + }) + .await + { + tracing::warn!("[cache] failed to clear user-list bucket: {e}"); } Ok(()) } diff --git a/crates/notecore/src/context.rs b/crates/notecore/src/context.rs index 42725d15d..348f982a7 100644 --- a/crates/notecore/src/context.rs +++ b/crates/notecore/src/context.rs @@ -160,8 +160,10 @@ impl Core { !self.db_absent.load(std::sync::atomic::Ordering::Acquire) } - /// 今すぐ使える DB (待たない)。初期化前と DB を持たない構成では None - pub fn try_db(&self) -> Option> { + /// 今すぐ使える DB (待たない)。初期化前と DB を持たない構成では None。 + /// 同期の rusqlite を async から直接触らせないため crate の外には出さない + /// (#1098 §7)。外からは `blocking` / `with_archive` を通す + pub(crate) fn try_db(&self) -> Option> { self.db_rx.borrow().clone() } @@ -371,7 +373,9 @@ impl Core { } /// Await until DB is ready (fast path — does not wait for MisskeyClient). - pub async fn db(&self) -> Arc { + /// 生のハンドルは crate の中だけ (#1098 §7)。コマンド本体は `blocking` / + /// `with_archive` で blocking スレッドに寄せ、async の worker で rusqlite を回さない + pub(crate) async fn db(&self) -> Arc { let mut rx = self.db_rx.clone(); let r = rx.wait_for(|v| v.is_some()).await.unwrap(); Arc::clone(r.as_ref().unwrap()) @@ -413,6 +417,23 @@ impl Core { self.blocking(move |db| f(db, value)).await } + /// 索引への書込を待たずに blocking スレッドで回す (fire-and-forget)。取得した + /// 応答をキャッシュへ写すような「返事を遅らせたくない」書込用。索引が無ければ + /// 何もしない。失敗は warn に落とす + pub fn spawn_archive_write(&self, f: F) + where + F: FnOnce(&Database) -> Result<()> + Send + 'static, + { + let Some(db) = self.try_db() else { + return; + }; + tokio::task::spawn_blocking(move || { + if let Err(e) = f(&db) { + tracing::warn!(error = %e, "[archive] background write failed"); + } + }); + } + /// Await until the Misskey client is set (DB を持たない構成でも待てる)。 pub async fn client(&self) -> Arc { let mut rx = self.rx.clone(); @@ -427,10 +448,23 @@ impl Core { Arc::clone(&r.as_ref().unwrap().server_info) } - /// `client()` + `get_credentials` の定型を 1 行に畳む (#782 R2)。DB を持たない構成でも動く。 + /// 口座の資格情報 (host, token)。口座の所在 (`AccountStore`: アプリでは DB、 + /// notemaid では写し) を資格情報キャッシュ越しに引く。キャッシュ miss のときだけ + /// 1 行の短い読みが走るので worker で許容する (#1098 §7)。コマンド本体が + /// `db()` を取って `get_credentials` に渡す形はこれに置き換える + pub fn credentials(&self, account_id: &str) -> Result<(String, String)> { + get_credentials(&*self.accounts()?, account_id) + } + + /// 匿名フォールバック版 (公開エンドポイント用)。 + pub fn credentials_or_anon(&self, account_id: &str) -> Result<(String, String)> { + get_credentials_or_anon(&*self.accounts()?, account_id) + } + + /// `client()` + `credentials` の定型を 1 行に畳む (#782 R2)。DB を持たない構成でも動く。 pub async fn authed(&self, account_id: &str) -> Result<(Arc, String, String)> { let client = self.client().await; - let (host, token) = get_credentials(&*self.accounts()?, account_id)?; + let (host, token) = self.credentials(account_id)?; Ok((client, host, token)) } @@ -440,7 +474,7 @@ impl Core { account_id: &str, ) -> Result<(Arc, String, String)> { let client = self.client().await; - let (host, token) = get_credentials_or_anon(&*self.accounts()?, account_id)?; + let (host, token) = self.credentials_or_anon(account_id)?; Ok((client, host, token)) } } diff --git a/crates/notemaid/Cargo.toml b/crates/notemaid/Cargo.toml index a58cc5763..1799396d5 100644 --- a/crates/notemaid/Cargo.toml +++ b/crates/notemaid/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notemaid" -version = "1.98.0" +version = "1.98.1" edition = "2021" description = "NoteDeck の AI (メイド): エージェントループ / HEARTBEAT / capability の実行 / セッション / skill / メモ。アプリが使うライブラリと、別プロセスとして走るバイナリの 1 クレート (#1106)" repository = "https://github.com/notedeck-dev/notedeck" diff --git a/package.json b/package.json index b3763ff42..bd3b2d39c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "notedeck", "description": "Misskey Pro — integrated deck environment (IDE) for Misskey power users", "private": true, - "version": "1.98.0", + "version": "1.98.1", "type": "module", "packageManager": "pnpm@11.18.0", "engines": { diff --git a/scripts/gen-capabilities.mjs b/scripts/gen-capabilities.mjs index c2a6a0739..9a395e095 100644 --- a/scripts/gen-capabilities.mjs +++ b/scripts/gen-capabilities.mjs @@ -172,8 +172,11 @@ export function renderTs(decls) { '// 生成は `pnpm gen:capabilities` (scripts/gen-capabilities.mjs)。', '// 生成物が最新かは tests/lint/capabilityDeclarations.test.ts が検査する (#1133)。', '', - "import type { Command } from '@/commands/registry'", - "import type { ParameterDef, ReturnTypeDef } from '@/capabilities/types'", + "import type {", + ' CapabilityCategory,', + ' ParameterDef,', + ' ReturnTypeDef,', + "} from '@/capabilities/types'", "import type { PermissionKey } from '@/permissions/schema'", '', '/** capability の実行属性 (#1106 §4.8): core = notecore 単独で実行できる /', @@ -183,7 +186,7 @@ export function renderTs(decls) { 'export interface CapabilityDeclaration {', ' id: CapabilityId', ' label: string', - " category: Command['category']", + ' category: CapabilityCategory', ' icon: string', ' permissions: readonly PermissionKey[]', ' aiTool: boolean', diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 53ab6cd5d..8df6cf8ea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notedeck" -version = "1.98.0" +version = "1.98.1" description = "Misskey Pro — integrated deck environment (IDE) for Misskey power users" edition = "2021" license = "AGPL-3.0-only" diff --git a/src-tauri/openapi.json b/src-tauri/openapi.json index a56378fc6..db2070dbd 100644 --- a/src-tauri/openapi.json +++ b/src-tauri/openapi.json @@ -6,7 +6,7 @@ "license": { "name": "MIT" }, - "version": "1.98.0" + "version": "1.98.1" }, "paths": { "/api": { diff --git a/src-tauri/src/commands/resident.rs b/src-tauri/src/commands/resident.rs index 17f00471d..309f57a2b 100644 --- a/src-tauri/src/commands/resident.rs +++ b/src-tauri/src/commands/resident.rs @@ -68,7 +68,7 @@ pub async fn core_set_resident( pub async fn core_sync_accounts( app_state: tauri::State<'_, super::AppState>, ) -> Result<(), String> { - if let Ok(list) = app_state.db().await.load_accounts() { + if let Ok(list) = app_state.blocking(|db| db.load_accounts()).await { notemaid::redact::register_misskey_tokens(&list); } if let Some(relay) = crate::client_layer::relay() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a653ffee..9799f462b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -377,8 +377,7 @@ fn run_inner() -> Result<(), Box> { // 口座の行をそのまま写す (トークン列を含む)。キーチェーンが使える環境では列は空で // notemaid もキーチェーンから読む。無い環境 (WSL2 など) ではこの列が唯一の経路で、 // アプリ自身の DB と同じ保護水準 (同じデータディレクトリ、同じユーザー) - let db = core.db().await; - match db.load_accounts() { + match core.blocking(|db| db.load_accounts()).await { Ok(list) => { notemaid::redact::register_misskey_tokens(&list); let outcome = relay diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8364781d4..d98da6d3d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", "productName": "NoteDeck", - "version": "1.98.0", + "version": "1.98.1", "identifier": "com.notedeck.desktop", "build": { "frontendDist": "../dist", diff --git a/src/App.vue b/src/App.vue index d22a00c28..70a13e159 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,7 +16,6 @@ import { initKeyboardInset } from '@/composables/useKeyboardInset' import { useMuteSync } from '@/composables/useMuteSync' import { useOsUnreadBadge } from '@/composables/useOsUnreadBadge' import { useOsWindowTitle } from '@/composables/useOsWindowTitle' -import { listenPipEvents } from '@/composables/usePipWindow' import { usePushAttention } from '@/composables/usePushAttention' import { useRenoteMuteSync } from '@/composables/useRenoteMuteSync' import { startSettingsFileSync } from '@/composables/useSettingsFileSync' @@ -30,6 +29,7 @@ import { useSystemStateStore } from '@/stores/systemState' import { useIsCompactLayout, useUiStore } from '@/stores/ui' import { exitSafeMode, readSafeMode } from '@/utils/safeMode' import { markStartup } from '@/utils/startupTrace' +import { listenPipEvents } from '@/windows/pip' const uiStore = useUiStore() const { isTauri, isDesktop } = uiStore diff --git a/src/adapters/factory.ts b/src/adapters/factory.ts index 4164d67ff..580537157 100644 --- a/src/adapters/factory.ts +++ b/src/adapters/factory.ts @@ -1,9 +1,39 @@ -import { useEmojisStore } from '@/stores/emojis' -import { usePinnedReactionsStore } from '@/stores/pinnedReactions' -import { useServersStore } from '@/stores/servers' import { createAdapter } from './registry' import type { ServerAdapter, ServerInfo } from './types' +/** + * factory が外側 (store) に頼む仕事。adapters 層は stores を import しないので + * (#1098)、アプリの起動時に `configureAdapterFactory` で渡す + */ +export interface AdapterFactoryDeps { + /** ホストのサーバー情報 (キャッシュ済みならそれ) */ + getServerInfo(host: string): Promise + /** + * adapter を返すたびに呼ぶ (作った直後もキャッシュ済みも)。絵文字辞書や + * ピン留めリアクションの温めなど、冪等な副作用の置き場 + */ + onAdapterReady?( + adapter: ServerAdapter, + ctx: { host: string; accountId: string; hasToken: boolean }, + options?: { pinnedReactions?: boolean }, + ): void +} + +let deps: AdapterFactoryDeps | null = null + +export function configureAdapterFactory(next: AdapterFactoryDeps): void { + deps = next +} + +function requireDeps(): AdapterFactoryDeps { + if (!deps) { + throw new Error( + 'adapter factory is not configured: call configureAdapterFactory() at startup', + ) + } + return deps +} + export interface InitAdapterResult { adapter: ServerAdapter serverInfo: ServerInfo @@ -31,7 +61,11 @@ export async function initAdapterFor( const cached = adapterCache.get(accountId) if (cached) { const serverInfo = cached.serverInfo - ensureSideEffects(host, accountId, cached, hasToken, options) + requireDeps().onAdapterReady?.( + cached, + { host, accountId, hasToken }, + options, + ) return { adapter: cached, serverInfo } } @@ -40,12 +74,12 @@ export async function initAdapterFor( if (inflight) return inflight const promise = (async (): Promise => { - const serversStore = useServersStore() - const serverInfo = await serversStore.getServerInfo(host) + const d = requireDeps() + const serverInfo = await d.getServerInfo(host) const adapter = createAdapter(serverInfo, accountId, hasToken) adapterCache.set(accountId, adapter) - ensureSideEffects(host, accountId, adapter, hasToken, options) + d.onAdapterReady?.(adapter, { host, accountId, hasToken }, options) return { adapter, serverInfo } })() @@ -58,25 +92,6 @@ export async function initAdapterFor( } } -/** Kick off emoji / pinned-reaction loading (idempotent) */ -function ensureSideEffects( - host: string, - accountId: string, - adapter: ServerAdapter, - hasToken: boolean, - options?: { pinnedReactions?: boolean }, -): void { - const emojisStore = useEmojisStore() - emojisStore.ensureLoaded(host, (opts) => adapter.api.getServerEmojis(opts)) - - if (hasToken && options?.pinnedReactions !== false) { - const pinnedReactionsStore = usePinnedReactionsStore() - pinnedReactionsStore.ensureLoaded(accountId, () => - adapter.api.getPinnedReactions(), - ) - } -} - /** * Destroy the cached adapter for an account. * Call on logout or account removal. diff --git a/src/adapters/misskey/api/chat.ts b/src/adapters/misskey/api/chat.ts index 4c0776424..d959b009d 100644 --- a/src/adapters/misskey/api/chat.ts +++ b/src/adapters/misskey/api/chat.ts @@ -2,11 +2,72 @@ import { commands } from '@/utils/tauriInvoke' import type { ChatApi, ChatMessage, PaginationOptions } from '../../types' import { type MisskeyApiContext, unwrapAny } from './context' -// 書込 (apiCreateChatMessage) と DB キャッシュ (apiGetCachedChat*) は Misskey -// 専用機能の例外として commands.* 直呼び (DeckChatColumn / useChatThreadPrefetch)。 -// adapter 経由なのは read 系 3 メソッドのみ。 export function createChatApi(ctx: MisskeyApiContext): ChatApi { return { + async createChatMessage(params: { + userId?: string | null + roomId?: string | null + text?: string | null + fileId?: string | null + }): Promise { + ctx.requireAuth() + return unwrapAny( + await commands.apiCreateChatMessage( + ctx.accountId, + params.userId ?? null, + params.roomId ?? null, + params.text ?? null, + params.fileId ?? null, + ), + ) + }, + + async deleteChatMessage(messageId: string): Promise { + ctx.requireAuth() + unwrapAny(await commands.apiDeleteChatMessage(ctx.accountId, messageId)) + }, + + async reactChatMessage(messageId: string, reaction: string): Promise { + ctx.requireAuth() + unwrapAny( + await commands.apiReactChatMessage(ctx.accountId, messageId, reaction), + ) + }, + + async unreactChatMessage( + messageId: string, + reaction: string, + ): Promise { + ctx.requireAuth() + unwrapAny( + await commands.apiUnreactChatMessage( + ctx.accountId, + messageId, + reaction, + ), + ) + }, + + async getCachedChatHistory(limit?: number): Promise { + return unwrapAny( + await commands.apiGetCachedChatHistory(ctx.accountId, limit ?? null), + ) + }, + + async getCachedChatThreadMessages( + threadId: string, + options: { untilId?: string | null; limit?: number | null } = {}, + ): Promise { + return unwrapAny( + await commands.apiGetCachedChatThreadMessages( + ctx.accountId, + threadId, + options.untilId ?? null, + options.limit ?? null, + ), + ) + }, + async getChatHistory( limit?: number, cache?: boolean | null, @@ -21,6 +82,20 @@ export function createChatApi(ctx: MisskeyApiContext): ChatApi { ) }, + async getChatRoomHistory( + limit?: number, + cache?: boolean | null, + ): Promise { + return unwrapAny( + await commands.apiGetChatHistory( + ctx.accountId, + limit ?? 100, + true, + cache ?? null, + ), + ) + }, + async getChatUserMessages( userId: string, options: PaginationOptions & { cache?: boolean | null } = {}, diff --git a/src/adapters/misskey/api/collections.ts b/src/adapters/misskey/api/collections.ts index 1a152722f..8d30708f9 100644 --- a/src/adapters/misskey/api/collections.ts +++ b/src/adapters/misskey/api/collections.ts @@ -1,3 +1,4 @@ +import type { JsonValue } from '@/bindings' import { commands } from '@/utils/tauriInvoke' import type { Antenna, @@ -106,5 +107,60 @@ export function createCollectionsApi(ctx: MisskeyApiContext): CollectionsApi { ), ) }, + + async getClip(clipId: string): Promise { + return unwrapAny(await commands.apiGetClip(ctx.accountId, { clipId })) + }, + + async createClip(name: string): Promise { + ctx.requireAuth() + return unwrapAny(await commands.apiCreateClip(ctx.accountId, { name })) + }, + + async favoriteClip(clipId: string): Promise { + ctx.requireAuth() + unwrapAny(await commands.apiFavoriteClip(ctx.accountId, { clipId })) + }, + + async unfavoriteClip(clipId: string): Promise { + ctx.requireAuth() + unwrapAny(await commands.apiUnfavoriteClip(ctx.accountId, { clipId })) + }, + + async getUserClips( + userId: string, + options: PaginationOptions = {}, + ): Promise { + const params: Record = { userId } + if (options.limit != null) params.limit = options.limit + if (options.sinceId) params.sinceId = options.sinceId + if (options.untilId) params.untilId = options.untilId + return unwrapAny(await commands.apiGetUserClips(ctx.accountId, params)) + }, + + async getList( + listId: string, + options: { forPublic?: boolean } = {}, + ): Promise { + const params: Record = { listId } + if (options.forPublic) params.forPublic = true + return unwrapAny(await commands.apiGetList(ctx.accountId, params)) + }, + + async favoriteList(listId: string): Promise { + ctx.requireAuth() + unwrapAny(await commands.apiFavoriteList(ctx.accountId, { listId })) + }, + + async unfavoriteList(listId: string): Promise { + ctx.requireAuth() + unwrapAny(await commands.apiUnfavoriteList(ctx.accountId, { listId })) + }, + + async getUserListsBy(userId?: string): Promise { + const params: Record = {} + if (userId) params.userId = userId + return unwrapAny(await commands.apiGetUserListsBy(ctx.accountId, params)) + }, } } diff --git a/src/adapters/misskey/api/drive.ts b/src/adapters/misskey/api/drive.ts index bd1d16ee7..42b98b8d9 100644 --- a/src/adapters/misskey/api/drive.ts +++ b/src/adapters/misskey/api/drive.ts @@ -69,5 +69,16 @@ export function createDriveApi(ctx: MisskeyApiContext): DriveApi { ), ) }, + + async getDriveFile(fileId: string): Promise { + return unwrapAny( + await commands.apiGetDriveFile(ctx.accountId, { fileId }), + ) + }, + + async deleteDriveFile(fileId: string): Promise { + ctx.requireAuth() + unwrapAny(await commands.apiDeleteDriveFile(ctx.accountId, fileId)) + }, } } diff --git a/src/adapters/misskey/api/notes.ts b/src/adapters/misskey/api/notes.ts index 2ccdf6e6f..bc4958c8c 100644 --- a/src/adapters/misskey/api/notes.ts +++ b/src/adapters/misskey/api/notes.ts @@ -216,5 +216,9 @@ export function createNotesApi(ctx: MisskeyApiContext): NotesApi { ), ) }, + + async getNoteRaw(noteId: string): Promise { + return unwrapAny(await commands.apiGetNoteRaw(ctx.accountId, { noteId })) + }, } } diff --git a/src/adapters/misskey/api/server.ts b/src/adapters/misskey/api/server.ts index 3bbf75928..230625953 100644 --- a/src/adapters/misskey/api/server.ts +++ b/src/adapters/misskey/api/server.ts @@ -1,3 +1,4 @@ +import type { JsonValue } from '@/bindings' import { commands, unwrap } from '@/utils/tauriInvoke' import type { Announcement, @@ -8,6 +9,7 @@ import type { GalleryPost, Page, PagesEndpoint, + PaginationOptions, ServerContentApi, ServerEmoji, } from '../../types' @@ -101,5 +103,117 @@ export function createServerContentApi( await commands.apiGetFederationInstance(ctx.accountId, { host }), ) }, + + async getMetaDetail(): Promise { + return unwrap(await commands.apiGetMetaDetail(ctx.accountId)) + }, + + async getServerStats(): Promise { + return unwrap(await commands.apiGetServerStats(ctx.accountId)) + }, + + async readAnnouncement(announcementId: string): Promise { + ctx.requireAuth() + unwrap(await commands.apiReadAnnouncement(ctx.accountId, announcementId)) + }, + + async likePage(pageId: string): Promise { + ctx.requireAuth() + unwrap(await commands.apiLikePage(ctx.accountId, pageId)) + }, + + async unlikePage(pageId: string): Promise { + ctx.requireAuth() + unwrap(await commands.apiUnlikePage(ctx.accountId, pageId)) + }, + + async updatePage(params: JsonValue): Promise { + ctx.requireAuth() + return unwrap(await commands.apiUpdatePage(ctx.accountId, params)) + }, + + async likeFlash(flashId: string): Promise { + ctx.requireAuth() + unwrap(await commands.apiLikeFlash(ctx.accountId, flashId)) + }, + + async unlikeFlash(flashId: string): Promise { + ctx.requireAuth() + unwrap(await commands.apiUnlikeFlash(ctx.accountId, flashId)) + }, + + async updateFlash(params: JsonValue): Promise { + ctx.requireAuth() + return unwrap(await commands.apiUpdateFlash(ctx.accountId, params)) + }, + + async likeGalleryPost(postId: string): Promise { + ctx.requireAuth() + unwrap(await commands.apiLikeGalleryPost(ctx.accountId, postId)) + }, + + async unlikeGalleryPost(postId: string): Promise { + ctx.requireAuth() + unwrap(await commands.apiUnlikeGalleryPost(ctx.accountId, postId)) + }, + + async getUserPagesBy( + userId: string, + options: PaginationOptions = {}, + ): Promise { + return unwrap( + await commands.apiGetUserPagesBy( + ctx.accountId, + userPagination(userId, options), + ), + ) + }, + + async getUserFlashs( + userId: string, + options: PaginationOptions = {}, + ): Promise { + return unwrapAny( + await commands.apiGetUserFlashs( + ctx.accountId, + userPagination(userId, options), + ), + ) + }, + + async getUserGalleryBy( + userId: string, + options: PaginationOptions = {}, + ): Promise { + return unwrap( + await commands.apiGetUserGalleryBy( + ctx.accountId, + userPagination(userId, options), + ), + ) + }, + + async apShow(uri: string): Promise { + return unwrap(await commands.apiApShow(ctx.accountId, uri)) + }, + + async request( + endpoint: string, + params: JsonValue | null, + ): Promise { + return unwrap(await commands.apiRequest(ctx.accountId, endpoint, params)) + }, } } + +/** users/pages, users/flashs, users/gallery/posts に共通の引数 */ +function userPagination( + userId: string, + options: PaginationOptions, +): Record { + const params: Record = { userId } + if (options.limit != null) params.limit = options.limit + if (options.sinceId) params.sinceId = options.sinceId + if (options.untilId) params.untilId = options.untilId + return params +} diff --git a/src/adapters/misskey/api/users.ts b/src/adapters/misskey/api/users.ts index 72300c4ca..a5d54c2c7 100644 --- a/src/adapters/misskey/api/users.ts +++ b/src/adapters/misskey/api/users.ts @@ -1,4 +1,4 @@ -import type { Muting } from '@/bindings' +import type { JsonValue, Muting } from '@/bindings' import { type MutedUser, parseMutingExpiry } from '@/services/muteExpiry' import { commands } from '@/utils/tauriInvoke' import type { @@ -9,7 +9,9 @@ import type { NormalizedUserDetail, NotesApi, PaginationOptions, + SearchUsersParams, UserNotesOptions, + UserReaction, UserRelation, UsersApi, } from '../../types' @@ -239,5 +241,86 @@ export function createUsersApi( ctx.requireAuth() unwrapAny(await commands.apiReportUser(ctx.accountId, userId, comment)) }, + + async getSelf(): Promise { + ctx.requireAuth() + return unwrapAny(await commands.apiGetSelf(ctx.accountId)) + }, + + async updateUserSetting(key: string, value: boolean): Promise { + ctx.requireAuth() + unwrapAny(await commands.apiUpdateUserSetting(ctx.accountId, key, value)) + }, + + async getUserRaw( + params: { userId: string } | { userIds: string[] }, + ): Promise { + return unwrapAny(await commands.apiGetUserRaw(ctx.accountId, params)) + }, + + async getUserReactions( + userId: string, + options: PaginationOptions = {}, + ): Promise { + const params: Record = { userId } + if (options.limit != null) params.limit = options.limit + if (options.sinceId) params.sinceId = options.sinceId + if (options.untilId) params.untilId = options.untilId + return unwrapAny( + await commands.apiGetUserReactions(ctx.accountId, params), + ) + }, + + async getUserAchievements(userId: string): Promise { + return unwrapAny( + await commands.apiGetUserAchievements(ctx.accountId, userId), + ) + }, + + async searchUsers(params: SearchUsersParams): Promise { + return unwrapAny( + await commands.apiSearchUsers( + ctx.accountId, + params.query ?? null, + params.origin ?? null, + params.sort ?? null, + params.state ?? null, + params.limit ?? null, + params.offset ?? null, + ), + ) + }, + + async getRoles(): Promise { + return unwrapAny(await commands.apiGetRoles(ctx.accountId)) + }, + + async getRoleUsers( + roleId: string, + options: { limit?: number; offset?: number } = {}, + ): Promise { + return unwrapAny( + await commands.apiGetRoleUsers( + ctx.accountId, + roleId, + options.limit ?? null, + options.offset ?? null, + ), + ) + }, + + async getFollowRequests(limit?: number): Promise { + ctx.requireAuth() + return unwrapAny( + await commands.apiGetFollowRequests(ctx.accountId, limit ?? null), + ) + }, + + async getSentFollowRequests(limit?: number): Promise { + ctx.requireAuth() + return unwrapAny( + await commands.apiGetSentFollowRequests(ctx.accountId, limit ?? null), + ) + }, } } diff --git a/src/adapters/types.ts b/src/adapters/types.ts index 9fadeb06e..b6fc5b007 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -523,9 +523,15 @@ import type { FederationInstance } from '@/bindings' export type { FederationInstance } // Pages / Gallery / Flash 型も specta 経由で Rust 側から自動生成される。 -import type { Flash, GalleryPost, Page } from '@/bindings' +import type { + Flash, + GalleryPost, + JsonValue, + Page, + UserReaction, +} from '@/bindings' -export type { Flash, GalleryPost, Page } +export type { Flash, GalleryPost, Page, UserReaction } // ワードミュート(#610)。mutedWords / hardMutedWords は notecli が `i` から取得。 import type { MutedWord, MutedWordsResult } from '@/bindings' @@ -623,6 +629,8 @@ export interface NotesApi { roleId: string, options?: PaginationOptions, ): Promise + /** サーバー応答そのまま (notes/show)。インスペクタの Raw JSON 表示用 */ + getNoteRaw(noteId: string): Promise } /** ユーザーの取得・フォロー関係・ミュート/ブロック/通報 (users/* + following/*) */ @@ -675,6 +683,40 @@ export interface UsersApi { blockUser(userId: string): Promise unblockUser(userId: string): Promise reportUser(userId: string, comment: string): Promise + /** 自分のユーザー情報そのまま (i)。isAdmin / isModerator 等の生フィールド用 */ + getSelf(): Promise + /** 自分の設定の真偽値 1 つを更新する (i/update) */ + updateUserSetting(key: string, value: boolean): Promise + /** サーバー応答そのまま (users/show)。単体 (userId) と複数 (userIds) の両形 */ + getUserRaw( + params: { userId: string } | { userIds: string[] }, + ): Promise + /** ユーザーの公開リアクション (users/reactions) */ + getUserReactions( + userId: string, + options?: PaginationOptions, + ): Promise + getUserAchievements(userId: string): Promise + /** users (ユーザー一覧 / 検索)。サーバー応答そのまま */ + searchUsers(params: SearchUsersParams): Promise + getRoles(): Promise + getRoleUsers( + roleId: string, + options?: { limit?: number; offset?: number }, + ): Promise + /** 自分宛てのフォローリクエスト (following/requests/list)。サーバー応答そのまま */ + getFollowRequests(limit?: number): Promise + /** 自分が送ったフォローリクエスト (following/requests/sent)。サーバー応答そのまま */ + getSentFollowRequests(limit?: number): Promise +} + +export interface SearchUsersParams { + query?: string | null + origin?: string | null + sort?: string | null + state?: string | null + limit?: number | null + offset?: number | null } /** リスト・アンテナ・クリップ・チャンネル (ノートを束ねるコレクション系) */ @@ -703,15 +745,31 @@ export interface CollectionsApi { channelId: string, options?: PaginationOptions, ): Promise + getClip(clipId: string): Promise + createClip(name: string): Promise + favoriteClip(clipId: string): Promise + unfavoriteClip(clipId: string): Promise + /** 他人の公開クリップ (users/clips)。自分のは getClips */ + getUserClips(userId: string, options?: PaginationOptions): Promise + /** `forPublic` は他人の公開リストを引くとき */ + getList(listId: string, options?: { forPublic?: boolean }): Promise + favoriteList(listId: string): Promise + unfavoriteList(listId: string): Promise + /** 公開リスト一覧 (users/lists/list)。userId 省略で自分の */ + getUserListsBy(userId?: string): Promise } /** - * チャット (Misskey v2025 Chat API #15686) の read 系。 - * 書込・DB キャッシュは Misskey 専用機能として `commands.*` 直呼びが例外的に - * 認められた経路 (DeckChatColumn / useChatThreadPrefetch)。 + * チャット (Misskey v2025 Chat API #15686)。Misskey 専用機能なのでフォーク差異は + * 想定せず、書込と手元の DB キャッシュの読取もここに置く (#1258)。 */ export interface ChatApi { getChatHistory(limit?: number, cache?: boolean | null): Promise + /** ルームの履歴 (chat/history room=true)。ルーム未対応のサーバーは投げる */ + getChatRoomHistory( + limit?: number, + cache?: boolean | null, + ): Promise getChatUserMessages( userId: string, options?: PaginationOptions & { cache?: boolean | null }, @@ -720,6 +778,22 @@ export interface ChatApi { roomId: string, options?: PaginationOptions & { cache?: boolean | null }, ): Promise + /** userId (DM) か roomId のどちらか。text / fileId はどちらか一方でも送れる */ + createChatMessage(params: { + userId?: string | null + roomId?: string | null + text?: string | null + fileId?: string | null + }): Promise + deleteChatMessage(messageId: string): Promise + reactChatMessage(messageId: string, reaction: string): Promise + unreactChatMessage(messageId: string, reaction: string): Promise + /** 手元の DB キャッシュだけを読む (サーバーへ行かない) */ + getCachedChatHistory(limit?: number): Promise + getCachedChatThreadMessages( + threadId: string, + options?: { untilId?: string | null; limit?: number | null }, + ): Promise } /** 通知 (i/notifications) */ @@ -746,6 +820,9 @@ export interface DriveApi { isSensitive?: boolean, folderId?: string | null, ): Promise + /** サーバー応答そのまま (drive/files/show)。normalizeDriveFile で整える */ + getDriveFile(fileId: string): Promise + deleteDriveFile(fileId: string): Promise } /** アクティビティチャート (charts/*) */ @@ -812,6 +889,32 @@ export interface ServerContentApi { params?: FederationInstancesParams, ): Promise getFederationInstance(host: string): Promise + /** サーバー情報の詳細 (meta detail=true)。サーバー応答そのまま */ + getMetaDetail(): Promise + /** サーバー統計 (stats)。サーバー応答そのまま */ + getServerStats(): Promise + readAnnouncement(announcementId: string): Promise + likePage(pageId: string): Promise + unlikePage(pageId: string): Promise + /** pages/update。params はサーバーの引数そのまま */ + updatePage(params: JsonValue): Promise + likeFlash(flashId: string): Promise + unlikeFlash(flashId: string): Promise + /** flash/update。params はサーバーの引数そのまま */ + updateFlash(params: JsonValue): Promise + likeGalleryPost(postId: string): Promise + unlikeGalleryPost(postId: string): Promise + getUserPagesBy(userId: string, options?: PaginationOptions): Promise + /** エンドポイント名は本家のスペルどおり users/flashs */ + getUserFlashs(userId: string, options?: PaginationOptions): Promise + getUserGalleryBy( + userId: string, + options?: PaginationOptions, + ): Promise + /** ActivityPub オブジェクトの照会 (ap/show)。サーバー応答そのまま */ + apShow(uri: string): Promise + /** 任意エンドポイントの生呼び出し。API コンソールとレジストリ駆動の作成用 */ + request(endpoint: string, params: JsonValue | null): Promise } /** diff --git a/src/aiscript/notedeck-api.test.ts b/src/aiscript/notedeck-api.test.ts index 2cb6d0c4e..55416ebb8 100644 --- a/src/aiscript/notedeck-api.test.ts +++ b/src/aiscript/notedeck-api.test.ts @@ -3,8 +3,10 @@ import type { Value } from '@syuilo/aiscript/interpreter/value.js' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { _clearCapabilitiesForTest, + getCapability, registerCapability, } from '@/capabilities/registry' +import type { Capability } from '@/capabilities/types' import type { Command, useCommandStore } from '@/commands/registry' import type { Principal } from '@/permissions/principal' import { setPermissionPreset } from '@/permissions/schema' @@ -88,13 +90,12 @@ function setPresetForTest(preset: 'readonly' | 'safe' | 'full'): void { ) } -function makeCapability(overrides: Partial = {}): Command { +function makeCapability(overrides: Partial = {}): Capability { return { id: 'test.cap', label: 'test', icon: 'ti-flask', category: 'general', - shortcuts: [], aiTool: true, permissions: [], signature: { description: 'test capability' }, @@ -176,10 +177,8 @@ describe('Nd:register_command (4-arg legacy form)', () => { expect(cmd.label).toBe('Greet') expect(cmd.icon).toBe('ti-hand') expect(cmd.category).toBe('general') - expect(cmd.aiTool).toBeUndefined() - expect(cmd.signature).toBeUndefined() - expect(cmd.permissions).toBeUndefined() - expect(cmd.requiresConfirmation).toBeUndefined() + // options 無しは capability にならない + expect(getCapability('acme.clock:greet')).toBeUndefined() }) }) @@ -210,7 +209,7 @@ describe('Nd:register_command (5-arg with options)', () => { expect(register).not.toHaveBeenCalled() }) - it('forwards aiTool/signature/permissions/requiresConfirmation to commandStore', async () => { + it('aiTool と signature を宣言した options は capability としても登録される', async () => { const options = utils.jsToVal({ aiTool: true, permissions: ['notes.write'], @@ -235,14 +234,15 @@ describe('Nd:register_command (5-arg with options)', () => { options, ]) expect(register).toHaveBeenCalledTimes(1) - const cmd = nthCommand(register, 0) - expect(cmd.aiTool).toBe(true) - expect(cmd.permissions).toEqual(['notes.write']) - expect(cmd.requiresConfirmation).toBe(true) - expect(cmd.signature?.description).toBe('Reverse the input string') - expect(cmd.signature?.params?.text?.type).toBe('string') - expect(cmd.signature?.returns?.type).toBe('string') - expect(cmd.signature?.cheap).toBe(true) + expect(nthCommand(register, 0).id).toBe('acme.clock:reverse') + const cap = getCapability('acme.clock:reverse') + expect(cap?.aiTool).toBe(true) + expect(cap?.permissions).toEqual(['notes.write']) + expect(cap?.requiresConfirmation).toBe(true) + expect(cap?.signature.description).toBe('Reverse the input string') + expect(cap?.signature.params?.text?.type).toBe('string') + expect(cap?.signature.returns?.type).toBe('string') + expect(cap?.signature.cheap).toBe(true) }) it('keeps aiTool false when omitted, even if signature is present', async () => { @@ -256,9 +256,9 @@ describe('Nd:register_command (5-arg with options)', () => { dummyHandler, options, ]) - const cmd = nthCommand(register, 0) - expect(cmd.aiTool).toBeUndefined() - expect(cmd.signature?.description).toBe('no ai exposure') + expect(nthCommand(register, 0).id).toBe('acme.clock:local-only') + // signature だけで aiTool が無ければ capability にはならない + expect(getCapability('acme.clock:local-only')).toBeUndefined() }) it('drops non-string entries from permissions array', async () => { @@ -274,8 +274,10 @@ describe('Nd:register_command (5-arg with options)', () => { dummyHandler, options, ]) - const cmd = nthCommand(register, 0) - expect(cmd.permissions).toEqual(['notes.read', 'notifications']) + expect(getCapability('acme.clock:mixed')?.permissions).toEqual([ + 'notes.read', + 'notifications', + ]) }) it('ignores a non-object options argument', async () => { @@ -286,9 +288,8 @@ describe('Nd:register_command (5-arg with options)', () => { dummyHandler, values.STR('not-an-object'), ]) - const cmd = nthCommand(register, 0) - expect(cmd.aiTool).toBeUndefined() - expect(cmd.signature).toBeUndefined() + expect(nthCommand(register, 0).id).toBe('acme.clock:plain') + expect(getCapability('acme.clock:plain')).toBeUndefined() }) }) @@ -707,18 +708,24 @@ describe('Nd:register_command — 呼び出し元の伝播 (#1099)', () => { } as unknown as Interpreter } + afterEach(() => { + _clearCapabilitiesForTest() + }) + /** capability (dispatcher 経由の実行体) とパレットの Command の両方を返す */ async function registerOne( stores: ReturnType, - ): Promise { + ): Promise<{ cap: Capability; cmd: Command }> { const env = createNoteDeckEnv(stores.ctx) await callRegisterCommand(env, [ values.STR('cmd'), values.STR('Cmd'), values.STR('ti-x'), values.FN_NATIVE(() => values.NULL), - utils.jsToVal({ aiTool: true }), + utils.jsToVal({ aiTool: true, signature: { description: 'd' } }), ]) - return nthCommand(stores.register, 0) + const cap = getCapability('acme.clock:cmd') + if (!cap) throw new Error('capability not registered') + return { cap, cmd: nthCommand(stores.register, 0) } } it('dispatcher 経由の実行中は呼び出し元が積まれ、終了後に降りる', async () => { @@ -727,8 +734,8 @@ describe('Nd:register_command — 呼び出し元の伝播 (#1099)', () => { stores.ctx.interpreter = fakeInterpreter(() => { seen = [...stores.ctx.callers] }) - const cmd = await registerOne(stores) - const result = await cmd.execute( + const { cap } = await registerOne(stores) + const result = await cap.execute( { x: 1 }, { principal: { kind: 'ai.chat' } }, ) @@ -744,8 +751,8 @@ describe('Nd:register_command — 呼び出し元の伝播 (#1099)', () => { seen = [...stores.ctx.callers] }) stores.ctx.interpreter = interp - const cmd = await registerOne(stores) - await cmd.execute(undefined, { principal: { kind: 'external' } }) + const { cap } = await registerOne(stores) + await cap.execute(undefined, { principal: { kind: 'external' } }) expect(seen).toEqual([{ kind: 'external' }]) expect(interp.execFnSimple).toHaveBeenCalledWith(expect.anything(), []) }) @@ -756,8 +763,8 @@ describe('Nd:register_command — 呼び出し元の伝播 (#1099)', () => { stores.ctx.interpreter = fakeInterpreter(() => { seen = [...stores.ctx.callers] }) - const cmd = await registerOne(stores) - await cmd.execute( + const { cap } = await registerOne(stores) + await cap.execute( { x: 1 }, { principal: { kind: 'plugin', pluginId: 'other' }, @@ -779,9 +786,9 @@ describe('Nd:register_command — 呼び出し元の伝播 (#1099)', () => { throw new Error('boom') }), } as unknown as Interpreter - const cmd = await registerOne(stores) + const { cap } = await registerOne(stores) await expect( - cmd.execute({ x: 1 }, { principal: { kind: 'ai.chat' } }), + cap.execute({ x: 1 }, { principal: { kind: 'ai.chat' } }), ).rejects.toThrow('boom') expect(stores.ctx.callers).toEqual([]) }) @@ -792,8 +799,8 @@ describe('Nd:register_command — 呼び出し元の伝播 (#1099)', () => { stores.ctx.interpreter = fakeInterpreter(() => { seen = [...stores.ctx.callers] }) - const cmd = await registerOne(stores) - await cmd.execute({ x: 1 }, { principal: { kind: 'user' } }) + const { cap } = await registerOne(stores) + await cap.execute({ x: 1 }, { principal: { kind: 'user' } }) expect(seen).toEqual([]) }) @@ -801,7 +808,7 @@ describe('Nd:register_command — 呼び出し元の伝播 (#1099)', () => { const stores = makeFakeStores() const interp = fakeInterpreter(noop) stores.ctx.interpreter = interp - const cmd = await registerOne(stores) + const { cmd } = await registerOne(stores) await cmd.execute() expect(interp.execFn).toHaveBeenCalledWith(expect.anything(), []) expect(interp.execFnSimple).not.toHaveBeenCalled() diff --git a/src/aiscript/notedeck-api.ts b/src/aiscript/notedeck-api.ts index 51089ed49..d9797ef65 100644 --- a/src/aiscript/notedeck-api.ts +++ b/src/aiscript/notedeck-api.ts @@ -2,8 +2,16 @@ import type { Interpreter } from '@syuilo/aiscript' import { utils, values } from '@syuilo/aiscript' import type { Value, VFn } from '@syuilo/aiscript/interpreter/value.js' import { dispatchCapability } from '@/capabilities/dispatcher' -import { listCapabilities } from '@/capabilities/registry' -import type { CapabilitySignature, PermissionKey } from '@/capabilities/types' +import { + listCapabilities, + registerCapability, + unregisterCapability, +} from '@/capabilities/registry' +import type { + Capability, + CapabilitySignature, + PermissionKey, +} from '@/capabilities/types' import type { Command, useCommandStore } from '@/commands/registry' import type { Principal } from '@/permissions/principal' import { PERMISSION_KEYS } from '@/permissions/schema' @@ -247,6 +255,8 @@ export function createNoteDeckEnv( ) } const handler = handlerVal as VFn + + // 1. パレットの入口 (本人操作): 戻り値不要なので fire-and-forget const command: Command = { id: commandId, label: labelVal.value, @@ -255,6 +265,34 @@ export function createNoteDeckEnv( // キーバインドは keybinds.json5 の上書き (設定 GUI の「プラグイン」節 / AI の // keybinds.set) で付け、読む側が resolveShortcuts で引く (#1243) shortcuts: [], + execute: () => { + const interp = ctx.interpreter + if (!interp) { + console.warn('[Nd:register_command] interpreter not available') + return + } + try { + interp.execFn(handler, []) + } catch (e) { + console.warn('[Nd:register_command]', e) + throw e + } + }, + } + commandStore.register(command) + ctx.disposers.push(() => commandStore.unregister(commandId)) + + // 2. options で aiTool と signature を宣言したものだけ capability にもなる + // (AI tool / HTTP API / 他プラグインの Nd:call から呼べる)。Command と + // capability は別の登録で、片方だけ消えることは無い (disposers が両方外す) + if (!(parsed.aiTool && parsed.signature)) return + const capability: Capability = { + id: commandId, + label: labelVal.value, + icon: iconVal.value, + category: 'general', + aiTool: true, + signature: parsed.signature, execute: async (params, capCtx) => { const interp = ctx.interpreter if (!interp) { @@ -263,7 +301,6 @@ export function createNoteDeckEnv( } const caller = capCtx?.principal if (!caller) { - // UI コマンドパレット (本人操作): 戻り値不要なので fire-and-forget try { interp.execFn(handler, []) } catch (e) { @@ -275,9 +312,8 @@ export function createNoteDeckEnv( // dispatcher 経由 (AI tool / HTTP API / 他プラグイン): 呼び出し元を // 積んで handler の完了を待つ。handler 内の Nd:call / Nd:http / Mk:api // は「呼び出し元 ∩ plugin」で判定される (#1099)。user はプロファイルを - // 持たないので積まない。以前は params ありを同期実行 (execFnSync) - // していたが、同期実行では async native (Nd:call 等) を呼べず、 - // params なしは fire-and-forget で呼び出し元が失われていた + // 持たないので積まない。同期実行では async native (Nd:call 等) を + // 呼べないので execFnSimple で待つ const args = params && Object.keys(params).length > 0 ? [utils.jsToVal(params)] @@ -302,15 +338,12 @@ export function createNoteDeckEnv( } }, } - if (parsed.aiTool) command.aiTool = true - if (parsed.permissions) command.permissions = parsed.permissions + if (parsed.permissions) capability.permissions = parsed.permissions if (parsed.requiresConfirmation !== undefined) { - command.requiresConfirmation = parsed.requiresConfirmation + capability.requiresConfirmation = parsed.requiresConfirmation } - if (parsed.signature) command.signature = parsed.signature - - commandStore.register(command) - ctx.disposers.push(() => commandStore.unregister(commandId)) + registerCapability(capability) + ctx.disposers.push(() => unregisterCapability(commandId)) }, ) diff --git a/src/aiscript/plugin-api.test.ts b/src/aiscript/plugin-api.test.ts index 63219c461..f2e0b7113 100644 --- a/src/aiscript/plugin-api.test.ts +++ b/src/aiscript/plugin-api.test.ts @@ -4,7 +4,7 @@ import { _clearCapabilitiesForTest, registerCapability, } from '@/capabilities/registry' -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { assertMisskeyApiAllowed } from '@/permissions/misskeyApiGate' import { type Account, useAccountsStore } from '@/stores/accounts' import { useAiScriptLogsStore } from '@/stores/aiscriptLogs' @@ -478,15 +478,13 @@ describe('Mk:api account context bridging', () => { it('Nd:register_command の handler から呼ぶ Mk:api は呼び出し元の権限も検査する (#1099)', async () => { apiRequestMock.mockResolvedValue({ status: 'ok', data: { id: 'n1' } }) const plugin = await installAndLaunch( - 'Nd:register_command("call", "Call", "ti-x", @(p) { Mk:api("notes/show", { noteId: "n1" }) }, { aiTool: true })', + 'Nd:register_command("call", "Call", "ti-x", @(p) { Mk:api("notes/show", { noteId: "n1" }) }, { aiTool: true, signature: { description: "d" } })', ) - const { useCommandStore } = await import('@/commands/registry') - const cmd = [...useCommandStore().commands.values()].find((c) => - c.id.endsWith(':call'), - ) - expect(cmd).toBeDefined() + const { listCapabilities } = await import('@/capabilities/registry') + const cap = listCapabilities().find((c) => c.id.endsWith(':call')) + expect(cap).toBeDefined() await withPluginAccountContext(plugin.id, 'acc-9', () => - cmd?.execute({ x: 1 }, { principal: { kind: 'ai.chat' } }), + cap?.execute({ x: 1 }, { principal: { kind: 'ai.chat' } }), ) expect(gateMock).toHaveBeenCalledWith( { kind: 'plugin', pluginId: plugin.id, name: plugin.name }, @@ -632,13 +630,12 @@ describe('Mk:toast (プラグイン env の配線)', () => { }) describe('UI 起点の実行エラー表示 (#1072 / #1074)', () => { - function makeCapability(overrides: Partial): Command { + function makeCapability(overrides: Partial): Capability { return { id: 'test.cap', label: 'test', icon: 'ti-flask', category: 'general', - shortcuts: [], permissions: [], signature: { description: 'test capability' }, execute: () => 'ok', diff --git a/src/capabilities/builtins/account.ts b/src/capabilities/builtins/account.ts index dc181936e..f0b524e16 100644 --- a/src/capabilities/builtins/account.ts +++ b/src/capabilities/builtins/account.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -19,7 +19,7 @@ export const accountCurrentCapability = implementCore('account.current') */ export const accountListCapability = implementCore('account.list') -export const ACCOUNT_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const ACCOUNT_BUILTIN_CAPABILITIES: readonly Capability[] = [ accountCurrentCapability, accountListCapability, ] diff --git a/src/capabilities/builtins/ai.test.ts b/src/capabilities/builtins/ai.test.ts index 5d7855111..e547cbc2f 100644 --- a/src/capabilities/builtins/ai.test.ts +++ b/src/capabilities/builtins/ai.test.ts @@ -51,10 +51,6 @@ describe('ai.chat capability', () => { expect(aiChatCapability.id).toBe('ai.chat') }) - it('is visible (= コマンドパレットから手動で叩ける)', () => { - expect(aiChatCapability.visible).toBe(true) - }) - it('does NOT require per-call confirmation (permission gate で十分)', () => { expect(aiChatCapability.requiresConfirmation).toBe(false) }) diff --git a/src/capabilities/builtins/ai.ts b/src/capabilities/builtins/ai.ts index 041a2e3c5..6741162ba 100644 --- a/src/capabilities/builtins/ai.ts +++ b/src/capabilities/builtins/ai.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { sendAiChatOnce } from '@/composables/useAiChat' import { resolveAiConnection } from '@/composables/useAiConfig' import { useVault } from '@/composables/useVault' @@ -62,4 +62,4 @@ export const aiChatCapability = implement('ai.chat', { }, }) -export const AI_BUILTIN_CAPABILITIES: readonly Command[] = [aiChatCapability] +export const AI_BUILTIN_CAPABILITIES: readonly Capability[] = [aiChatCapability] diff --git a/src/capabilities/builtins/aiSessions.ts b/src/capabilities/builtins/aiSessions.ts index b5045b432..41e9e7052 100644 --- a/src/capabilities/builtins/aiSessions.ts +++ b/src/capabilities/builtins/aiSessions.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -19,7 +19,7 @@ export const aiSessionsReadCapability = implementCore('ai.sessions.read') export const aiSessionsSearchCapability = implementCore('ai.sessions.search') -export const AI_SESSIONS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const AI_SESSIONS_BUILTIN_CAPABILITIES: readonly Capability[] = [ aiSessionsListCapability, aiSessionsReadCapability, aiSessionsSearchCapability, diff --git a/src/capabilities/builtins/aiscript.ts b/src/capabilities/builtins/aiscript.ts index c1482f900..1c27f4829 100644 --- a/src/capabilities/builtins/aiscript.ts +++ b/src/capabilities/builtins/aiscript.ts @@ -1,5 +1,5 @@ import { validateAiScript } from '@/aiscript/validate' -import type { Command, PreflightFailure } from '@/commands/registry' +import type { Capability, PreflightFailure } from '@/capabilities/types' import { type AiScriptLogLevel, type AiScriptSourceKind, @@ -64,7 +64,7 @@ export const aiscriptLogsCapability = implement('aiscript.logs', { }, }) -export const AISCRIPT_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const AISCRIPT_BUILTIN_CAPABILITIES: readonly Capability[] = [ aiscriptValidateCapability, aiscriptLogsCapability, ] diff --git a/src/capabilities/builtins/announcements.ts b/src/capabilities/builtins/announcements.ts index 4fe48378c..43204b973 100644 --- a/src/capabilities/builtins/announcements.ts +++ b/src/capabilities/builtins/announcements.ts @@ -1,8 +1,8 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const announcementsListCapability = implementCore('announcements.list') -export const ANNOUNCEMENTS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const ANNOUNCEMENTS_BUILTIN_CAPABILITIES: readonly Capability[] = [ announcementsListCapability, ] diff --git a/src/capabilities/builtins/antenna.ts b/src/capabilities/builtins/antenna.ts index 8136c82a1..a2d6e621d 100644 --- a/src/capabilities/builtins/antenna.ts +++ b/src/capabilities/builtins/antenna.ts @@ -1,11 +1,11 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const antennaListCapability = implementCore('antenna.list') export const antennaNotesCapability = implementCore('antenna.notes') -export const ANTENNA_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const ANTENNA_BUILTIN_CAPABILITIES: readonly Capability[] = [ antennaListCapability, antennaNotesCapability, ] diff --git a/src/capabilities/builtins/app.ts b/src/capabilities/builtins/app.ts index ec197d8d0..ad5ed5bfb 100644 --- a/src/capabilities/builtins/app.ts +++ b/src/capabilities/builtins/app.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { useUiStore } from '@/stores/ui' import { implement } from '../declare' @@ -30,7 +30,7 @@ export const appShowCapability = implement('app.show', { }, }) -export const APP_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const APP_BUILTIN_CAPABILITIES: readonly Capability[] = [ appHideCapability, appShowCapability, ] diff --git a/src/capabilities/builtins/backup.ts b/src/capabilities/builtins/backup.ts index 281592fe5..7874df024 100644 --- a/src/capabilities/builtins/backup.ts +++ b/src/capabilities/builtins/backup.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { i18n } from '@/i18n' import { formatLocalTimestamp } from '@/services/aiSessionId' import { commands, unwrap } from '@/utils/tauriInvoke' @@ -67,6 +67,6 @@ export const backupCreateCapability = implement('backup.create', { }, }) -export const BACKUP_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const BACKUP_BUILTIN_CAPABILITIES: readonly Capability[] = [ backupCreateCapability, ] diff --git a/src/capabilities/builtins/channel.ts b/src/capabilities/builtins/channel.ts index 384c61b26..e09dbf9ee 100644 --- a/src/capabilities/builtins/channel.ts +++ b/src/capabilities/builtins/channel.ts @@ -1,11 +1,11 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const channelListCapability = implementCore('channel.list') export const channelNotesCapability = implementCore('channel.notes') -export const CHANNEL_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const CHANNEL_BUILTIN_CAPABILITIES: readonly Capability[] = [ channelListCapability, channelNotesCapability, ] diff --git a/src/capabilities/builtins/chat.ts b/src/capabilities/builtins/chat.ts index bd0c2a39b..541d5dec5 100644 --- a/src/capabilities/builtins/chat.ts +++ b/src/capabilities/builtins/chat.ts @@ -1,11 +1,11 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const chatReactCapability = implementCore('chat.react') export const chatUnreactCapability = implementCore('chat.unreact') -export const CHAT_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const CHAT_BUILTIN_CAPABILITIES: readonly Capability[] = [ chatReactCapability, chatUnreactCapability, ] diff --git a/src/capabilities/builtins/clipboard.ts b/src/capabilities/builtins/clipboard.ts index f83adc7b3..43215863b 100644 --- a/src/capabilities/builtins/clipboard.ts +++ b/src/capabilities/builtins/clipboard.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implement } from '../declare' /** @@ -32,7 +32,7 @@ export const clipboardWriteCapability = implement('clipboard.write', { }, }) -export const CLIPBOARD_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const CLIPBOARD_BUILTIN_CAPABILITIES: readonly Capability[] = [ clipboardReadCapability, clipboardWriteCapability, ] diff --git a/src/capabilities/builtins/clips.ts b/src/capabilities/builtins/clips.ts index 54f850fef..384545c15 100644 --- a/src/capabilities/builtins/clips.ts +++ b/src/capabilities/builtins/clips.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const clipsListCapability = implementCore('clips.list') @@ -11,7 +11,7 @@ export const clipsAddNoteCapability = implementCore('clips.addNote') export const clipsRemoveNoteCapability = implementCore('clips.removeNote') -export const CLIPS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const CLIPS_BUILTIN_CAPABILITIES: readonly Capability[] = [ clipsListCapability, clipsNotesCapability, clipsCreateCapability, diff --git a/src/capabilities/builtins/column.ts b/src/capabilities/builtins/column.ts index 3f564e299..208f69e97 100644 --- a/src/capabilities/builtins/column.ts +++ b/src/capabilities/builtins/column.ts @@ -1,5 +1,5 @@ +import type { Capability } from '@/capabilities/types' import { ALL_COLUMN_TYPES, COLUMN_REGISTRY } from '@/columns/registry' -import type { Command } from '@/commands/registry' import { type ColumnSource, normalizeSources } from '@/services/columnSource' import { parseVariantKey } from '@/services/noteKey' import { externalQueryPatch } from '@/services/searchFilter' @@ -383,7 +383,7 @@ export const sidebarToggleCapability = implement('sidebar.toggle', { }, }) -export const COLUMN_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const COLUMN_BUILTIN_CAPABILITIES: readonly Capability[] = [ columnActiveCapability, columnFocusedNoteCapability, columnListCapability, diff --git a/src/capabilities/builtins/drafts.ts b/src/capabilities/builtins/drafts.ts index 8fbb1d410..03c2a62a5 100644 --- a/src/capabilities/builtins/drafts.ts +++ b/src/capabilities/builtins/drafts.ts @@ -1,5 +1,5 @@ import type { NoteVisibility, PostVisibility } from '@/adapters/types' -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { type DraftData, deleteDraft, @@ -179,7 +179,7 @@ export const draftsDeleteCapability = implement('drafts.delete', { }, }) -export const DRAFTS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const DRAFTS_BUILTIN_CAPABILITIES: readonly Capability[] = [ draftsListCapability, draftsCreateCapability, draftsUpdateCapability, diff --git a/src/capabilities/builtins/drive.ts b/src/capabilities/builtins/drive.ts index 5d70d74fb..f516e92ea 100644 --- a/src/capabilities/builtins/drive.ts +++ b/src/capabilities/builtins/drive.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -7,6 +7,6 @@ import { implementCore } from '../declare' */ export const driveListCapability = implementCore('drive.list') -export const DRIVE_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const DRIVE_BUILTIN_CAPABILITIES: readonly Capability[] = [ driveListCapability, ] diff --git a/src/capabilities/builtins/favorites.ts b/src/capabilities/builtins/favorites.ts index 8a7d8eb9e..ce53fe2ff 100644 --- a/src/capabilities/builtins/favorites.ts +++ b/src/capabilities/builtins/favorites.ts @@ -1,11 +1,11 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const favoritesAddCapability = implementCore('favorites.add') export const favoritesRemoveCapability = implementCore('favorites.remove') -export const FAVORITES_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const FAVORITES_BUILTIN_CAPABILITIES: readonly Capability[] = [ favoritesAddCapability, favoritesRemoveCapability, ] diff --git a/src/capabilities/builtins/federation.ts b/src/capabilities/builtins/federation.ts index a6c97b8f3..6ceec602a 100644 --- a/src/capabilities/builtins/federation.ts +++ b/src/capabilities/builtins/federation.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -22,7 +22,7 @@ export const federationInstancesCapability = implementCore( export const federationInstanceCapability = implementCore('federation.instance') -export const FEDERATION_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const FEDERATION_BUILTIN_CAPABILITIES: readonly Capability[] = [ federationChartCapability, federationInstancesCapability, federationInstanceCapability, diff --git a/src/capabilities/builtins/files.ts b/src/capabilities/builtins/files.ts index 98d03248f..30e05a588 100644 --- a/src/capabilities/builtins/files.ts +++ b/src/capabilities/builtins/files.ts @@ -1,7 +1,7 @@ import { normalizeDriveFile } from '@/adapters/misskey/api/drive' import type { ExportFileItem, ExportProgress } from '@/bindings' import { events } from '@/bindings' -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { i18n } from '@/i18n' import { commands, unwrap } from '@/utils/tauriInvoke' import { resolveAccountId } from '../accountContext' @@ -230,6 +230,6 @@ export const filesExportCapability = implement('files.export', { }, }) -export const FILES_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const FILES_BUILTIN_CAPABILITIES: readonly Capability[] = [ filesExportCapability, ] diff --git a/src/capabilities/builtins/flash.ts b/src/capabilities/builtins/flash.ts index 6dbe661d9..a02b97791 100644 --- a/src/capabilities/builtins/flash.ts +++ b/src/capabilities/builtins/flash.ts @@ -1,11 +1,11 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const flashListCapability = implementCore('flash.list') export const flashShowCapability = implementCore('flash.show') -export const FLASH_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const FLASH_BUILTIN_CAPABILITIES: readonly Capability[] = [ flashListCapability, flashShowCapability, ] diff --git a/src/capabilities/builtins/gallery.ts b/src/capabilities/builtins/gallery.ts index 5f283935b..2fedfc008 100644 --- a/src/capabilities/builtins/gallery.ts +++ b/src/capabilities/builtins/gallery.ts @@ -1,8 +1,8 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const galleryListCapability = implementCore('gallery.list') -export const GALLERY_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const GALLERY_BUILTIN_CAPABILITIES: readonly Capability[] = [ galleryListCapability, ] diff --git a/src/capabilities/builtins/heartbeat.ts b/src/capabilities/builtins/heartbeat.ts index 437a0a536..51f5f7448 100644 --- a/src/capabilities/builtins/heartbeat.ts +++ b/src/capabilities/builtins/heartbeat.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -8,6 +8,6 @@ import { implementCore } from '../declare' */ export const heartbeatReportCapability = implementCore('heartbeat.report') -export const HEARTBEAT_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const HEARTBEAT_BUILTIN_CAPABILITIES: readonly Capability[] = [ heartbeatReportCapability, ] diff --git a/src/capabilities/builtins/http.ts b/src/capabilities/builtins/http.ts index e1a985887..a6f265c5f 100644 --- a/src/capabilities/builtins/http.ts +++ b/src/capabilities/builtins/http.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -10,6 +10,6 @@ import { implementCore } from '../declare' */ export const httpFetchCapability = implementCore('http.fetch') -export const HTTP_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const HTTP_BUILTIN_CAPABILITIES: readonly Capability[] = [ httpFetchCapability, ] diff --git a/src/capabilities/builtins/index.ts b/src/capabilities/builtins/index.ts index 518ee091d..3800d2a0f 100644 --- a/src/capabilities/builtins/index.ts +++ b/src/capabilities/builtins/index.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { ACCOUNT_BUILTIN_CAPABILITIES } from './account' import { AI_BUILTIN_CAPABILITIES } from './ai' import { AI_SESSIONS_BUILTIN_CAPABILITIES } from './aiSessions' @@ -59,7 +59,7 @@ import { WINDOWS_BUILTIN_CAPABILITIES } from './windows' * Anthropic / OpenAI の tool name 制約に合わせて `toolSchema.ts` で * `_` に変換される (`time.now` → `time_now`)。 */ -export const ALL_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const ALL_BUILTIN_CAPABILITIES: readonly Capability[] = [ ...TIME_BUILTIN_CAPABILITIES, ...APP_BUILTIN_CAPABILITIES, ...ACCOUNT_BUILTIN_CAPABILITIES, diff --git a/src/capabilities/builtins/keybinds.ts b/src/capabilities/builtins/keybinds.ts index 1461d2783..8f2da7e41 100644 --- a/src/capabilities/builtins/keybinds.ts +++ b/src/capabilities/builtins/keybinds.ts @@ -1,4 +1,5 @@ -import { type Command, useCommandStore } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' +import { useCommandStore } from '@/commands/registry' import { TASK_COMMAND_PREFIX } from '@/commands/taskCommandPrefix' import { isExposed } from '@/settings/exposure' import { useKeybindsStore } from '@/stores/keybinds' @@ -45,7 +46,7 @@ export const commandsListCapability = implement('commands.list', { }, }) -export const KEYBINDS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const KEYBINDS_BUILTIN_CAPABILITIES: readonly Capability[] = [ commandsListCapability, keybindsListCapability, keybindsSetCapability, diff --git a/src/capabilities/builtins/list.ts b/src/capabilities/builtins/list.ts index 0cf2c342a..150475689 100644 --- a/src/capabilities/builtins/list.ts +++ b/src/capabilities/builtins/list.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const listListCapability = implementCore('list.list') @@ -7,7 +7,7 @@ export const listAddUserCapability = implementCore('list.addUser') export const listRemoveUserCapability = implementCore('list.removeUser') -export const LIST_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const LIST_BUILTIN_CAPABILITIES: readonly Capability[] = [ listListCapability, listAddUserCapability, listRemoveUserCapability, diff --git a/src/capabilities/builtins/logs.ts b/src/capabilities/builtins/logs.ts index 7534ad294..0f53f96d2 100644 --- a/src/capabilities/builtins/logs.ts +++ b/src/capabilities/builtins/logs.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { type LogLevel, useLogsStore } from '@/stores/logs' import { implement } from '../declare' @@ -20,6 +20,6 @@ export const logsRecentCapability = implement('logs.recent', { }, }) -export const LOGS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const LOGS_BUILTIN_CAPABILITIES: readonly Capability[] = [ logsRecentCapability, ] diff --git a/src/capabilities/builtins/memos-read.ts b/src/capabilities/builtins/memos-read.ts index 0d827f3bf..e304957e1 100644 --- a/src/capabilities/builtins/memos-read.ts +++ b/src/capabilities/builtins/memos-read.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** `memos.list` — tag / 日付 / キーワードで絞り込んでメモを列挙 */ @@ -10,7 +10,7 @@ export const memosSearchCapability = implementCore('memos.search') /** `memos.backlinks` — 指定 memo を `[name](memo:)` で参照しているメモを返す (#494) */ export const memosBacklinksCapability = implementCore('memos.backlinks') -export const MEMOS_READ_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const MEMOS_READ_BUILTIN_CAPABILITIES: readonly Capability[] = [ memosListCapability, memosSearchCapability, memosBacklinksCapability, diff --git a/src/capabilities/builtins/memos.ts b/src/capabilities/builtins/memos.ts index 72e20e8e1..854de36ca 100644 --- a/src/capabilities/builtins/memos.ts +++ b/src/capabilities/builtins/memos.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** `memos.create` — 新規メモを作成する */ @@ -13,7 +13,7 @@ export const memosDeleteCapability = implementCore('memos.delete') /** `memos.revert` — メモを編集履歴の過去状態に戻す (#981 と同型) */ export const memosRevertCapability = implementCore('memos.revert') -export const MEMOS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const MEMOS_BUILTIN_CAPABILITIES: readonly Capability[] = [ memosCreateCapability, memosUpdateCapability, memosDeleteCapability, diff --git a/src/capabilities/builtins/meta.ts b/src/capabilities/builtins/meta.ts index 075617796..879d58346 100644 --- a/src/capabilities/builtins/meta.ts +++ b/src/capabilities/builtins/meta.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -31,7 +31,7 @@ export const metaConfigCapability = implementCore('meta.config') */ export const metaHeartbeatCapability = implementCore('meta.heartbeat') -export const META_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const META_BUILTIN_CAPABILITIES: readonly Capability[] = [ metaPermissionsCapability, metaActiveSkillsCapability, metaPersonaCapability, diff --git a/src/capabilities/builtins/metrics.ts b/src/capabilities/builtins/metrics.ts index f1c5a4207..6f8ddfa12 100644 --- a/src/capabilities/builtins/metrics.ts +++ b/src/capabilities/builtins/metrics.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { listStreamHealth, type StreamHealthSummary, @@ -109,6 +109,6 @@ export const metricsReadCapability = implement('metrics.read', { }), }) -export const METRICS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const METRICS_BUILTIN_CAPABILITIES: readonly Capability[] = [ metricsReadCapability, ] diff --git a/src/capabilities/builtins/misstore.ts b/src/capabilities/builtins/misstore.ts index c33957f73..9692848a6 100644 --- a/src/capabilities/builtins/misstore.ts +++ b/src/capabilities/builtins/misstore.ts @@ -1,8 +1,8 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const misstoreSearchCapability = implementCore('misstore.search') -export const MISSTORE_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const MISSTORE_BUILTIN_CAPABILITIES: readonly Capability[] = [ misstoreSearchCapability, ] diff --git a/src/capabilities/builtins/navbar.ts b/src/capabilities/builtins/navbar.ts index a40aeada3..9ea5e9f61 100644 --- a/src/capabilities/builtins/navbar.ts +++ b/src/capabilities/builtins/navbar.ts @@ -1,5 +1,5 @@ +import type { Capability } from '@/capabilities/types' import { isColumnType } from '@/columns/registry' -import type { Command } from '@/commands/registry' import { i18n } from '@/i18n' import { type ColumnType, type NavItem, useDeckStore } from '@/stores/deck' import { implement, implementCore } from '../declare' @@ -81,7 +81,7 @@ export const navbarSetCapability = implement('navbar.set', { export const navbarResetCapability = implementCore('navbar.reset') -export const NAVBAR_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const NAVBAR_BUILTIN_CAPABILITIES: readonly Capability[] = [ navbarListCapability, navbarSetCapability, navbarResetCapability, diff --git a/src/capabilities/builtins/notes-write.ts b/src/capabilities/builtins/notes-write.ts index 26972883e..c077dabc4 100644 --- a/src/capabilities/builtins/notes-write.ts +++ b/src/capabilities/builtins/notes-write.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** `notes.create` — 新規ノートを投稿する */ @@ -34,7 +34,7 @@ export const notesPinCapability = implementCore('notes.pin') export const notesUnpinCapability = implementCore('notes.unpin') -export const NOTES_WRITE_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const NOTES_WRITE_BUILTIN_CAPABILITIES: readonly Capability[] = [ notesCreateCapability, notesReactCapability, notesUnreactCapability, diff --git a/src/capabilities/builtins/notes.ts b/src/capabilities/builtins/notes.ts index 3ee21bf58..4a84159c9 100644 --- a/src/capabilities/builtins/notes.ts +++ b/src/capabilities/builtins/notes.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** `notes.search` — Misskey の /notes/search 経由でキーワード検索 */ @@ -26,7 +26,7 @@ export const notesShowCapability = implementCore('notes.show') /** `notes.children` — 指定ノートへのリプライ (子ノート) を取得 */ export const notesChildrenCapability = implementCore('notes.children') -export const NOTES_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const NOTES_BUILTIN_CAPABILITIES: readonly Capability[] = [ notesSearchCapability, notesSearchArchiveCapability, notesTimelineCapability, diff --git a/src/capabilities/builtins/notifications.ts b/src/capabilities/builtins/notifications.ts index db1ddd787..acb8fce98 100644 --- a/src/capabilities/builtins/notifications.ts +++ b/src/capabilities/builtins/notifications.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -17,7 +17,7 @@ export const notificationsMarkReadCapability = implementCore( 'notifications.markRead', ) -export const NOTIFICATIONS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const NOTIFICATIONS_BUILTIN_CAPABILITIES: readonly Capability[] = [ notificationsListCapability, notificationsMarkReadCapability, ] diff --git a/src/capabilities/builtins/pages.ts b/src/capabilities/builtins/pages.ts index ca680509b..1137df13a 100644 --- a/src/capabilities/builtins/pages.ts +++ b/src/capabilities/builtins/pages.ts @@ -1,11 +1,11 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const pagesListCapability = implementCore('pages.list') export const pagesShowCapability = implementCore('pages.show') -export const PAGES_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const PAGES_BUILTIN_CAPABILITIES: readonly Capability[] = [ pagesListCapability, pagesShowCapability, ] diff --git a/src/capabilities/builtins/performance.ts b/src/capabilities/builtins/performance.ts index 6e36f799b..edbe4b5ad 100644 --- a/src/capabilities/builtins/performance.ts +++ b/src/capabilities/builtins/performance.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const performanceListCapability = implementCore('performance.list') @@ -15,7 +15,7 @@ export const performanceApplySliderCapability = implementCore( 'performance.applySlider', ) -export const PERFORMANCE_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const PERFORMANCE_BUILTIN_CAPABILITIES: readonly Capability[] = [ performanceListCapability, performanceSetCapability, performanceResetCapability, diff --git a/src/capabilities/builtins/persona.ts b/src/capabilities/builtins/persona.ts index 75486f0e9..c23a8877c 100644 --- a/src/capabilities/builtins/persona.ts +++ b/src/capabilities/builtins/persona.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -30,7 +30,7 @@ export const memoryUpdateCapability = implementCore('memory.update') /** 人格の核 (SOUL.md) の書き換え提案。常に人が承認する (#1162) */ export const soulProposeCapability = implementCore('soul.propose') -export const PERSONA_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const PERSONA_BUILTIN_CAPABILITIES: readonly Capability[] = [ aiListPersonasCapability, aiSetPersonaCapability, memoryUpdateCapability, diff --git a/src/capabilities/builtins/plugins.ts b/src/capabilities/builtins/plugins.ts index 24436a981..7d70e8769 100644 --- a/src/capabilities/builtins/plugins.ts +++ b/src/capabilities/builtins/plugins.ts @@ -1,5 +1,5 @@ import { parsePluginMeta } from '@/aiscript/plugin-api' -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { i18n } from '@/i18n' import { usePluginsStore } from '@/stores/plugins' import { implement, implementCore } from '../declare' @@ -170,7 +170,7 @@ export const pluginsInstallCapability = implementCore('plugins.install') */ export const pluginsUninstallCapability = implementCore('plugins.uninstall') -export const PLUGINS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const PLUGINS_BUILTIN_CAPABILITIES: readonly Capability[] = [ pluginsListCapability, pluginsReadCapability, pluginsCreateCapability, diff --git a/src/capabilities/builtins/queries.ts b/src/capabilities/builtins/queries.ts index 437dab7fc..aa9a0d9d7 100644 --- a/src/capabilities/builtins/queries.ts +++ b/src/capabilities/builtins/queries.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { i18n } from '@/i18n' import { compileColumnQuery } from '@/services/columnQuery/compiler' import { useColumnQueriesStore } from '@/stores/columnQueries' @@ -103,7 +103,7 @@ export const queriesHistoryCapability = implementCore('queries.history') export const queriesRevertCapability = implementCore('queries.revert') -export const QUERIES_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const QUERIES_BUILTIN_CAPABILITIES: readonly Capability[] = [ queriesListCapability, queriesReadCapability, queriesCreateCapability, diff --git a/src/capabilities/builtins/registry.ts b/src/capabilities/builtins/registry.ts index 94f392371..c37c7585e 100644 --- a/src/capabilities/builtins/registry.ts +++ b/src/capabilities/builtins/registry.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const registryListKeysCapability = implementCore('registry.listKeys') @@ -9,7 +9,7 @@ export const registrySetCapability = implementCore('registry.set') export const registryDeleteCapability = implementCore('registry.delete') -export const REGISTRY_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const REGISTRY_BUILTIN_CAPABILITIES: readonly Capability[] = [ registryListKeysCapability, registryGetCapability, registrySetCapability, diff --git a/src/capabilities/builtins/role.ts b/src/capabilities/builtins/role.ts index 7303478a2..10e1ec63c 100644 --- a/src/capabilities/builtins/role.ts +++ b/src/capabilities/builtins/role.ts @@ -1,8 +1,8 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const roleNotesCapability = implementCore('role.notes') -export const ROLE_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const ROLE_BUILTIN_CAPABILITIES: readonly Capability[] = [ roleNotesCapability, ] diff --git a/src/capabilities/builtins/skills.ts b/src/capabilities/builtins/skills.ts index 576297901..98587a777 100644 --- a/src/capabilities/builtins/skills.ts +++ b/src/capabilities/builtins/skills.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -85,7 +85,7 @@ export const skillsUninstallCapability = implementCore('skills.uninstall') /** `skills.delete` — id で削除する (無ければ removed: false)。予約 skill は本体が拒否する */ export const skillsDeleteCapability = implementCore('skills.delete') -export const SKILLS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const SKILLS_BUILTIN_CAPABILITIES: readonly Capability[] = [ skillsListCapability, skillsReadCapability, skillsCreateCapability, diff --git a/src/capabilities/builtins/styles.ts b/src/capabilities/builtins/styles.ts index 78e8f9f99..8a7926be5 100644 --- a/src/capabilities/builtins/styles.ts +++ b/src/capabilities/builtins/styles.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' export const stylesReadCapability = implementCore('styles.read') @@ -11,7 +11,7 @@ export const stylesHistoryCapability = implementCore('styles.history') export const stylesRevertCapability = implementCore('styles.revert') -export const STYLES_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const STYLES_BUILTIN_CAPABILITIES: readonly Capability[] = [ stylesReadCapability, stylesWriteCapability, stylesAppendCapability, diff --git a/src/capabilities/builtins/tasks.test.ts b/src/capabilities/builtins/tasks.test.ts index e35165e72..15e963787 100644 --- a/src/capabilities/builtins/tasks.test.ts +++ b/src/capabilities/builtins/tasks.test.ts @@ -33,12 +33,6 @@ describe('tasks.run capability', () => { /taskId is required/, ) }) - - it('is hidden from command palette (visible: false)', () => { - // タスクは TaskRunner カラムから run するもので、コマンドパレット経由 - // で実行する想定はない (= 重複 UI を避ける) - expect(tasksRunCapability.visible).toBe(false) - }) }) describe('TASKS_BUILTIN_CAPABILITIES export', () => { diff --git a/src/capabilities/builtins/tasks.ts b/src/capabilities/builtins/tasks.ts index ca08daa78..8242fc25e 100644 --- a/src/capabilities/builtins/tasks.ts +++ b/src/capabilities/builtins/tasks.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { assertMisskeyApiAllowed } from '@/permissions/misskeyApiGate' import { useTaskRunnerStore } from '@/stores/taskRunner' import { useTasksStore } from '@/stores/tasks' @@ -49,6 +49,6 @@ export const tasksRunCapability = implement('tasks.run', { }, }) -export const TASKS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const TASKS_BUILTIN_CAPABILITIES: readonly Capability[] = [ tasksRunCapability, ] diff --git a/src/capabilities/builtins/theme.ts b/src/capabilities/builtins/theme.ts index fbfcebe65..106fe09c0 100644 --- a/src/capabilities/builtins/theme.ts +++ b/src/capabilities/builtins/theme.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { misskeyThemeOf } from '@/services/distributableCodecs/themeCodec' import { useAccountsStore } from '@/stores/accounts' import { useThemeStore } from '@/stores/theme' @@ -99,7 +99,7 @@ export const themeUninstallCapability = implementCore('themes.uninstall') /** `themes.delete` — id で削除する (無ければ removed: false) */ export const themeDeleteCapability = implementCore('themes.delete') -export const THEME_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const THEME_BUILTIN_CAPABILITIES: readonly Capability[] = [ themeListCapability, themeReadCapability, themeApplyCapability, diff --git a/src/capabilities/builtins/time.ts b/src/capabilities/builtins/time.ts index 697638058..1b731b024 100644 --- a/src/capabilities/builtins/time.ts +++ b/src/capabilities/builtins/time.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { implementCore } from '../declare' /** @@ -11,4 +11,4 @@ import { implementCore } from '../declare' export const timeNowCapability = implementCore('time.now') /** Builtin capability すべて。起動時に register する用。 */ -export const BUILTIN_CAPABILITIES: readonly Command[] = [timeNowCapability] +export const BUILTIN_CAPABILITIES: readonly Capability[] = [timeNowCapability] diff --git a/src/capabilities/builtins/ui.ts b/src/capabilities/builtins/ui.ts index 27c0e7fca..40d19d2fd 100644 --- a/src/capabilities/builtins/ui.ts +++ b/src/capabilities/builtins/ui.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { sendDesktopNotification } from '@/utils/desktopNotification' import { implement } from '../declare' @@ -19,4 +19,6 @@ export const uiNotifyCapability = implement('ui.notify', { }, }) -export const UI_BUILTIN_CAPABILITIES: readonly Command[] = [uiNotifyCapability] +export const UI_BUILTIN_CAPABILITIES: readonly Capability[] = [ + uiNotifyCapability, +] diff --git a/src/capabilities/builtins/user.ts b/src/capabilities/builtins/user.ts index 6b3afbad3..2159f23e3 100644 --- a/src/capabilities/builtins/user.ts +++ b/src/capabilities/builtins/user.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { i18n } from '@/i18n' import { useMutesStore } from '@/stores/mutes' import { AppError } from '@/utils/errors' @@ -207,7 +207,7 @@ export const userFollowersCapability = implementCore('user.followers') export const userFollowingCapability = implementCore('user.following') -export const USER_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const USER_BUILTIN_CAPABILITIES: readonly Capability[] = [ userLookupCapability, userSearchCapability, userMuteCapability, diff --git a/src/capabilities/builtins/vault.ts b/src/capabilities/builtins/vault.ts index c4f9b39bd..58c7692aa 100644 --- a/src/capabilities/builtins/vault.ts +++ b/src/capabilities/builtins/vault.ts @@ -1,6 +1,5 @@ import type { Connection, PrincipalClass } from '@/bindings' -import type { CapabilityContext } from '@/capabilities/types' -import type { Command } from '@/commands/registry' +import type { Capability, CapabilityContext } from '@/capabilities/types' import { i18n } from '@/i18n' import { type Principal, principalActorLabel } from '@/permissions/principal' import { commands, unwrap } from '@/utils/tauriInvoke' @@ -190,6 +189,6 @@ function isStringRecord(v: unknown): v is Record { return true } -export const VAULT_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const VAULT_BUILTIN_CAPABILITIES: readonly Capability[] = [ vaultFetchCapability, ] diff --git a/src/capabilities/builtins/widgets.ts b/src/capabilities/builtins/widgets.ts index ec09c9baa..01ca86fbf 100644 --- a/src/capabilities/builtins/widgets.ts +++ b/src/capabilities/builtins/widgets.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { i18n } from '@/i18n' import { useWidgetsStore } from '@/stores/widgets' import { implement, implementCore } from '../declare' @@ -149,7 +149,7 @@ export const widgetsInstallCapability = implementCore('widgets.install') */ export const widgetsUninstallCapability = implementCore('widgets.uninstall') -export const WIDGETS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const WIDGETS_BUILTIN_CAPABILITIES: readonly Capability[] = [ widgetsListCapability, widgetsReadCapability, widgetsCreateCapability, diff --git a/src/capabilities/builtins/windows.ts b/src/capabilities/builtins/windows.ts index d5b23911e..5d2187705 100644 --- a/src/capabilities/builtins/windows.ts +++ b/src/capabilities/builtins/windows.ts @@ -1,4 +1,4 @@ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { i18n } from '@/i18n' import { useWindowsStore, type WindowType } from '@/stores/windows' import { WINDOW_SIZES } from '@/windows/registry' @@ -107,7 +107,7 @@ export const windowsCloseAllCapability = implement('windows.closeAll', { }, }) -export const WINDOWS_BUILTIN_CAPABILITIES: readonly Command[] = [ +export const WINDOWS_BUILTIN_CAPABILITIES: readonly Capability[] = [ windowsListCapability, windowsOpenCapability, windowsCloseCapability, diff --git a/src/capabilities/declarations.generated.ts b/src/capabilities/declarations.generated.ts index 7aeb5a9d7..2da4cf744 100644 --- a/src/capabilities/declarations.generated.ts +++ b/src/capabilities/declarations.generated.ts @@ -2,8 +2,11 @@ // 生成は `pnpm gen:capabilities` (scripts/gen-capabilities.mjs)。 // 生成物が最新かは tests/lint/capabilityDeclarations.test.ts が検査する (#1133)。 -import type { Command } from '@/commands/registry' -import type { ParameterDef, ReturnTypeDef } from '@/capabilities/types' +import type { + CapabilityCategory, + ParameterDef, + ReturnTypeDef, +} from '@/capabilities/types' import type { PermissionKey } from '@/permissions/schema' /** capability の実行属性 (#1106 §4.8): core = notecore 単独で実行できる / @@ -13,7 +16,7 @@ export type CapabilityExec = 'core' | 'device' | 'live' export interface CapabilityDeclaration { id: CapabilityId label: string - category: Command['category'] + category: CapabilityCategory icon: string permissions: readonly PermissionKey[] aiTool: boolean diff --git a/src/capabilities/declare.ts b/src/capabilities/declare.ts index 9b92109b4..ee0b0b09d 100644 --- a/src/capabilities/declare.ts +++ b/src/capabilities/declare.ts @@ -14,7 +14,6 @@ */ import type { JsonValue } from '@/bindings' -import type { Command } from '@/commands/registry' import { i18n } from '@/i18n' import type { ConfirmOptions } from '@/stores/confirm' import { commands, unwrap } from '@/utils/tauriInvoke' @@ -22,20 +21,20 @@ import { CAPABILITY_DECLARATIONS, type CapabilityId, } from './declarations.generated' -import type { CapabilityContext } from './types' +import type { Capability, CapabilityContext } from './types' /** builtins が書く部分 = 振る舞いだけ */ export interface CapabilityImpl { - execute: Command['execute'] + execute: Capability['execute'] /** * 確認ダイアログの内容を組み立てる (関数形)。宣言の `confirm: true` だけなら * 省略でき、汎用モーダル (label + 引数 JSON) になる。宣言が `confirm: false` * なのに渡すと lint で落ちる。 */ - requiresConfirmation?: Command['requiresConfirmation'] - onConfirmRemember?: Command['onConfirmRemember'] - preflight?: Command['preflight'] - enabled?: Command['enabled'] + requiresConfirmation?: Capability['requiresConfirmation'] + onConfirmRemember?: Capability['onConfirmRemember'] + preflight?: Capability['preflight'] + enabled?: Capability['enabled'] /** * 実行時に決まる enum (例: column.add の `type` はカラム種別レジストリ由来で、 * plugin が登録した種別も AI に見せる)。宣言には enum を書かず、ここで getter を @@ -52,7 +51,7 @@ export function capabilityLabel(id: CapabilityId): string { return typeof node === 'string' ? node : CAPABILITY_DECLARATIONS[id].label } -export function implement(id: CapabilityId, impl: CapabilityImpl): Command { +export function implement(id: CapabilityId, impl: CapabilityImpl): Capability { const d = CAPABILITY_DECLARATIONS[id] let params = d.params if (impl.enumOf) { @@ -68,7 +67,7 @@ export function implement(id: CapabilityId, impl: CapabilityImpl): Command { } } } - const cmd: Command = { + const cmd: Capability = { id, // 表示名は辞書から引く (#135)。capability は import 時に組まれるので、 // 辞書を読む前に触らないよう参照した時点で引く @@ -77,8 +76,6 @@ export function implement(id: CapabilityId, impl: CapabilityImpl): Command { }, icon: d.icon, category: d.category, - shortcuts: [], - visible: d.visible, aiTool: d.aiTool, permissions: [...d.permissions], signature: { @@ -110,7 +107,7 @@ export function implement(id: CapabilityId, impl: CapabilityImpl): Command { export function implementCore( id: CapabilityId, impl: Omit = {}, -): Command { +): Capability { const d = CAPABILITY_DECLARATIONS[id] if (d.exec !== 'core') { throw new Error(`${id}: implementCore requires exec: core`) @@ -160,9 +157,9 @@ function pluginIdOf(ctx: CapabilityContext | undefined): string | null { return p?.kind === 'plugin' ? p.pluginId : null } -const CORE_DELEGATES = new WeakSet() +const CORE_DELEGATES = new WeakSet() /** builtin が notecore への委譲か (lint 用) */ -export function isCoreDelegate(cmd: Command): boolean { +export function isCoreDelegate(cmd: Capability): boolean { return CORE_DELEGATES.has(cmd) } diff --git a/src/capabilities/deviceTools.test.ts b/src/capabilities/deviceTools.test.ts index e28862e68..0e5638e4b 100644 --- a/src/capabilities/deviceTools.test.ts +++ b/src/capabilities/deviceTools.test.ts @@ -1,14 +1,13 @@ import { describe, expect, it } from 'vitest' -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { collectDeviceTools, collectRuntimeEnums } from './deviceTools' -function cmd(partial: Partial & { id: string }): Command { +function cmd(partial: Partial & { id: string }): Capability { return { label: partial.id, icon: 'ti-bolt', category: 'general', - shortcuts: [], - visible: false, + signature: { description: partial.id }, execute: async () => undefined, ...partial, } diff --git a/src/capabilities/deviceTools.ts b/src/capabilities/deviceTools.ts index 733198fa1..155c9bcb3 100644 --- a/src/capabilities/deviceTools.ts +++ b/src/capabilities/deviceTools.ts @@ -13,7 +13,7 @@ */ import type { DeviceTool } from '@/bindings' -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { CAPABILITY_DECLARATIONS } from './declarations.generated' import type { ParameterDef } from './types' @@ -22,7 +22,7 @@ function isDeclared(id: string): boolean { } /** 宣言表に無い AI tool (plugin 由来) */ -export function collectDeviceTools(caps: readonly Command[]): DeviceTool[] { +export function collectDeviceTools(caps: readonly Capability[]): DeviceTool[] { const out: DeviceTool[] = [] for (const cap of caps) { if (!cap.aiTool || !cap.signature || isDeclared(cap.id)) continue @@ -48,7 +48,7 @@ export function collectDeviceTools(caps: readonly Command[]): DeviceTool[] { /** 宣言表にある capability のうち、実行時に決まった enum を持つ引数 */ export function collectRuntimeEnums( - caps: readonly Command[], + caps: readonly Capability[], ): Record> | null { const out: Record> = {} for (const cap of caps) { diff --git a/src/capabilities/dispatcher.test.ts b/src/capabilities/dispatcher.test.ts index 9c7c0c384..50a061ed9 100644 --- a/src/capabilities/dispatcher.test.ts +++ b/src/capabilities/dispatcher.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { useSpotlightStore } from '@/composables/useSpotlight' import { _clearPluginDenialsForTest, @@ -25,13 +25,12 @@ import { } from './dispatcher' import { _clearCapabilitiesForTest, registerCapability } from './registry' -function makeCapability(overrides: Partial = {}): Command { +function makeCapability(overrides: Partial = {}): Capability { return { id: 'test.cap', label: 'test', icon: 'ti-flask', category: 'general', - shortcuts: [], aiTool: true, permissions: [], signature: { description: 'test capability' }, @@ -1833,7 +1832,7 @@ describe('account.actAs gate (#777)', () => { }) /** actsAsAccount 宣言付きの write 系 capability (permissions は空で actAs だけを検査する) */ - function actAsCap(overrides: Partial = {}): Command { + function actAsCap(overrides: Partial = {}): Capability { return makeCapability({ id: 'notes.create', actsAsAccount: true, @@ -2014,7 +2013,7 @@ describe('plugin 拒否の UI 操作起点 toast (#712 §8.4 補強)', () => { setActivePinia(createPinia()) }) - function deniedCap(): Command { + function deniedCap(): Capability { return makeCapability({ id: 'files.export', permissions: ['files.export'], diff --git a/src/capabilities/dispatcher.ts b/src/capabilities/dispatcher.ts index e6c952d41..6257cabc3 100644 --- a/src/capabilities/dispatcher.ts +++ b/src/capabilities/dispatcher.ts @@ -11,8 +11,8 @@ */ import { nextTick } from 'vue' +import type { Capability } from '@/capabilities/types' import { COLUMN_LABELS } from '@/columns/registry' -import type { Command } from '@/commands/registry' import { useAiConfig } from '@/composables/useAiConfig' import { accountTargetId, @@ -539,7 +539,7 @@ interface PreparedConfirmation { * null = この呼び出しでは確認しない (宣言なし / no-op / 記憶済み)。 */ async function prepareConfirmation( - cap: Command, + cap: Capability, params: Record | undefined, capCtx: CapabilityContext, ctx: DispatchContext, @@ -695,7 +695,7 @@ export async function rememberConfirmation( } async function buildConfirmOptions( - cap: Command, + cap: Capability, params: Record | undefined, ctx: CapabilityContext, opts?: { force?: boolean }, diff --git a/src/capabilities/goldenTools.test.ts b/src/capabilities/goldenTools.test.ts index 0f7fd2d17..4c214d5e0 100644 --- a/src/capabilities/goldenTools.test.ts +++ b/src/capabilities/goldenTools.test.ts @@ -1,7 +1,7 @@ import { writeFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { CAPABILITY_DECLARATIONS } from './declarations.generated' import golden from './golden/tools.json' import { toAnthropicTool } from './toolSchema' @@ -25,7 +25,7 @@ function toolFromDeclaration(id: keyof typeof CAPABILITY_DECLARATIONS) { const cmd = { id: d.id, signature: { description: d.description, params: d.params }, - } as unknown as Command + } as unknown as Capability return JSON.parse(JSON.stringify(toAnthropicTool(cmd))) } diff --git a/src/capabilities/registry.test.ts b/src/capabilities/registry.test.ts index 280f9128c..fa8fd02c0 100644 --- a/src/capabilities/registry.test.ts +++ b/src/capabilities/registry.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { _clearCapabilitiesForTest, getCapability, @@ -8,13 +8,12 @@ import { unregisterCapability, } from './registry' -function makeCapability(overrides: Partial = {}): Command { +function makeCapability(overrides: Partial = {}): Capability { return { id: 'test.cap', label: 'test', icon: 'ti-flask', category: 'general', - shortcuts: [], aiTool: true, signature: { description: 'test capability' }, execute: () => 'ok', diff --git a/src/capabilities/registry.ts b/src/capabilities/registry.ts index 8b07f5104..ee46b945e 100644 --- a/src/capabilities/registry.ts +++ b/src/capabilities/registry.ts @@ -1,7 +1,7 @@ /** * Capability registry — capability 登録・lookup API。AiScript (`Nd:call`) / - * HTTP API / CLI / コマンドパレット / AI tool calling の 5 経路から共通で - * 参照される。 + * HTTP API / slash / パレットの CLI / AI tool calling の経路から共通で + * 参照される (dispatcher を通る)。 * * `aiTool` フラグは「AI tool schema に含めるか」だけを意味し、registry 登録 * の前提条件ではない。`aiTool: false` でも他経路 (= AiScript / HTTP API / @@ -12,12 +12,12 @@ * て登録する。 */ -import type { Command } from '@/commands/registry' +import type { Capability } from './types' -const capabilities = new Map() +const capabilities = new Map() /** Capability を registry に登録する。`signature` 必須。 */ -export function registerCapability(cmd: Command): void { +export function registerCapability(cmd: Capability): void { if (!cmd.signature) { throw new Error( `Capability "${cmd.id}" must have a signature to be registered`, @@ -30,11 +30,11 @@ export function unregisterCapability(id: string): void { capabilities.delete(id) } -export function getCapability(id: string): Command | undefined { +export function getCapability(id: string): Capability | undefined { return capabilities.get(id) } -export function listCapabilities(): Command[] { +export function listCapabilities(): Capability[] { return [...capabilities.values()] } diff --git a/src/capabilities/toolSchema.test.ts b/src/capabilities/toolSchema.test.ts index 8673ef129..0e43b3a41 100644 --- a/src/capabilities/toolSchema.test.ts +++ b/src/capabilities/toolSchema.test.ts @@ -1,14 +1,13 @@ import { describe, expect, it } from 'vitest' -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { toAnthropicTool, toOpenAiTool } from './toolSchema' -function makeCapability(overrides: Partial = {}): Command { +function makeCapability(overrides: Partial = {}): Capability { return { id: 'notes.post', label: 'ノートを投稿', icon: 'ti-pencil', category: 'note', - shortcuts: [], aiTool: true, permissions: ['notes.write'], signature: { diff --git a/src/capabilities/toolSchema.ts b/src/capabilities/toolSchema.ts index 50db62116..ddd6c03d1 100644 --- a/src/capabilities/toolSchema.ts +++ b/src/capabilities/toolSchema.ts @@ -9,7 +9,7 @@ * OpenAI: https://platform.openai.com/docs/guides/function-calling */ -import type { Command } from '@/commands/registry' +import type { Capability } from '@/capabilities/types' import { sanitizeToolName } from './identifier' import type { CapabilitySignature, ParameterDef } from './types' @@ -33,7 +33,7 @@ export interface AnthropicTool { input_schema: InputSchema } -export function toAnthropicTool(cmd: Command): AnthropicTool { +export function toAnthropicTool(cmd: Capability): AnthropicTool { if (!cmd.signature) { throw new Error(`Capability "${cmd.id}" has no signature`) } @@ -55,7 +55,7 @@ export interface OpenAiTool { } } -export function toOpenAiTool(cmd: Command): OpenAiTool { +export function toOpenAiTool(cmd: Capability): OpenAiTool { if (!cmd.signature) { throw new Error(`Capability "${cmd.id}" has no signature`) } diff --git a/src/capabilities/types.ts b/src/capabilities/types.ts index 2fd8a61e0..5279874a9 100644 --- a/src/capabilities/types.ts +++ b/src/capabilities/types.ts @@ -1,12 +1,15 @@ /** - * Capability Registry の型定義 (Phase 2 入口)。 + * Capability Registry の型定義。 * - * Capability = 「NoteDeck で実行できる操作」の単位。 - * 既存の `Command` interface に optional フィールドを追加することで、 - * UI / CLI / HTTP API / AiScript / AI Tool calling の 5 つの呼び出し口で - * 共通利用できる Single Source of Truth へ進化させる。 + * Capability = 「NoteDeck で実行できる操作」の単位。宣言 (id / 権限 / 確認 / + * 引数の型) は `crates/notecore/capabilities.json5` が正本で、実装は + * `implement()` で結び付ける。AI tool calling / HTTP API / AiScript (`Nd:call`) / + * slash / 外部トークンの経路はすべて dispatcher を通ってここに届く。 * - * Phase 2 A-1 (本ファイル): 型定義のみ。実 dispatcher は A-2 で実装。 + * コマンドパレットの `Command` (`src/commands/registry.ts`) とは別物 (#1098 §9): + * Command は人が押す UI の入口 (ラベル / アイコン / ショートカット / 露出) で、 + * capability を呼ぶかどうかは中身の自由。id も別の名前空間 (Command は + * kebab、capability は dotted)。 * * 設計詳細: #408 "Capability Registry as Single Source of Truth" * https://github.com/notedeck-dev/notedeck/issues/408#issuecomment-4334932896 @@ -14,6 +17,7 @@ import type { Principal } from '@/permissions/principal' import type { PermissionKey } from '@/permissions/schema' +import type { ConfirmOptions } from '@/stores/confirm' /** * Capability の引数 1 つを表す型情報。 @@ -113,3 +117,71 @@ export interface CapabilityContext { */ markTainted?: () => void } + +/** capability / Command が属する分類 (パレットのグループと AI 向けの一覧で共用) */ +export type CapabilityCategory = + | 'general' + | 'navigation' + | 'column' + | 'account' + | 'note' + | 'window' + +export interface PreflightFailure { + /** AI へそのまま返るエラー文字列。診断 JSON を埋め込んでよい。 */ + error: string +} + +/** + * 実行できる操作 1 つ。宣言 (capabilities.json5) から `implement()` が組む。 + * プラグインの `Nd:register_command` (options 付き) も同じ形で登録する。 + */ +export interface Capability { + /** dotted id (`notes.create`)。プラグイン由来は provider 付き */ + id: string + label: string + /** Tabler icon 名 */ + icon: string + category: CapabilityCategory + /** + * 実行関数。`params` は AI tool / Nd:call / HTTP / slash から、`ctx` は + * dispatcher が組み立てる実行コンテキスト。戻り値は AI tool への応答。 + */ + execute: ( + params?: Record, + ctx?: CapabilityContext, + ) => unknown + /** params / returns の型。AI tool schema / OpenAPI spec の生成元 */ + signature: CapabilitySignature + /** 実行に要る権限。principal のプロファイルと照合する */ + permissions?: PermissionKey[] + /** AI tool として公開するか (false でも他経路からは呼べる) */ + aiTool?: boolean + /** + * `params.accountId` が実行主体を意味する宣言 (#777)。非 user principal が + * 文脈と違うアカウントを指定したとき `account.actAs` を要求する + */ + actsAsAccount?: boolean + /** + * 実行前の確認。dispatcher 経路でだけ enforce される。 + * - `true` — label + description + 引数 JSON の汎用モーダル + * - 関数 — params を見て `ConfirmOptions` を返す。`null` でその回はスキップ + */ + requiresConfirmation?: + | boolean + | (( + params: Record | undefined, + ctx: CapabilityContext, + ) => ConfirmOptions | null | Promise) + /** 「次回から確認しない」が ON のまま許可されたときの副作用 */ + onConfirmRemember?: ( + params: Record | undefined, + ctx: CapabilityContext, + ) => void | Promise + /** confirm より前の入力検証。`{ error }` で fail-fast (#553) */ + preflight?: ( + params: Record | undefined, + ) => PreflightFailure | null | Promise + /** false を返すと今は実行できない (dispatcher が弾く) */ + enabled?: () => boolean +} diff --git a/src/commands/cliHandlers.test.ts b/src/commands/cliHandlers.test.ts index c68ab2ef7..2167b9e08 100644 --- a/src/commands/cliHandlers.test.ts +++ b/src/commands/cliHandlers.test.ts @@ -5,14 +5,7 @@ vi.mock('@/utils/tauriInvoke', () => { return { commands: { apiCreateNote: vi.fn(async () => ok), - apiDeleteNote: vi.fn(async () => ok), apiUpdateNote: vi.fn(async () => ok), - apiCreateReaction: vi.fn(async () => ok), - apiDeleteReaction: vi.fn(async () => ok), - apiFollowUser: vi.fn(async () => ok), - apiUnfollowUser: vi.fn(async () => ok), - apiCreateFavorite: vi.fn(async () => ok), - apiDeleteFavorite: vi.fn(async () => ok), }, unwrap: (r: { status: string; data?: unknown; error?: unknown }) => { if (r.status === 'ok') return r.data @@ -21,9 +14,17 @@ vi.mock('@/utils/tauriInvoke', () => { } }) +// API を叩く動詞は capability を dispatcher 経由で呼ぶ (#1098 §9) +const dispatchCapability = vi.hoisted(() => + vi.fn(async () => ({ ok: true as const, result: null })), +) +vi.mock('@/capabilities/dispatcher', () => ({ dispatchCapability })) + import { commands } from '@/utils/tauriInvoke' import { type CliHandlerDeps, createCliHandlers } from './cliHandlers' +const asUser = { principal: { kind: 'user' } } + interface FakeColumn { id: string type: string @@ -186,10 +187,30 @@ describe('note navigation and mutation', () => { expect(deps.navigateToUser).toHaveBeenCalledWith('acc-1', 'u7') }) - it('delete calls apiDeleteNote with the note id', async () => { + it('delete dispatches notes.delete with the note id', async () => { const { handlers } = makeDeps() await handlers.delete?.('n99') - expect(commands.apiDeleteNote).toHaveBeenCalledWith('acc-1', 'n99') + expect(dispatchCapability).toHaveBeenCalledWith( + 'notes.delete', + { noteId: 'n99', accountId: 'acc-1' }, + asUser, + ) + }) + + it('キャンセルは投げず、他の失敗は投げる', async () => { + const { handlers } = makeDeps() + dispatchCapability.mockResolvedValueOnce({ + ok: false, + code: 'user_cancelled', + error: 'cancelled', + } as never) + await expect(handlers.delete?.('n1')).resolves.toBeUndefined() + dispatchCapability.mockResolvedValueOnce({ + ok: false, + code: 'execute_failed', + error: 'boom', + } as never) + await expect(handlers.delete?.('n1')).rejects.toThrow('notes.delete: boom') }) it('update requires both id and text', async () => { @@ -208,18 +229,15 @@ describe('note navigation and mutation', () => { ) }) - it('renote creates a note with renoteId only', async () => { + it('renote dispatches notes.create with renoteId only', async () => { const { handlers } = makeDeps() await handlers.renote?.('n5') - expect(commands.apiCreateNote).toHaveBeenCalledWith( - 'acc-1', - expect.objectContaining({ - text: null, - renoteId: 'n5', - visibility: 'public', - }), - null, + expect(dispatchCapability).toHaveBeenCalledWith( + 'notes.create', + { renoteId: 'n5', visibility: 'public', accountId: 'acc-1' }, + asUser, ) + expect(commands.apiCreateNote).not.toHaveBeenCalled() }) }) @@ -227,16 +245,16 @@ describe('react', () => { it('requires both note id and reaction', async () => { const { handlers } = makeDeps() await handlers.react?.('n1') - expect(commands.apiCreateReaction).not.toHaveBeenCalled() + expect(dispatchCapability).not.toHaveBeenCalled() }) it('sends the reaction, joining trailing parts with spaces', async () => { const { handlers } = makeDeps() await handlers.react?.('n1 :ablob: extra') - expect(commands.apiCreateReaction).toHaveBeenCalledWith( - 'acc-1', - 'n1', - ':ablob: extra', + expect(dispatchCapability).toHaveBeenCalledWith( + 'notes.react', + { noteId: 'n1', reaction: ':ablob: extra', accountId: 'acc-1' }, + asUser, ) }) }) @@ -245,7 +263,11 @@ describe('favorite / unfavorite', () => { it('favorite calls the API and invalidates the favorites column', async () => { const { deps, handlers } = makeDeps() await handlers.favorite?.('n1') - expect(commands.apiCreateFavorite).toHaveBeenCalledWith('acc-1', 'n1') + expect(dispatchCapability).toHaveBeenCalledWith( + 'favorites.add', + { noteId: 'n1', accountId: 'acc-1' }, + asUser, + ) expect(deps.deckStore.invalidateColumnByKey).toHaveBeenCalledWith( 'favorites', ) @@ -254,7 +276,11 @@ describe('favorite / unfavorite', () => { it('unfavorite calls the API and invalidates the favorites column', async () => { const { deps, handlers } = makeDeps() await handlers.unfavorite?.('n1') - expect(commands.apiDeleteFavorite).toHaveBeenCalledWith('acc-1', 'n1') + expect(dispatchCapability).toHaveBeenCalledWith( + 'favorites.remove', + { noteId: 'n1', accountId: 'acc-1' }, + asUser, + ) expect(deps.deckStore.invalidateColumnByKey).toHaveBeenCalledWith( 'favorites', ) diff --git a/src/commands/cliHandlers.ts b/src/commands/cliHandlers.ts index d3a28c76f..f175ab3f6 100644 --- a/src/commands/cliHandlers.ts +++ b/src/commands/cliHandlers.ts @@ -1,4 +1,5 @@ import type { NoteVisibility, PostVisibility } from '@/adapters/types' +import { dispatchCapability } from '@/capabilities/dispatcher' import { i18n } from '@/i18n' import { FAVORITES_CACHE_KEY } from '@/services/columnCacheKey' import type { useAccountsStore } from '@/stores/accounts' @@ -21,6 +22,21 @@ export interface CliHandlerDeps { export type CliHandler = (args: string) => void | Promise +/** + * API を叩く動詞は capability を dispatcher 経由で呼ぶ (#1098 §9)。本人の操作 + * なので principal は user (プロファイルでは縛られない)。宣言が confirm の + * capability は slash (`/notes.react`) と同じく確認ダイアログを通る。 + * キャンセルは正常な操作なので投げない。失敗は投げて呼び出し側のログに残す + */ +async function run(id: string, params: Record): Promise { + const result = await dispatchCapability(id, params, { + principal: { kind: 'user' }, + }) + if (!result.ok && result.code !== 'user_cancelled') { + throw new Error(`${id}: ${result.error}`) + } +} + interface ParsedFlags { flags: Record rest: string @@ -81,6 +97,8 @@ export function createCliHandlers( deps: CliHandlerDeps, ): Record { return { + // post と update は capability (notes.create) に無い引数 (--local-only / + // 編集) を持つので IPC を直接叩く。宣言に足したら run() に寄せる post: async (args) => { if (!args.trim()) return const accountId = await deps.pickAccount(i18n.ts._cliHandlers.pickPost) @@ -238,7 +256,7 @@ export function createCliHandlers( if (!args.trim()) return const accountId = await deps.pickAccount(i18n.ts._cliHandlers.pickDelete) if (!accountId) return - unwrap(await commands.apiDeleteNote(accountId, args.trim())) + await run('notes.delete', { noteId: args.trim(), accountId }) }, update: async (args) => { @@ -277,38 +295,25 @@ export function createCliHandlers( if (parts.length < 2) return const noteId = parts[0] as string const reaction = parts.slice(1).join(' ') - unwrap(await commands.apiCreateReaction(accountId, noteId, reaction)) + await run('notes.react', { noteId, reaction, accountId }) }, unreact: async (args) => { if (!args.trim()) return const accountId = await deps.pickAccount(i18n.ts._cliHandlers.pickUnreact) if (!accountId) return - unwrap(await commands.apiDeleteReaction(accountId, args.trim())) + await run('notes.unreact', { noteId: args.trim(), accountId }) }, renote: async (args) => { if (!args.trim()) return const accountId = await deps.pickAccount(i18n.ts._cliHandlers.pickRenote) if (!accountId) return - unwrap( - await commands.apiCreateNote( - accountId, - { - text: null, - cw: null, - renoteId: args.trim(), - visibility: 'public', - replyId: null, - localOnly: null, - modeFlags: null, - fileIds: null, - poll: null, - scheduledAt: null, - }, - null, - ), - ) + await run('notes.create', { + renoteId: args.trim(), + visibility: 'public', + accountId, + }) }, user: async (args) => { @@ -337,21 +342,21 @@ export function createCliHandlers( if (!args.trim()) return const accountId = await deps.pickAccount(i18n.ts._cliHandlers.pickCommand) if (!accountId) return - unwrap(await commands.apiFollowUser(accountId, args.trim())) + await run('user.follow', { userId: args.trim(), accountId }) }, unfollow: async (args) => { if (!args.trim()) return const accountId = await deps.pickAccount(i18n.ts._cliHandlers.pickCommand) if (!accountId) return - unwrap(await commands.apiUnfollowUser(accountId, args.trim())) + await run('user.unfollow', { userId: args.trim(), accountId }) }, favorite: async (args) => { if (!args.trim()) return const accountId = await deps.pickAccount(i18n.ts._cliHandlers.pickCommand) if (!accountId) return - unwrap(await commands.apiCreateFavorite(accountId, args.trim())) + await run('favorites.add', { noteId: args.trim(), accountId }) deps.deckStore.invalidateColumnByKey(FAVORITES_CACHE_KEY) }, @@ -359,7 +364,7 @@ export function createCliHandlers( if (!args.trim()) return const accountId = await deps.pickAccount(i18n.ts._cliHandlers.pickCommand) if (!accountId) return - unwrap(await commands.apiDeleteFavorite(accountId, args.trim())) + await run('favorites.remove', { noteId: args.trim(), accountId }) deps.deckStore.invalidateColumnByKey(FAVORITES_CACHE_KEY) }, diff --git a/src/commands/definitions.ts b/src/commands/definitions.ts index cc8e99467..f1762201e 100644 --- a/src/commands/definitions.ts +++ b/src/commands/definitions.ts @@ -1046,7 +1046,7 @@ export function registerDefaultCommands(handlers: CommandHandlers) { category: 'window', shortcuts: keybindsStore.getShortcuts('pip-window'), execute: () => { - import('@/composables/usePipWindow').then(({ openPipWindow }) => { + import('@/windows/pip').then(({ openPipWindow }) => { openPipWindow() }) }, diff --git a/src/commands/registry.test.ts b/src/commands/registry.test.ts index 0f46909b2..755b3e079 100644 --- a/src/commands/registry.test.ts +++ b/src/commands/registry.test.ts @@ -1,12 +1,8 @@ import { createPinia, setActivePinia } from 'pinia' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - _clearCapabilitiesForTest, - getCapability, -} from '@/capabilities/registry' +import { beforeEach, describe, expect, it } from 'vitest' import { type Command, useCommandStore } from './registry' -function makeUiCommand(overrides: Partial = {}): Command { +function makeCommand(overrides: Partial = {}): Command { return { id: 'ui.only', label: 'UI only', @@ -18,76 +14,39 @@ function makeUiCommand(overrides: Partial = {}): Command { } } -function makeAiCapableCommand(overrides: Partial = {}): Command { - return makeUiCommand({ - id: 'cap.do', - aiTool: true, - signature: { description: 'something AI can do' }, - ...overrides, - }) -} - -describe('useCommandStore mirror to capability registry', () => { +// Command はパレットの入口だけで、capability registry とは独立 (#1098 §9)。 +// capability にもしたいプラグインは Nd:register_command の options で別に登録する +describe('useCommandStore', () => { beforeEach(() => { setActivePinia(createPinia()) }) - afterEach(() => { - _clearCapabilitiesForTest() - }) - it('register a UI-only command does NOT mirror to capability registry', () => { + it('register / unregister で一覧が増減する', () => { const store = useCommandStore() - store.register(makeUiCommand({ id: 'ui-1' })) - expect(getCapability('ui-1')).toBeUndefined() + store.register(makeCommand({ id: 'a' })) + store.register(makeCommand({ id: 'b' })) + expect([...store.commands.keys()]).toEqual(['a', 'b']) + store.unregister('a') + expect([...store.commands.keys()]).toEqual(['b']) }) - it('register an AI-capable command mirrors to capability registry', () => { + it('同じ id の再登録は置き換える', () => { const store = useCommandStore() - const cmd = makeAiCapableCommand({ id: 'cap-1' }) - store.register(cmd) - expect(getCapability('cap-1')).toBe(cmd) + store.register(makeCommand({ id: 'x', label: 'first' })) + store.register(makeCommand({ id: 'x', label: 'second' })) + expect(store.commands.get('x')?.label).toBe('second') }) - it('register requires both aiTool: true AND a signature for mirroring', () => { + it('execute は enabled が false の command を走らせない', () => { const store = useCommandStore() - // aiTool: true だけでは signature 不在で mirror されない - store.register(makeUiCommand({ id: 'half-1', aiTool: true })) - expect(getCapability('half-1')).toBeUndefined() - // signature だけで aiTool が false / 未指定なら mirror されない + let ran = 0 store.register( - makeUiCommand({ - id: 'half-2', - signature: { description: 'd' }, - }), + makeCommand({ id: 'off', enabled: () => false, execute: () => ran++ }), ) - expect(getCapability('half-2')).toBeUndefined() - }) - - it('unregister removes from capability registry too', () => { - const store = useCommandStore() - store.register(makeAiCapableCommand({ id: 'cap-2' })) - expect(getCapability('cap-2')).toBeDefined() - store.unregister('cap-2') - expect(getCapability('cap-2')).toBeUndefined() - }) - - it('unregister of UI-only command does not touch capability registry', () => { - const store = useCommandStore() - // capability 側に直接登録された別 command が消されないこと - store.register(makeAiCapableCommand({ id: 'standalone' })) - store.register(makeUiCommand({ id: 'ui-2' })) - store.unregister('ui-2') - expect(getCapability('standalone')).toBeDefined() - }) - - it('re-registering the same id with new aiTool flag updates both registries', () => { - const store = useCommandStore() - // First: UI only - store.register(makeUiCommand({ id: 'morphing', label: 'first' })) - expect(getCapability('morphing')).toBeUndefined() - // Then: same id with aiTool + signature → should appear in capability registry - const upgraded = makeAiCapableCommand({ id: 'morphing', label: 'second' }) - store.register(upgraded) - expect(getCapability('morphing')).toBe(upgraded) + store.register(makeCommand({ id: 'on', execute: () => ran++ })) + store.execute('off') + store.execute('on') + expect(ran).toBe(1) + expect(store.getEnabled().map((c) => c.id)).toEqual(['on']) }) }) diff --git a/src/commands/registry.ts b/src/commands/registry.ts index a400fa5cd..1cce949ef 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -1,16 +1,7 @@ import { defineStore } from 'pinia' import { ref, shallowRef, triggerRef } from 'vue' -import { - registerCapability, - unregisterCapability, -} from '@/capabilities/registry' -import type { - CapabilityContext, - CapabilitySignature, - PermissionKey, -} from '@/capabilities/types' +import type { CapabilityCategory } from '@/capabilities/types' import type { ExposureTag } from '@/settings/exposure' -import type { ConfirmOptions } from '@/stores/confirm' import type { QuickPickStep } from './quickPick' export interface Shortcut { @@ -28,34 +19,23 @@ export interface Shortcut { } /** - * Command (= Capability) は NoteDeck で実行できる操作の単位。 + * Command はコマンドパレットの 1 行 (人が押す入口)。ラベル / アイコン / + * ショートカット / 露出を持ち、押されたら `execute()` を呼ぶだけ。 * - * 5 つの呼び出し口で共通利用される Single Source of Truth: - * UI (コマンドパレット) / CLI (notecli) / HTTP API (port 19820) / - * AiScript (Nd:register_command) / AI Tool calling - * - * Phase 1 では UI 専用。Phase 2 以降で `aiTool: true` & `signature` & `permissions` - * を宣言した command が AI から呼べるようになる。すべて optional なので既存 - * コマンドはそのまま (= UI のみ扱い) で動く。 + * AI / HTTP / AiScript / slash から呼べる「操作」は capability + * (`src/capabilities/types.ts` の `Capability`) で、宣言は capabilities.json5。 + * Command が capability を呼ぶかどうかは中身の自由で、型としては別物 (#1098 §9)。 + * id も別の名前空間 (Command は kebab、capability は dotted)。 */ export interface Command { id: string label: string /** Tabler icon 名 ('pencil', 'search' 等) */ icon: string - category: 'general' | 'navigation' | 'column' | 'account' | 'note' | 'window' + category: CapabilityCategory shortcuts: Shortcut[] - /** - * 実行関数。 - * - `params`: UI 経由は引数なし、AI tool / Nd:call / dispatcher 経由は params 付き - * - `ctx`: dispatcher が組み立てる実行コンテキスト (例: 現在の AiConfig)。 - * UI 直接呼び出しでは渡されない。多くの capability は不要なので optional - * 戻り値は AI tool への応答として使われるため `unknown` を返せる。 - */ - execute: ( - params?: Record, - ctx?: CapabilityContext, - ) => unknown + /** パレットから押されたときの処理 */ + execute: () => unknown /** false を返すとパレットでグレー表示+実行不可 */ enabled?: () => boolean /** false にするとパレットに非表示 (ショートカットのみ) */ @@ -66,76 +46,6 @@ export interface Command { * (キーバインド・AI・プラグインからの呼び出しは通る)。 */ exposure?: ExposureTag - /** - * この command を実行するのに必要な権限 (Phase 1 の `ai.json5` permissions と - * 同じスキーマ)。AI tool / HTTP API / プラグインから呼ぶときに照合される。 - * UI のみで使う command は省略可。 - */ - permissions?: PermissionKey[] - /** - * AI tool として公開するか。true & signature 付き & permissions が満たされる - * とき、AI tool schema に自動変換されて Anthropic / OpenAI の tools[] に乗る。 - * 未指定 / false なら UI のみ。 - */ - aiTool?: boolean - /** - * params / returns の型。AI tool schema / OpenAPI spec の自動生成に使う。 - * UI のみの command は省略可 (引数なし扱い)。 - */ - signature?: CapabilitySignature - /** - * `params.accountId` が「どのアカウントとして実行するか」(実行主体) を - * 意味する capability の宣言 (#777)。dispatcher は宣言付き capability で - * 非 user principal が呼び出し文脈と異なるアカウントを明示指定したとき、 - * `account.actAs` permission を要求し、確認を「今後確認しない」の対象外に - * する。UI スコープの accountId (column.add 等) には付けない。 - * write 系 (requiresConfirmation 宣言済み) のみが対象。 - */ - actsAsAccount?: boolean - /** - * 実行前にユーザー確認モーダルを出すか。AI / slash 経由 (= dispatchCapability - * から呼ばれる経路) でだけ enforce される。UI からの直接実行は対象外。 - * - * - `false` / 未指定 (default) — 確認なしで execute - * - `true` — `cap.label` + signature.description + 引数 JSON で汎用モーダル - * を出す (write 系はこれで十分) - * - 関数 — params を見て動的に `ConfirmOptions` を返す。`null` を返すと - * その回はスキップ可能 (= no-op エッジケース)。snapshot 取得など I/O が - * 必要なケースのため async (= `Promise` 返却) もサポート。 - */ - requiresConfirmation?: - | boolean - | (( - params: Record | undefined, - ctx: CapabilityContext, - ) => ConfirmOptions | null | Promise) - /** - * 確認ダイアログで「次回から確認しない」系のチェックボックス - * (`ConfirmOptions.rememberLabel`) が ON のまま許可されたときに呼ばれる。 - * 信頼状態の永続化など、確認スキップを次回以降に効かせる副作用をここで行う。 - * `requiresConfirmation` が `rememberLabel` 付き `ConfirmOptions` を返す - * capability だけが実装すればよい。 - */ - onConfirmRemember?: ( - params: Record | undefined, - ctx: CapabilityContext, - ) => void | Promise - /** - * confirm ダイアログより前に走る入力検証フック。`null` を返したら通常フロー、 - * `{ error }` を返したら dispatcher が `{ ok: false, code: 'preflight_failed' }` - * で即時 fail-fast する (= confirm を出さず AI に diagnostics を返す)。 - * - * 用途: AI が壊れた src を渡してきたとき、ユーザー確認ダイアログ表示前に - * 弾いて AI 内ループで自動修復させる二重防壁 (#553)。 - */ - preflight?: ( - params: Record | undefined, - ) => PreflightFailure | null | Promise -} - -export interface PreflightFailure { - /** AI へそのまま返るエラー文字列。診断 JSON を埋め込んでよい。 */ - error: string } export const useCommandStore = defineStore('commands', () => { @@ -151,21 +61,11 @@ export const useCommandStore = defineStore('commands', () => { function register(command: Command) { commands.value.set(command.id, command) triggerRef(commands) - // `aiTool: true` & `signature` 宣言済みの command は AI tool calling - // からも呼べるよう capability registry にミラー登録する。 - // (Phase 2 A-4: Command と Capability を Single Source of Truth で扱う) - if (command.aiTool && command.signature) { - registerCapability(command) - } } function unregister(id: string) { - const existing = commands.value.get(id) commands.value.delete(id) triggerRef(commands) - if (existing?.aiTool) { - unregisterCapability(id) - } } function getEnabled(): Command[] { diff --git a/src/components/common/MkChatMessage.vue b/src/components/common/MkChatMessage.vue index c7cd857e2..84677071b 100644 --- a/src/components/common/MkChatMessage.vue +++ b/src/components/common/MkChatMessage.vue @@ -6,12 +6,12 @@ import MkChatMessageMoreMenu from '@/components/common/MkChatMessageMoreMenu.vue import MkMfm from '@/components/common/MkMfm.vue' import { useEmojiResolver } from '@/composables/useEmojiResolver' import { USER_POPUP_HOVER, useHoverPopup } from '@/composables/useHoverPopup' +import { useMultiAccountAdapters } from '@/composables/useMultiAccountAdapters' import { useNavigation } from '@/composables/useNavigation' import { provideNoteAccountId } from '@/composables/useNoteContext' import { usePortal } from '@/composables/usePortal' import { i18n } from '@/i18n' import { proxyEmojiUrl, proxyThumbUrl } from '@/utils/mediaProxy' -import { commands, unwrap } from '@/utils/tauriInvoke' const MkUserPopup = defineAsyncComponent(() => import('./MkUserPopup.vue')) @@ -164,6 +164,7 @@ function handleReactionClick(reaction: string, reacted: boolean) { // User hover popup for mentions const mentionPopup = useHoverPopup(USER_POPUP_HOVER) +const { getOrCreate } = useMultiAccountAdapters() const mentionUserId = ref('') let mentionHovering = false @@ -176,9 +177,9 @@ async function onMentionHover( mentionHovering = true const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() try { - const user = unwrap( - await commands.apiLookupUser(props.accountId, username, host ?? null), - ) + const adapter = await getOrCreate(props.accountId) + if (!adapter) return + const user = await adapter.api.lookupUser(username, host) if (!mentionHovering) return mentionUserId.value = user.id mentionPopup.show({ x: rect.right + 8, y: rect.top }) diff --git a/src/components/common/MkDrivePicker.vue b/src/components/common/MkDrivePicker.vue index 35e9b16cd..a08c22430 100644 --- a/src/components/common/MkDrivePicker.vue +++ b/src/components/common/MkDrivePicker.vue @@ -5,17 +5,18 @@ import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import MkFileGrid from '@/components/common/MkFileGrid.vue' import MkFolderGrid from '@/components/common/MkFolderGrid.vue' import { useDriveFolder } from '@/composables/useDriveFolder' +import { useMultiAccountAdapters } from '@/composables/useMultiAccountAdapters' import { i18n } from '@/i18n' import { useThemeStore } from '@/stores/theme' import { useUiStore } from '@/stores/ui' import { AppError, authErrorMessage } from '@/utils/errors' -import { commands, unwrap } from '@/utils/tauriInvoke' const props = defineProps<{ accountId: string }>() const themeStore = useThemeStore() +const { getOrCreate } = useMultiAccountAdapters() const themeVars = computed(() => themeStore.getStyleVarsForAccount(props.accountId), ) @@ -58,17 +59,16 @@ async function onFileSelected(e: Event) { const uploaded: NormalizedDriveFile[] = [] for (const file of input.files) { const buf = await file.arrayBuffer() - const result = unwrap( - await commands.apiUploadFile( - props.accountId, - file.name, - [...new Uint8Array(buf)], - file.type || 'application/octet-stream', - false, - currentFolderId.value, - ), + const adapter = await getOrCreate(props.accountId) + if (!adapter) throw new Error('account not found') + const result = await adapter.api.uploadFile( + file.name, + [...new Uint8Array(buf)], + file.type || 'application/octet-stream', + false, + currentFolderId.value, ) - uploaded.push(result as unknown as NormalizedDriveFile) + uploaded.push(result) } await fetchDrive() // Auto-select uploaded files so user just taps 添付 to confirm. diff --git a/src/components/common/MkMediaLightbox.vue b/src/components/common/MkMediaLightbox.vue index 436d62792..044cc3cbf 100644 --- a/src/components/common/MkMediaLightbox.vue +++ b/src/components/common/MkMediaLightbox.vue @@ -8,6 +8,7 @@ import { watch, } from 'vue' import type { NormalizedDriveFile } from '@/adapters/types' +import { saveImageToFile } from '@/composables/imageFiles' import { useBackButton } from '@/composables/useBackButton' import { useClipboardFeedback } from '@/composables/useClipboardFeedback' import { useLongPress } from '@/composables/useLongPress' @@ -18,7 +19,6 @@ import { i18n } from '@/i18n' import { isSafeUrl } from '@/services/safeUrl' import { useSystemStateStore } from '@/stores/systemState' import { proxyUrl } from '@/utils/mediaProxy' -import { commands, unwrap } from '@/utils/tauriInvoke' import { openSafeUrl } from '@/utils/url' import MkMediaVideo from './MkMediaVideo.vue' import PopupMenu from './PopupMenu.vue' @@ -299,7 +299,7 @@ async function copyImage() { async function downloadImage() { const f = file.value if (!f?.url || !isSafeUrl(f.url)) return - unwrap(await commands.saveImageToFile(f.url)) + await saveImageToFile(f.url) menuRef.value?.close() } diff --git a/src/components/common/MkNote.vue b/src/components/common/MkNote.vue index 0904f1338..59c7042a7 100644 --- a/src/components/common/MkNote.vue +++ b/src/components/common/MkNote.vue @@ -75,7 +75,6 @@ import { expandedNoteContent, renderedReactionNotes, } from '@/utils/renderedMemo' -import { commands, unwrap } from '@/utils/tauriInvoke' import { extractColumnThemeVars } from '@/utils/themeVars' import { toggleReaction } from '@/utils/toggleReaction' import AccountAvatar from './AccountAvatar.vue' @@ -343,9 +342,8 @@ function openRenoteMenu(e: MouseEvent) { // Check if already renoted myRenoteId.value = null - commands - .apiGetNoteRenotes(props.note._accountId, effectiveNote.value.id, 30) - .then((r) => unwrap(r)) + apiFor(props.note._accountId) + .then((api) => api.getNoteRenotes(effectiveNote.value.id, { limit: 30 })) .then((renotes) => { const account = accountsStore.accountMap.get(props.note._accountId) const mine = renotes.find((r) => r.user.id === account?.userId) @@ -372,7 +370,7 @@ async function handleUnrenote() { isRenoted.value = false myRenoteId.value = null try { - unwrap(await commands.apiDeleteNote(props.note._accountId, renoteId)) + await (await apiFor(props.note._accountId)).deleteNote(renoteId) } catch { effectiveNote.value.renoteCount = (effectiveNote.value.renoteCount ?? 0) + 1 isRenoted.value = true @@ -739,12 +737,9 @@ watch( async function handleMentionClick(username: string, host: string | null) { try { - const user = unwrap( - await commands.apiLookupUser( - props.note._accountId, - username, - host ?? null, - ), + const user = await (await apiFor(props.note._accountId)).lookupUser( + username, + host, ) navToUser(props.note._accountId, user.id) } catch (e) { @@ -767,12 +762,9 @@ async function onMentionHover( const rect = el.getBoundingClientRect() popupTheme.value = extractColumnThemeVars(el) try { - const user = unwrap( - await commands.apiLookupUser( - props.note._accountId, - username, - host ?? null, - ), + const user = await (await apiFor(props.note._accountId)).lookupUser( + username, + host, ) if (!mentionHovering) return mentionUserId.value = user.id @@ -892,6 +884,13 @@ const pickerAccount = ref<{ serverHost: string; accountId: string } | null>( ) const crossQuoteTarget = ref(null) const { getOrCreate: getAdapterFor } = useMultiAccountAdapters() + +/** 取得元アカウントの API。口座が無ければ投げる (呼び出し側の catch で拾う) */ +async function apiFor(accountId: string) { + const adapter = await getAdapterFor(accountId) + if (!adapter) throw new Error(`account not found: ${accountId}`) + return adapter.api +} const { reactAs, renoteAs, quoteAs } = useCrossAccountNoteActions() function openCrossAccountPicker(accountId: string) { diff --git a/src/components/common/MkNoteEmbed.vue b/src/components/common/MkNoteEmbed.vue index 3f5266872..a8af27743 100644 --- a/src/components/common/MkNoteEmbed.vue +++ b/src/components/common/MkNoteEmbed.vue @@ -6,7 +6,6 @@ import { useNoteAccountId } from '@/composables/useNoteContext' import { parseNoteUrl } from '@/services/noteUrl' import { useAccountsStore } from '@/stores/accounts' import { usePerformanceStore } from '@/stores/performance' -import { commands, unwrap } from '@/utils/tauriInvoke' const perfStore = usePerformanceStore() @@ -66,9 +65,10 @@ async function fetchNote() { if (account && account.host === parsed.host) { return await api.getNote(parsed.noteId) } - const result = unwrap( - await commands.apiApShow(accountId, props.url), - ) as unknown as { type: string; object?: { id: string } } + const result = (await api.apShow(props.url)) as { + type: string + object?: { id: string } + } if (result.type === 'Note' && result.object?.id) { return await api.getNote(result.object.id) } diff --git a/src/components/common/MkReactionUsersPopup.vue b/src/components/common/MkReactionUsersPopup.vue index 1ed02551a..ae7b23f78 100644 --- a/src/components/common/MkReactionUsersPopup.vue +++ b/src/components/common/MkReactionUsersPopup.vue @@ -1,15 +1,14 @@