diff --git a/apps/desktop/plans/20260418-aivis-dictionary-and-usage.md b/apps/desktop/plans/20260418-aivis-dictionary-and-usage.md new file mode 100644 index 00000000000..45d83ff025e --- /dev/null +++ b/apps/desktop/plans/20260418-aivis-dictionary-and-usage.md @@ -0,0 +1,339 @@ +# Aivis: ユーザー辞書 & 日別使用量ダッシュボード + +作成日: 2026-04-18 +関連 Issue: #286 (Aivis 音声読み上げ機能の拡張) +関連 PR: #287 (Aivis 通知のベース実装) + +## 背景 + +PR #287 で Aivis API による音声読み上げ通知を実装済み。次のステップとして、開発者向けに刺さりそうな以下 2 機能を追加する。 + +1. **ユーザー辞書**: ブランチ名・プロジェクト名・英略語など特殊な読み方をする単語をカスタム登録し、音声合成に反映する +2. **日別使用量ダッシュボード**: Aivis の API 使用状況 (リクエスト数・文字数・クレジット消費) を日別に可視化する + +どちらも Settings > Notifications > Aivis セクション内に配置する。 + +## API 前提 + +### ユーザー辞書 + +| Method | Path | 用途 | +|---|---|---| +| GET | `/v1/user-dictionaries` | 辞書一覧 (uuid, name, description, word_count, created_at, updated_at) | +| GET | `/v1/user-dictionaries/{uuid}` | 辞書詳細 (word_properties 配列まで含む) | +| PUT | `/v1/user-dictionaries/{uuid}` | 辞書を丸ごと置き換え (作成・更新共通) | +| DELETE | `/v1/user-dictionaries/{uuid}` | 辞書削除 | +| POST | `/v1/user-dictionaries/{uuid}/import?override=true\|false` | AivisSpeech 互換 JSON を取り込み | +| GET | `/v1/user-dictionaries/{uuid}/export` | AivisSpeech 互換 JSON を出力 | + +**WordProperty フィールド**: + +```ts +{ + uuid: string; // クライアント側で UUID v4 を採番 + surface: string[]; // 表記 (配列) 例: ["Superset", "superset"] + normalized_surface: string[] | null; + pronunciation: string[]; // カタカナ読み 例: ["スーパーセット"] + accent_type: number[]; // アクセント核位置 (0 始まり)。不明なら 0 + word_type: "PROPER_NOUN" | "COMMON_NOUN" | "VERB" | "ADJECTIVE" | "SUFFIX"; // デフォルト PROPER_NOUN + priority: number; // 0-10、デフォルト 5 +} +``` + +**合成時の指定**: `POST /v1/tts/synthesize` のボディに `user_dictionary_uuid` (単一 uuid、オプション) を載せる。**複数辞書指定は不可**。 + +### 使用量サマリ + +| Method | Path | +|---|---| +| GET | `/v1/payment/usage-summaries?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD` | + +**レスポンス (1 行 = 1 時間 × 1 API キー)**: + +```ts +{ + summaries: Array<{ + api_key_id: string; + api_key_name: string; + summary_date: string; // YYYY-MM-DD + summary_hour: number; // 0-23 + request_count: number; + character_count: number; + credit_consumed: number; + }>; +} +``` + +日別グラフ化はクライアント側で `summary_date` ごとに集計する。API キーが複数ある場合もまとめて合算 (オプションでキー別表示)。 + +## 実装方針 + +### 全体構成 + +- Aivis API 呼び出しは main プロセスに集約 (API キーを renderer に流さない) + - 新規: `apps/desktop/src/main/lib/aivis/client.ts` — 汎用の authorized fetch ラッパー + - 新規: `apps/desktop/src/main/lib/aivis/dictionary.ts` — 辞書 CRUD + - 新規: `apps/desktop/src/main/lib/aivis/usage.ts` — 使用量取得 + 日別集計 +- TRPC `aivis` サブルーターを新設: `apps/desktop/src/lib/trpc/routers/aivis/index.ts` + - 既存の `settings.testAivisPlayback` もこちらに移植 (移植は別PRでも可) + - すべて API キーは main で DB から読み取るため、renderer からは引数不要 +- 既存の settings に `aivisUserDictionaryUuid` (text) を追加し、合成時に載せる + +### ステップ 1: API クライアント + +`apps/desktop/src/main/lib/aivis/client.ts`: + +```ts +const BASE = "https://api.aivis-project.com"; + +function readApiKey(): string | null { + const row = localDb.select().from(settings).get(); + return row?.aivisApiKey || null; +} + +export async function aivisFetch( + path: string, + init: RequestInit & { query?: Record } = {}, +): Promise { + const key = readApiKey(); + if (!key) throw new Error("Aivis API key is not configured"); + const url = new URL(path, BASE); + for (const [k, v] of Object.entries(init.query ?? {})) { + if (v !== undefined) url.searchParams.set(k, v); + } + const res = await fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${key}`, + Accept: "application/json", + ...(init.body && !(init.body instanceof FormData) + ? { "Content-Type": "application/json" } + : {}), + ...init.headers, + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Aivis ${res.status}: ${body.slice(0, 300)}`); + } + return res; +} +``` + +### ステップ 2: DB スキーマ + +`packages/local-db/src/schema/schema.ts` の settings テーブルに追加: + +- `aivisUserDictionaryUuid: text("aivis_user_dictionary_uuid")` — 合成時に適用する辞書 UUID + +マイグレーション自動生成: `bun run generate --name="add_aivis_user_dictionary_uuid"` + +### ステップ 3: 辞書 TRPC ルーター + +`apps/desktop/src/lib/trpc/routers/aivis/dictionary.ts`: + +```ts +export const aivisDictionaryRouter = router({ + list: publicProcedure.query(async () => { + const res = await aivisFetch("/v1/user-dictionaries"); + const json = await res.json(); + return json.user_dictionaries as Array<{ + uuid: string; + name: string; + description: string; + word_count: number; + updated_at: string; + }>; + }), + + get: publicProcedure.input(z.object({ uuid: z.string().uuid() })) + .query(async ({ input }) => { + const res = await aivisFetch(`/v1/user-dictionaries/${input.uuid}`); + return await res.json(); + }), + + upsert: publicProcedure.input(z.object({ + uuid: z.string().uuid(), // 新規作成時は crypto.randomUUID() + name: z.string().max(100), + description: z.string().max(500).default(""), + words: z.array(z.object({ + uuid: z.string().uuid(), + surface: z.array(z.string().min(1)).min(1), + pronunciation: z.array(z.string().min(1)).min(1), + accent_type: z.array(z.number().int().min(0)), + word_type: z.enum(["PROPER_NOUN","COMMON_NOUN","VERB","ADJECTIVE","SUFFIX"]).default("PROPER_NOUN"), + priority: z.number().int().min(0).max(10).default(5), + })), + })).mutation(async ({ input }) => { + await aivisFetch(`/v1/user-dictionaries/${input.uuid}`, { + method: "PUT", + body: JSON.stringify({ + name: input.name, + description: input.description, + word_properties: input.words, + }), + }); + return { success: true }; + }), + + delete: publicProcedure.input(z.object({ uuid: z.string().uuid() })) + .mutation(async ({ input }) => { + await aivisFetch(`/v1/user-dictionaries/${input.uuid}`, { method: "DELETE" }); + return { success: true }; + }), + + export: publicProcedure.input(z.object({ uuid: z.string().uuid() })) + .query(async ({ input }) => { + const res = await aivisFetch(`/v1/user-dictionaries/${input.uuid}/export`); + return await res.json(); // AivisSpeech 互換 Object + }), + + import: publicProcedure.input(z.object({ + uuid: z.string().uuid(), + data: z.record(z.string(), z.unknown()), // AivisSpeech 互換 + override: z.boolean().default(false), + })).mutation(async ({ input }) => { + await aivisFetch(`/v1/user-dictionaries/${input.uuid}/import`, { + method: "POST", + query: { override: String(input.override) }, + body: JSON.stringify(input.data), + }); + return { success: true }; + }), +}); +``` + +### ステップ 4: 合成呼び出しに辞書 UUID を付与 + +`apps/desktop/src/main/lib/notifications/aivis-tts.ts` を拡張: + +- `readAivisSettings()` に `userDictionaryUuid` を追加 +- `synthesize()` が受け取り、リクエストボディに `user_dictionary_uuid` を積む +- `playAivisTts` オプションにも `userDictionaryUuid?: string` を追加 + +### ステップ 5: 使用量サマリルーター + +`apps/desktop/src/lib/trpc/routers/aivis/usage.ts`: + +```ts +export const aivisUsageRouter = router({ + daily: publicProcedure.input(z.object({ + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + })).query(async ({ input }) => { + const res = await aivisFetch("/v1/payment/usage-summaries", { + query: { start_date: input.startDate, end_date: input.endDate }, + }); + const { summaries } = await res.json(); + + // summary_date で集計 + const byDate = new Map; + }>(); + for (const s of summaries) { + const entry = byDate.get(s.summary_date) ?? { + date: s.summary_date, + requestCount: 0, characterCount: 0, creditConsumed: 0, + byApiKey: {}, + }; + entry.requestCount += s.request_count; + entry.characterCount += s.character_count; + entry.creditConsumed += s.credit_consumed; + const bucket = entry.byApiKey[s.api_key_id] ?? { + name: s.api_key_name, requestCount: 0, characterCount: 0, creditConsumed: 0, + }; + bucket.requestCount += s.request_count; + bucket.characterCount += s.character_count; + bucket.creditConsumed += s.credit_consumed; + entry.byApiKey[s.api_key_id] = bucket; + byDate.set(s.summary_date, entry); + } + + return { + days: [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)), + total: { + requestCount: [...byDate.values()].reduce((a, b) => a + b.requestCount, 0), + characterCount: [...byDate.values()].reduce((a, b) => a + b.characterCount, 0), + creditConsumed: [...byDate.values()].reduce((a, b) => a + b.creditConsumed, 0), + }, + }; + }), +}); +``` + +### ステップ 6: 辞書 UI + +配置: `apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/AivisDictionary/` + +- 辞書リスト (name / word_count / 更新日) +- 辞書の新規作成 (name 入力 → crypto.randomUUID でローカル採番して upsert) +- 辞書選択 (ラジオ) → settings.aivisUserDictionaryUuid に保存 +- 辞書編集モーダル: + - 表形式で `surface / pronunciation / accent_type / priority` を編集 + - 行追加 / 削除 / 並べ替え + - accent_type は数値スピナー、word_type は select、priority は 0-10 スライダ +- エクスポート (ダウンロード) / インポート (JSON ファイル選択) +- 削除ボタン (確認ダイアログ) + +**バリデーション注意点**: +- `surface` / `pronunciation` は空配列不可、空文字の要素不可 +- `accent_type` が空なら 0 を自動補完 +- pronunciation はカタカナのみに制限 (正規表現 `/^[\u30A0-\u30FFー]+$/`) + +### ステップ 7: 使用量ダッシュボード UI + +配置: `apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/AivisUsage/` + +- 期間選択 (直近 7 日 / 30 日 / カスタム) +- 合計バー (リクエスト / 文字数 / クレジット) +- 日別棒グラフ (シンプルに CSS で作るか、既に入っていれば recharts を利用) + - y 軸: クレジット消費 (既定)。トグルで request_count / character_count に切替 +- 日別テーブル (日付 / リクエスト / 文字数 / クレジット) +- API キーが複数ある場合のみ「キー別内訳」アコーディオン + +**依存追加の判断**: +- 既に `recharts` / `visx` / `chart.js` 等が入っていれば流用 +- なければ最初はシンプルな CSS バーで十分 (過剰依存を避ける) + +## タスク分解 + +1. local-db: `aivisUserDictionaryUuid` 追加 + migration 生成 +2. main: `aivis/client.ts` 追加 (authorized fetch) +3. main: `aivis/dictionary.ts` (ラッパ)、`aivis/usage.ts` (集計ロジック) 追加 +4. TRPC: `aivis` サブルーター (dictionary + usage) を登録 +5. main: `aivis-tts.ts` に `user_dictionary_uuid` を付与するよう拡張 +6. settings: `getAivisSettings`/`setAivisSettings` に `userDictionaryUuid` を追加 +7. UI: AivisDictionary (一覧 + 編集モーダル + import/export) +8. UI: AivisUsage (期間選択 + グラフ + テーブル) +9. 既存 AivisSettings に辞書セレクタを追加 +10. Settings 検索に辞書/使用量アイテムを追加 +11. typecheck / lint / 実機動作確認 + +## リスク・論点 + +- **API レート/クレジット消費**: 使用量グラフの描画で `usage-summaries` を頻繁に叩かない (フォーカス時のみ or 手動更新)。キャッシュ TTL 5 分程度。 +- **エラー表示**: Aivis 401 (キー無効) 時に UI 全体を無効化するか、辞書/使用量だけエラー表示にするか。後者推奨。 +- **辞書 UI の複雑度**: アクセント型の編集は UX が難しい。MVP では数値入力で十分。将来的に実音声プレビュー + アクセントビジュアライザを検討。 +- **複数 API キー**: Aivis 側でキー切替・失効管理が可能。今は単一キー前提だが、将来的には複数キーに拡張できる設計にする (`api_key_id` で集計済み)。 +- **辞書の単語上限**: API ドキュメントに明示上限なし。数千行になるケースを想定し、テーブル UI は仮想スクロールを検討。 + +## 段階リリース案 + +- **Phase 1** (この PR): 辞書 CRUD (最小限の UI) + 合成時適用 +- **Phase 2** (続く PR): 使用量ダッシュボード +- **Phase 3** (余力): 辞書エディタの UX 強化 (音声プレビュー、アクセントビジュアル) + +Phase 1 と 2 は独立しているため、同時に PR を分けて進めても良い。 + +## 完了条件 + +- [ ] 辞書を作成・編集・削除できる +- [ ] 作成した辞書を通知音声合成に適用できる (固有名詞が期待通り読まれる) +- [ ] AivisSpeech 互換 JSON の import/export が動作 +- [ ] 日別使用量を直近 7 日 / 30 日で表示できる +- [ ] API キー未設定時は適切な誘導が出る +- [ ] typecheck / lint / 既存テスト がすべて緑 diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index ec824b96f3a..d2db9974c7c 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -1040,6 +1040,79 @@ export const createSettingsRouter = () => { return { success: true }; }), + getAivisSettings: publicProcedure.query(() => { + const row = getSettings(); + return { + enabled: row.aivisEnabled ?? false, + apiKey: row.aivisApiKey ?? "", + modelUuid: row.aivisModelUuid ?? "", + format: row.aivisFormat ?? "ワークスペース、{{workspace}}、です", + formatPermission: + row.aivisFormatPermission ?? "{{branch}}で対応が必要です", + }; + }), + + setAivisSettings: publicProcedure + .input( + z.object({ + enabled: z.boolean().optional(), + apiKey: z.string().optional(), + modelUuid: z.string().optional(), + format: z.string().optional(), + formatPermission: z.string().optional(), + }), + ) + .mutation(({ input }) => { + const values: Record = { id: 1 }; + const set: Record = {}; + if (input.enabled !== undefined) { + values.aivisEnabled = input.enabled; + set.aivisEnabled = input.enabled; + } + if (input.apiKey !== undefined) { + values.aivisApiKey = input.apiKey; + set.aivisApiKey = input.apiKey; + } + if (input.modelUuid !== undefined) { + values.aivisModelUuid = input.modelUuid; + set.aivisModelUuid = input.modelUuid; + } + if (input.format !== undefined) { + values.aivisFormat = input.format; + set.aivisFormat = input.format; + } + if (input.formatPermission !== undefined) { + values.aivisFormatPermission = input.formatPermission; + set.aivisFormatPermission = input.formatPermission; + } + localDb + .insert(settings) + .values(values) + .onConflictDoUpdate({ target: settings.id, set }) + .run(); + return { success: true }; + }), + + testAivisPlayback: publicProcedure + .input( + z.object({ + apiKey: z.string(), + modelUuid: z.string(), + text: z.string().min(1).max(3000), + }), + ) + .mutation(async ({ input }) => { + const { playAivisTts } = await import( + "main/lib/notifications/aivis-tts" + ); + await playAivisTts({ + apiKey: input.apiKey, + modelUuid: input.modelUuid, + text: input.text, + }); + return { success: true }; + }), + getFontSettings: publicProcedure.query(() => { const row = getSettings(); return { diff --git a/apps/desktop/src/main/lib/notification-sound.ts b/apps/desktop/src/main/lib/notification-sound.ts index 9a530aa6daa..cb06ff000d9 100644 --- a/apps/desktop/src/main/lib/notification-sound.ts +++ b/apps/desktop/src/main/lib/notification-sound.ts @@ -53,10 +53,15 @@ function getSelectedRingtonePath(): string | null { /** * Plays the notification sound based on user's selected ringtone. * Uses platform-specific commands to play the audio file. + * + * `onComplete` fires when playback finishes, or immediately when playback + * is skipped (muted / no ringtone). Callers can chain follow-up audio + * (e.g. Aivis TTS) so it plays after the ringtone instead of overlapping. */ -export function playNotificationSound(): void { +export function playNotificationSound(onComplete?: () => void): void { // Check if sounds are muted if (areNotificationSoundsMuted()) { + onComplete?.(); return; } @@ -64,6 +69,7 @@ export function playNotificationSound(): void { // No sound if "none" is selected if (!soundPath) { + onComplete?.(); return; } @@ -84,5 +90,12 @@ export function playNotificationSound(): void { volume = 100; } - playSoundFile(soundPath, volume); + let done = false; + const finish = () => { + if (done) return; + done = true; + onComplete?.(); + }; + const proc = playSoundFile(soundPath, volume, { onComplete: finish }); + if (!proc) finish(); } diff --git a/apps/desktop/src/main/lib/notifications/aivis-tts.ts b/apps/desktop/src/main/lib/notifications/aivis-tts.ts new file mode 100644 index 00000000000..98befc2d748 --- /dev/null +++ b/apps/desktop/src/main/lib/notifications/aivis-tts.ts @@ -0,0 +1,157 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { settings } from "@superset/local-db"; +import { localDb } from "../local-db"; +import { playSoundFile } from "../play-sound"; + +export type AivisEventKind = "complete" | "permission"; + +export interface AivisPlaceholders { + branch?: string; + workspace?: string; + worktree?: string; + tab?: string; + pane?: string; + event?: string; +} + +const AIVIS_ENDPOINT = "https://api.aivis-project.com/v1/tts/synthesize"; + +export const AIVIS_PLACEHOLDER_KEYS = [ + "branch", + "workspace", + "worktree", + "tab", + "pane", + "event", +] as const satisfies readonly (keyof AivisPlaceholders)[]; + +export function renderAivisTemplate( + template: string, + vars: AivisPlaceholders, +): string { + return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key: string) => { + const value = vars[key as keyof AivisPlaceholders]; + return value ?? ""; + }); +} + +function readAivisSettings() { + try { + const row = localDb.select().from(settings).get(); + return { + enabled: row?.aivisEnabled ?? false, + apiKey: row?.aivisApiKey ?? "", + modelUuid: row?.aivisModelUuid ?? "", + format: row?.aivisFormat ?? "ワークスペース、{{workspace}}、です", + formatPermission: + row?.aivisFormatPermission ?? "{{branch}}で対応が必要です", + volume: + typeof row?.notificationVolume === "number" && + Number.isFinite(row.notificationVolume) + ? Math.max(0, Math.min(100, row.notificationVolume)) + : 100, + }; + } catch { + return null; + } +} + +async function synthesize( + apiKey: string, + modelUuid: string, + text: string, +): Promise { + const res = await fetch(AIVIS_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + Accept: "audio/mpeg", + }, + body: JSON.stringify({ + model_uuid: modelUuid, + text, + output_format: "mp3", + }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `Aivis API error: ${res.status} ${res.statusText} ${body.slice(0, 200)}`, + ); + } + + const arrayBuffer = await res.arrayBuffer(); + return Buffer.from(arrayBuffer); +} + +function uniqueTmpPath(): string { + return join( + tmpdir(), + `superset-aivis-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp3`, + ); +} + +function cleanup(path: string): void { + execFile("rm", ["-f", path], () => { + /* ignore */ + }); +} + +/** + * Synthesize text via Aivis API and play it. + * Called with explicit apiKey/modelUuid (used by both the test endpoint + * and the runtime notification flow). + */ +export async function playAivisTts(options: { + apiKey: string; + modelUuid: string; + text: string; + volume?: number; +}): Promise { + const trimmed = options.text.trim(); + if (!trimmed) return; + if (!options.apiKey || !options.modelUuid) { + throw new Error("Aivis API key and model UUID are required"); + } + + const audio = await synthesize(options.apiKey, options.modelUuid, trimmed); + const path = uniqueTmpPath(); + await writeFile(path, audio); + + playSoundFile(path, options.volume ?? 100, { + onComplete: () => cleanup(path), + }); +} + +/** + * Render the configured template for the given event and play it. + * No-op if aivis is disabled, not configured, or the rendered text is empty. + */ +export async function playAivisNotification( + event: AivisEventKind, + vars: AivisPlaceholders, +): Promise { + const cfg = readAivisSettings(); + if (!cfg || !cfg.enabled) return; + if (!cfg.apiKey || !cfg.modelUuid) return; + + const template = event === "permission" ? cfg.formatPermission : cfg.format; + const text = renderAivisTemplate(template, vars).trim(); + if (!text) return; + + try { + await playAivisTts({ + apiKey: cfg.apiKey, + modelUuid: cfg.modelUuid, + text, + volume: cfg.volume, + }); + } catch (err) { + console.warn("[aivis-tts] playback failed", err); + } +} diff --git a/apps/desktop/src/main/lib/notifications/notification-manager.ts b/apps/desktop/src/main/lib/notifications/notification-manager.ts index 0ad7facd07b..fd8c7dbf5c2 100644 --- a/apps/desktop/src/main/lib/notifications/notification-manager.ts +++ b/apps/desktop/src/main/lib/notifications/notification-manager.ts @@ -21,7 +21,8 @@ export interface NotificationManagerDeps { body: string; silent: boolean; }) => NativeNotification; - playSound: () => void; + playSound: (onComplete?: () => void) => void; + playAivis?: (event: AgentLifecycleEvent) => void; onNotificationClick: (ids: NotificationIds) => void; getVisibilityContext: () => { isFocused: boolean; @@ -77,7 +78,9 @@ export class NotificationManager { const key = event.sessionId ?? event.paneId ?? `_anon_${this.counter++}`; this.track(key, notification); - this.deps.playSound(); + // Chain Aivis after the ringtone so the voice announcement plays + // once the notification sound finishes rather than in parallel. + this.deps.playSound(() => this.deps.playAivis?.(event)); notification.on("click", () => { this.deps.onNotificationClick({ diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 965b8975b35..c17a14287e0 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -19,6 +19,7 @@ import { appState } from "../lib/app-state"; import { browserManager } from "../lib/browser/browser-manager"; import { createApplicationMenu } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; +import { playAivisNotification } from "../lib/notifications/aivis-tts"; import { NotificationManager } from "../lib/notifications/notification-manager"; import { notificationsApp, @@ -46,28 +47,52 @@ import { getWorkspaceRuntimeRegistry } from "../lib/workspace-runtime"; // Singleton IPC handler to prevent duplicate handlers on window reopen (macOS) let ipcHandler: ReturnType | null = null; -function getWorkspaceNameFromDb(workspaceId: string | undefined): string { - if (!workspaceId) return "Workspace"; +function getWorkspaceRecords(workspaceId: string | undefined) { + if (!workspaceId) return { workspace: null, worktree: null }; try { - const workspace = localDb - .select() - .from(workspaces) - .where(eq(workspaces.id, workspaceId)) - .get(); + const workspace = + localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get() ?? null; const worktree = workspace?.worktreeId - ? localDb + ? (localDb .select() .from(worktrees) .where(eq(worktrees.id, workspace.worktreeId)) - .get() - : undefined; - return getWorkspaceName({ workspace, worktree }); + .get() ?? null) + : null; + return { workspace, worktree }; } catch (error) { - console.error("[notifications] Failed to get workspace name:", error); - return "Workspace"; + console.error("[notifications] Failed to read workspace records:", error); + return { workspace: null, worktree: null }; } } +function getWorkspaceNameFromDb(workspaceId: string | undefined): string { + const { workspace, worktree } = getWorkspaceRecords(workspaceId); + return getWorkspaceName({ workspace, worktree }); +} + +function buildAivisVars(event: AgentLifecycleEvent) { + const { workspace, worktree } = getWorkspaceRecords(event.workspaceId); + const tabs = appState.data?.tabsState?.tabs; + const panes = appState.data?.tabsState?.panes; + const tab = event.tabId ? tabs?.find((t) => t.id === event.tabId) : undefined; + const pane = event.paneId ? panes?.[event.paneId] : undefined; + const branch = worktree?.branch ?? ""; + const worktreeName = branch || ""; + return { + branch, + workspace: workspace?.name || branch || "", + worktree: worktreeName, + tab: (tab?.userTitle?.trim() || tab?.name) ?? "", + pane: pane?.name ?? "", + event: event.eventType, + }; +} + let currentWindow: BrowserWindow | null = null; let mainWindowCleanup: (() => void) | null = null; @@ -223,6 +248,11 @@ export async function MainWindow() { isSupported: () => Notification.isSupported(), createNotification: (opts) => new Notification(opts), playSound: playNotificationSound, + playAivis: (event) => { + const kind = + event.eventType === "PermissionRequest" ? "permission" : "complete"; + void playAivisNotification(kind, buildAivisVars(event)); + }, onNotificationClick: (ids) => { window.show(); window.focus(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/AivisSettings/AivisSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/AivisSettings/AivisSettings.tsx new file mode 100644 index 00000000000..423930c0f1c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/AivisSettings/AivisSettings.tsx @@ -0,0 +1,249 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { Switch } from "@superset/ui/switch"; +import { Textarea } from "@superset/ui/textarea"; +import { useEffect, useRef, useState } from "react"; +import { HiPlay } from "react-icons/hi2"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + isItemVisible, + SETTING_ITEM_ID, + type SettingItemId, +} from "../../../utils/settings-search"; + +const PLACEHOLDERS = [ + { key: "branch", label: "ブランチ" }, + { key: "workspace", label: "ワークスペース" }, + { key: "worktree", label: "ワークツリー" }, + { key: "tab", label: "タブ" }, + { key: "pane", label: "ペーン" }, + { key: "event", label: "イベント" }, +] as const; + +interface AivisSettingsProps { + visibleItems?: SettingItemId[] | null; +} + +export function AivisSettings({ visibleItems }: AivisSettingsProps) { + const visible = isItemVisible(SETTING_ITEM_ID.RINGTONES_AIVIS, visibleItems); + + const utils = electronTrpc.useUtils(); + const { data } = electronTrpc.settings.getAivisSettings.useQuery(); + const save = electronTrpc.settings.setAivisSettings.useMutation({ + onSuccess: () => utils.settings.getAivisSettings.invalidate(), + }); + const testPlay = electronTrpc.settings.testAivisPlayback.useMutation(); + + const [enabled, setEnabled] = useState(false); + const [apiKey, setApiKey] = useState(""); + const [modelUuid, setModelUuid] = useState(""); + const [format, setFormat] = useState(""); + const [formatPermission, setFormatPermission] = useState(""); + const [testError, setTestError] = useState(null); + + const formatRef = useRef(null); + const formatPermissionRef = useRef(null); + const [activeField, setActiveField] = useState<"format" | "permission">( + "format", + ); + const hydratedRef = useRef(false); + + useEffect(() => { + if (!data || hydratedRef.current) return; + hydratedRef.current = true; + setEnabled(data.enabled); + setApiKey(data.apiKey); + setModelUuid(data.modelUuid); + setFormat(data.format); + setFormatPermission(data.formatPermission); + }, [data]); + + if (!visible) return null; + + const insertPlaceholder = (key: string) => { + const ref = activeField === "permission" ? formatPermissionRef : formatRef; + const setter = + activeField === "permission" ? setFormatPermission : setFormat; + const current = activeField === "permission" ? formatPermission : format; + const token = `{{${key}}}`; + const el = ref.current; + if (!el) { + setter(current + token); + return; + } + const start = el.selectionStart ?? current.length; + const end = el.selectionEnd ?? current.length; + const next = current.slice(0, start) + token + current.slice(end); + setter(next); + requestAnimationFrame(() => { + el.focus(); + const pos = start + token.length; + el.setSelectionRange(pos, pos); + }); + }; + + const handleToggle = (next: boolean) => { + setEnabled(next); + save.mutate({ enabled: next }); + }; + + const commit = (patch: Parameters[0]) => { + save.mutate(patch); + }; + + const handleTest = async (kind: "complete" | "permission") => { + setTestError(null); + const template = kind === "permission" ? formatPermission : format; + const rendered = template + .replace(/\{\{\s*branch\s*\}\}/g, "サンプルブランチ") + .replace(/\{\{\s*workspace\s*\}\}/g, "サンプルワークスペース") + .replace(/\{\{\s*worktree\s*\}\}/g, "サンプルワークツリー") + .replace(/\{\{\s*tab\s*\}\}/g, "ターミナル") + .replace(/\{\{\s*pane\s*\}\}/g, "ペーン1") + .replace( + /\{\{\s*event\s*\}\}/g, + kind === "permission" ? "PermissionRequest" : "Stop", + ) + .replace(/\{\{\s*\w+\s*\}\}/g, ""); + try { + await testPlay.mutateAsync({ + apiKey, + modelUuid, + text: rendered || "テストです", + }); + } catch (err) { + setTestError(err instanceof Error ? err.message : String(err)); + } + }; + + return ( +
+
+

Aivis Voice Announcement

+

+ 通知音の後に Aivis API + でワークスペース名やブランチ名を音声で読み上げます。 +

+
+ +
+
+ +

+ LLM の動作完了時と許可要求時に音声で通知します。 +

+
+ +
+ +
+ + setApiKey(e.target.value)} + onBlur={() => commit({ apiKey })} + placeholder="aivis_..." + disabled={!enabled} + /> +
+ +
+ + setModelUuid(e.target.value)} + onBlur={() => commit({ modelUuid })} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + disabled={!enabled} + /> +
+ +
+
+ + + {activeField === "permission" + ? "許可要求フォーマットに挿入" + : "完了フォーマットに挿入"} + +
+
+ {PLACEHOLDERS.map((p) => ( + + ))} +
+
+ +
+
+ + +
+