commit 24b07a7bb2aea74154270577a4028aa7fc322e52 Author: shin-fukasawa1 Date: Fri Apr 17 16:11:31 2026 +0900 feat: リアルタイム議事録システム初期リリース Made-with: Cursor diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..3db4167 --- /dev/null +++ b/.cursorrules @@ -0,0 +1 @@ +常に日本語で回答してください。 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5225dda --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# ===== 必須 ===== +# 1P: ADS 議事録システム / Deepgram API Key +DEEPGRAM_API_KEY={{ op://AI-Driven School/aymxccrse4mmlhunqej4ifjjme/password }} +# 1P: ADS 議事録システム / Google AI API Key +GOOGLE_GENERATIVE_AI_API_KEY={{ op://AI-Driven School/gj6ah5wib6to3pxydvx2xbthve/password }} + +# ===== オプション ===== +# PORT=3001 +# +# 会議設定(参加者・キーターム・置換ルール)は config.yaml で管理します diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..794b761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +output/ +dist/ +.env +pnpm-config.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..2969f74 --- /dev/null +++ b/README.md @@ -0,0 +1,214 @@ +# リアルタイム議事録システム + +会議音声をリアルタイムで文字起こしし、タスクを自動抽出するツールです。 + +## 何ができるか + +マイクで拾った会議の音声を、裏側でリアルタイムに文字起こしします。さらに 30 秒ごとに会話内容を AI が読み取り、「誰が/いつまでに/何をやるか」をタスクとして自動で抜き出します。 + +``` +マイク → 音声認識(Deepgram) → 文字起こしファイル(transcript.md) + ↓ + AI(Gemini) → タスク一覧ファイル(tasks.md) +``` + +会議が終わると、手元に 2 つの Markdown ファイルが残ります。 + +- **transcript.md** — 会議の全文記録(タイムスタンプ付き) +- **tasks.md** — 抽出されたタスクの一覧表(担当者・期限・根拠つき) + +## 前提条件 + +以下のツールが必要です。 + +| ツール | バージョン | インストール方法 | +|--------|-----------|-----------------| +| Node.js | 20 以上 | https://nodejs.org/ | +| pnpm | 最新 | `npm install -g pnpm` | +| just | 最新 | `brew install just`(macOS) | + +## セットアップ + +### 1. API キーの取得 + +2つの API キーが必要です。 + +**Deepgram**(音声認識) + +1. https://console.deepgram.com/ にアクセス +2. アカウントを作成(または既存アカウントでログイン) +3. 「API Keys」から新しいキーを作成 +4. キーをコピー + +**Google AI**(タスク抽出) + +1. https://aistudio.google.com/apikey にアクセス +2. Google アカウントでログイン +3. 「Create API key」でキーを作成 +4. キーをコピー + +### 2. .env ファイルの作成 + +```bash +cp .env.example .env +``` + +テキストエディタで `.env` を開き、取得した API キーを貼り付けてください。 + +``` +DEEPGRAM_API_KEY=ここにDeepgramのキーを貼り付け +GOOGLE_GENERATIVE_AI_API_KEY=ここにGoogle AIのキーを貼り付け +``` + +### 3. 会議設定(config.yaml) + +`config.yaml` を開いて、会議に合わせて編集します。設定しなくても動きますが、設定すると精度が上がります。 + +**参加者名** + +会議の参加者名を登録すると、音声認識で名前が正しく聞き取られやすくなります。タスク抽出でも、担当者名の表記ミスを防げます。 + +```yaml +participants: + - 田中 + - 鈴木 + - 佐藤 +``` + +**キーターム(専門用語)** + +会議で出てくる専門用語・プロジェクト名・ブランド名を登録すると、音声認識の精度が上がります。20〜50 語が目安です。 + +```yaml +keyterms: + - AI-Driven School + - Deepgram + - Linear + - Slack +``` + +**置換ルール(任意)** + +音声認識でよくある誤変換パターンを自動修正できます。キータームで解決しない場合の補助手段です。 + +```yaml +replacements: + - find: 深グラム + replace: Deepgram +``` + +## 使い方 + +### 立ち上げ方 + +1. Cursor でこのプロジェクトフォルダを開く +2. **Cmd + J** でターミナルを開く +3. 以下を入力して Enter + +```bash +just start +``` + +初回は必要なパッケージの自動インストールが走るため、数十秒ほどかかります。ターミナルに起動完了のメッセージが出たら準備完了です。 + +4. ブラウザで http://localhost:5173 を開く + +> `just` はよく使うコマンドに名前を付けてまとめたものです。中身を知らなくても `just start` だけ覚えれば OK です。 +> +> ターミナル操作に不安がある場合は、Cursor の AI に「このプロジェクトを立ち上げて」と頼んでも大丈夫です。 + +### 録音する + +1. ブラウザで「**録音を開始**」ボタンを押す + - 初回はマイクの使用許可を求められるので「許可」を選んでください +2. 録音中は画面に最新の文字起こしが表示されます。裏では 30 秒ごとにタスクも自動抽出されています +3. 会議が終わったら「**録音を停止**」ボタンを押す + +### 停止 + +- ターミナルで **Ctrl+C** を押す +- または別のターミナルで `just stop` を実行 + +### 出力ファイル + +`output/` フォルダに、録音ごとに 2 つのファイルが生成されます。 + +``` +output/ + meeting-2026-04-17T14-00-00-transcript.md + meeting-2026-04-17T14-00-00-tasks.md +``` + +**transcript.md の例** + +```markdown +# 会議メモ 2026/04/17 14:00 + +## 参加者 +田中、鈴木、佐藤 + +--- + +**[14:00:05]** 今日は来週のリリースについて話しましょう + +**[14:00:12]** デザインの最終確認がまだ終わっていないんですが + +**[14:00:20]** 佐藤さん、明日までにレビューお願いできますか +``` + +**tasks.md の例** + +```markdown +# 抽出タスク + +| # | タスク | 担当 | 期限 | 根拠 | +|---|--------|------|------|------| +| 1 | デザインの最終確認レビュー | 佐藤 | 明日 | 佐藤さん、明日までにレビューお願いできますか | +``` + +議事録としてチームに共有したり、タスクをスプレッドシートや Linear に転記して使えます。 + +## トラブルシューティング + +### 「.env ファイルが見つかりません」と表示される + +上記の「.env ファイルの作成」の手順に従ってください。 + +### 「APIキーが無効です」と表示される + +- API キーが正しくコピーされているか確認してください(前後にスペースが入っていないか) +- Deepgram: https://console.deepgram.com/ でキーが有効か確認 +- Google AI: https://aistudio.google.com/apikey でキーが有効か確認 + +### 「ポート 3001 が使用中です」と表示される + +前回のサーバーが残っている可能性があります。 + +```bash +just stop +``` + +を実行してから、再度 `just start` を試してください。 + +### マイクが使えない / ブロックされている + +Chrome の場合: アドレスバーの鍵アイコン →「サイトの設定」→ マイク →「許可」に変更し、ページを再読み込みしてください。 + +### 「サーバーとの接続に失敗しました」と表示される + +サーバーが起動していない可能性があります。ターミナルでエラーが出ていないか確認し、`just start` で再起動してください。 + +### 文字起こしの精度が低い + +- `config.yaml` のキータームに、会議で出てくる専門用語を追加してください +- マイクに近い位置で話すと精度が上がります +- 複数人が同時に話すと認識精度が下がります + +### タスクが抽出されない + +- 会話が短すぎると(2 文以下)タスクは抽出されません +- Google AI の無料枠を超えると一時的にタスク抽出が停止します。しばらく待つか、API キーのプランを確認してください + +### 推奨ブラウザ + +**Chrome** を推奨します。Safari や Firefox ではマイクの挙動が異なる場合があります。 diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..1dac9e2 --- /dev/null +++ b/config.yaml @@ -0,0 +1,25 @@ +# 会議設定 +# このファイルを編集して、音声認識の精度をチューニングできます + +# 参加者名(タスク抽出の担当者候補として使用) +# 参加者名はキータームにも自動追加され、名前の認識精度が上がります +participants: + - 田中 + - 鈴木 + - 佐藤 + +# 音声認識のキーターム +# 会議で出てくる専門用語・プロジェクト名・ブランド名を指定すると認識精度が上がります +# 上限: 全キータームで合計500トークン(20〜50語が目安) +keyterms: + - AI-Driven School + - Deepgram + - Linear + - Slack + +# 認識後の置換ルール(任意) +# 既知の誤変換パターンを修正します +# keyterms で解決しない場合の補助手段です +replacements: + - find: 深グラム + replace: Deepgram diff --git a/index.html b/index.html new file mode 100644 index 0000000..f5a82c6 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + リアルタイム議事録 + + +
+ + + diff --git a/justfile b/justfile new file mode 100644 index 0000000..b9f9340 --- /dev/null +++ b/justfile @@ -0,0 +1,21 @@ +# リアルタイム議事録システム + +# サーバー起動 +start *args: + #!/usr/bin/env bash + command -v node >/dev/null 2>&1 || { echo "Node.js がインストールされていません。https://nodejs.org/ からインストールしてください"; exit 1; } + command -v pnpm >/dev/null 2>&1 || { echo "pnpm がインストールされていません。npm install -g pnpm を実行してください"; exit 1; } + [ -d node_modules ] || pnpm install + pnpm tsx server/preflight.ts + pnpm dev {{args}} + +# サーバー停止 +stop: + #!/usr/bin/env bash + pids=$(lsof -ti :3001,:5173 2>/dev/null) + [[ -n "$pids" ]] && kill $pids 2>/dev/null || true + +# サーバー再起動 +restart *args: + just stop + just start {{args}} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6f98234 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "realtime-meeting-minutes", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently --kill-others-on-fail \"vite\" \"tsx watch server/index.ts\"", + "dev:client": "vite", + "dev:server": "tsx watch server/index.ts", + "build": "tsc && vite build" + }, + "packageManager": "pnpm@10.24.0", + "dependencies": { + "@ai-sdk/google": "^3.0.60", + "@deepgram/sdk": "^5.0.0", + "ai": "^6.0.154", + "clsx": "^2.1.1", + "dotenv": "^17.4.1", + "express": "^5.2.1", + "lucide-react": "^1.8.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "tailwind-merge": "^3.5.0", + "ws": "^8.20.0", + "yaml": "^2.8.3", + "zod": "^4.3.6" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/express": "^5.0.6", + "@types/node": "^25.5.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "^6.0.1", + "concurrently": "^9.2.1", + "postcss": "^8.5.9", + "tailwindcss": "^4.2.2", + "tsx": "^4.21.0", + "typescript": "^6.0.2", + "vite": "^8.0.8" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..999531d --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2124 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ai-sdk/google': + specifier: ^3.0.60 + version: 3.0.60(zod@4.3.6) + '@deepgram/sdk': + specifier: ^5.0.0 + version: 5.0.0 + ai: + specifier: ^6.0.154 + version: 6.0.154(zod@4.3.6) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dotenv: + specifier: ^17.4.1 + version: 17.4.1 + express: + specifier: ^5.2.1 + version: 5.2.1 + lucide-react: + specifier: ^1.8.0 + version: 1.8.0(react@19.2.5) + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + ws: + specifier: ^8.20.0 + version: 8.20.0 + yaml: + specifier: ^2.8.3 + version: 2.8.3 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.2(vite@8.0.8(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.8(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + concurrently: + specifier: ^9.2.1 + version: 9.2.1 + postcss: + specifier: ^8.5.9 + version: 8.5.9 + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^8.0.8 + version: 8.0.8(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + +packages: + + '@ai-sdk/gateway@3.0.94': + resolution: {integrity: sha512-uDDwLZhCkvC89crVS3S90D5L7AcVN8WriGuYVNYgVAaVcvy3Mthy3R9ICfzG75BObhz6pm2FWnhxDfNRK+t69Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/google@3.0.60': + resolution: {integrity: sha512-ye/hG0LeO24VmjLbfgkFZV8V8k/l4nVBODutpJQkFPyUiGOCbFtFUTgxSeC7+njrk5+HhgyHrzJay4zmhwMH+w==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.23': + resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + + '@deepgram/sdk@5.0.0': + resolution: {integrity: sha512-x1wMiOgDGqcLEaQpQBQLTtk5mLbXbYgcBEpp7cfJIyEtqdIGgijCZH+a/esiVp+xIcTYYroTxG47RVppZOHbWw==} + engines: {node: '>=18.0.0'} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.3': + resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@oxc-project/types@0.124.0': + resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + + '@rolldown/binding-android-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.15': + resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/node@25.5.2': + resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ai@6.0.154: + resolution: {integrity: sha512-HfKJKCTJsDZxqrIUDSVnBQ7DpQlx5WI4ExqtLd7Bl70epLmvkpc/HYMzU1hP9W+g9VEAcvZo4fbMqc3v5D+9gQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} + engines: {node: '>=18'} + hasBin: true + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lucide-react@1.8.0: + resolution: {integrity: sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + engines: {node: ^10 || ^12 || >=14} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.0.0-rc.15: + resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@8.0.8: + resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@ai-sdk/gateway@3.0.94(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 + + '@ai-sdk/google@3.0.60(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.23(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + + '@deepgram/sdk@5.0.0': + dependencies: + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@opentelemetry/api@1.9.0': {} + + '@oxc-project/types@0.124.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.15': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + + '@standard-schema/spec@1.1.0': {} + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@8.0.8(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 8.0.8(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.5.2 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.5.2 + + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 25.5.2 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/node@25.5.2': + dependencies: + undici-types: 7.18.2 + + '@types/qs@6.15.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.5.2 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.5.2 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.5.2 + + '@vercel/oidc@3.1.0': {} + + '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.8(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + ai@6.0.154(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 3.0.94(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + '@opentelemetry/api': 1.9.0 + zod: 4.3.6 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concurrently@9.2.1: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.8.3 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + dotenv@17.4.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-promise@4.0.0: {} + + jiti@2.6.1: {} + + json-schema@0.4.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lucide-react@1.8.0(react@19.2.5): + dependencies: + react: 19.2.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + negotiator@1.0.0: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.9: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + + react@19.2.5: {} + + require-directory@2.1.1: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.0.0-rc.15: + dependencies: + '@oxc-project/types': 0.124.0 + '@rolldown/pluginutils': 1.0.0-rc.15 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-x64': 1.0.0-rc.15 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + statuses@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + toidentifier@1.0.1: {} + + tree-kill@1.2.2: {} + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@6.0.2: {} + + undici-types@7.18.2: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + vite@8.0.8(@types/node@25.5.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.9 + rolldown: 1.0.0-rc.15 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.5.2 + esbuild: 0.27.7 + fsevents: 2.3.3 + jiti: 2.6.1 + tsx: 4.21.0 + yaml: 2.8.3 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.20.0: {} + + y18n@5.0.8: {} + + yaml@2.8.3: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + zod@4.3.6: {} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..3e0d24c --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: {}, +}; diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 0000000..453ee1b --- /dev/null +++ b/server/config.ts @@ -0,0 +1,155 @@ +import fs from "node:fs"; +import { parse as parseYaml } from "yaml"; +import { getEnv } from "./env.js"; +import { logger } from "./logger.js"; + +export interface Replacement { + find: string; + replace: string; +} + +export interface Config { + server: { + port: number; + }; + deepgram: { + model: string; + language: string; + diarize: boolean; + interimResults: boolean; + endpointing: number | false; + utteranceEndMs: number; + smartFormat: boolean; + punctuate: boolean; + keyterms: string[]; + replacements: Replacement[]; + }; + keepAlive: { + intervalMs: number; + idleThresholdMs: number; + }; + taskExtraction: { + model: string; + intervalMs: number; + llmMaxRetries: number; + maxConsecutiveFailures: number; + maxBackoffMs: number; + }; + reconnect: { + maxAttempts: number; + maxBufferBytes: number; + }; + output: { + dir: string; + }; + participants: string[]; +} + +// --- config.yaml 読み込み --- + +const CONFIG_YAML_PATH = "config.yaml"; + +interface MeetingConfig { + participants: string[]; + keyterms: string[]; + replacements: Replacement[]; +} + +const DEFAULT_MEETING_CONFIG: MeetingConfig = { + participants: [], + keyterms: [], + replacements: [], +}; + +function filterStrings(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((v): v is string => typeof v === "string") + : []; +} + +function isReplacement(v: unknown): v is Replacement { + if (typeof v !== "object" || v === null) return false; + const r = v as Record; + return typeof r.find === "string" && typeof r.replace === "string"; +} + +function loadMeetingConfig(): MeetingConfig { + if (!fs.existsSync(CONFIG_YAML_PATH)) { + logger.info("config.yaml が見つかりません。デフォルト設定で起動します"); + return DEFAULT_MEETING_CONFIG; + } + + try { + const raw = fs.readFileSync(CONFIG_YAML_PATH, "utf-8"); + const parsed = parseYaml(raw) as Record; + + const config: MeetingConfig = { + participants: filterStrings(parsed.participants), + keyterms: filterStrings(parsed.keyterms), + replacements: Array.isArray(parsed.replacements) + ? parsed.replacements.filter(isReplacement) + : [], + }; + + logger.info( + `config.yaml: 参加者 ${config.participants.length} 名, キーターム ${config.keyterms.length} 件, 置換ルール ${config.replacements.length} 件`, + ); + + return config; + } catch (e) { + logger.warn( + "config.yaml の読み込みに失敗しました。デフォルト設定で起動します", + e, + ); + return DEFAULT_MEETING_CONFIG; + } +} + +// プロセスライフタイムで1回だけ初期化される設定値(録音セッション状態とは無関係) +let _config: Config | null = null; + +export function getConfig(): Config { + if (!_config) { + const env = getEnv(); + const meeting = loadMeetingConfig(); + _config = { + server: { + port: env.PORT, + }, + deepgram: { + model: "nova-3", + language: "ja", + diarize: false, + interimResults: true, + endpointing: 500, + utteranceEndMs: 1000, + smartFormat: true, + punctuate: true, + keyterms: [ + ...new Set([...meeting.participants, ...meeting.keyterms]), + ], + replacements: meeting.replacements, + }, + keepAlive: { + intervalMs: 3_000, + idleThresholdMs: 5_000, + }, + taskExtraction: { + model: "gemini-3-flash-preview", + intervalMs: 30_000, + llmMaxRetries: 0, + maxConsecutiveFailures: 3, + maxBackoffMs: 180_000, + }, + reconnect: { + maxAttempts: 3, + maxBufferBytes: 2 * 1024 * 1024, + }, + output: { + dir: "output", + }, + participants: meeting.participants, + }; + } + return _config; +} diff --git a/server/constants.ts b/server/constants.ts new file mode 100644 index 0000000..e73973d --- /dev/null +++ b/server/constants.ts @@ -0,0 +1,9 @@ +// preflight.ts と env.ts の両方から参照される定数。 +// 外部ライブラリに依存しないため、preflight.ts を単独実行しても安全。 + +export const REQUIRED_KEYS = [ + "DEEPGRAM_API_KEY", + "GOOGLE_GENERATIVE_AI_API_KEY", +] as const; + +export const DEFAULT_PORT = 3001; diff --git a/server/deepgram-relay.ts b/server/deepgram-relay.ts new file mode 100644 index 0000000..a6ff2c3 --- /dev/null +++ b/server/deepgram-relay.ts @@ -0,0 +1,278 @@ +import { DeepgramClient } from "@deepgram/sdk"; +import type { WebSocket } from "ws"; +import { getConfig, type Config, type Replacement } from "./config.js"; +import { TranscriptWriter } from "./transcript-writer.js"; +import type { ServerMessage } from "./types.js"; +import { logger } from "./logger.js"; + +type V1Socket = Awaited< + ReturnType["listen"]["v1"]["connect"]> +>; + +function formatReplacement(r: Replacement): string { + return `${r.find}:${r.replace}`; +} + +export class DeepgramRelay { + readonly timestamp: string; + readonly llmInputBuffer: string[] = []; + readonly outputPath: string; + + private readonly config: Config; + private readonly writer: TranscriptWriter; + private readonly meetingStartTime: Date; + private readonly connectArgs: Parameters["listen"]["v1"]["connect"]>[0]; + + private connection: V1Socket | null = null; + private keepAliveTimer: ReturnType | null = null; + private lastDataSentAt = Date.now(); + private timestampOffsetMs = 0; + + private reconnecting = false; + private readonly audioBuffer: Buffer[] = []; + private audioBufferBytes = 0; + + constructor( + private readonly apiKey: string, + private readonly browserWs: WebSocket, + ) { + this.config = getConfig(); + this.meetingStartTime = new Date(); + this.timestamp = this.meetingStartTime + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, 19); + this.writer = new TranscriptWriter(this.timestamp); + this.outputPath = this.writer.getOutputPath(); + + this.connectArgs = { + model: this.config.deepgram.model, + language: this.config.deepgram.language, + encoding: "linear16", + sample_rate: "16000", + channels: "1", + ...(this.config.deepgram.diarize ? { diarize: "true" } : {}), + interim_results: String(this.config.deepgram.interimResults), + endpointing: String(this.config.deepgram.endpointing), + utterance_end_ms: String(this.config.deepgram.utteranceEndMs), + smart_format: String(this.config.deepgram.smartFormat), + punctuate: String(this.config.deepgram.punctuate), + Authorization: this.apiKey, + queryParams: this.buildQueryParams(), + }; + } + + async start(participants: string[]): Promise { + await this.writer.init(participants); + this.connection = await this.openConnection(); + this.startKeepAlive(); + } + + sendAudio(chunk: Buffer): void { + if (chunk.byteLength === 0) return; + this.lastDataSentAt = Date.now(); + + if (this.reconnecting) { + this.bufferChunk(chunk); + return; + } + + try { + this.connection?.sendMedia(chunk); + } catch { + logger.warn("音声送信失敗 — 再接続を開始"); + this.bufferChunk(chunk); + void this.reconnect(); + } + } + + async stop(): Promise { + this.sendToClient({ type: "stopping" }); + + try { this.connection?.sendFinalize({ type: "Finalize" }); } catch {} + await this.wait(5000); + try { this.connection?.sendCloseStream({ type: "CloseStream" }); } catch {} + + this.clearKeepAlive(); + await this.writer.flush(); + + try { this.connection?.close(); } catch {} + this.connection = null; + } + + private buildQueryParams(): Record | undefined { + const params: Record = {}; + const { keyterms, replacements } = this.config.deepgram; + + if (keyterms.length > 0) { + params.keyterm = keyterms; + logger.info(`Deepgram keyterms: ${keyterms.join(", ")}`); + } + + if (replacements.length > 0) { + params.replace = replacements.map(formatReplacement); + logger.info( + `Deepgram replace: ${replacements.map(formatReplacement).join(", ")}`, + ); + } + + return Object.keys(params).length > 0 ? params : undefined; + } + + // --- Private: Connection lifecycle --- + + private async openConnection(): Promise { + const client = new DeepgramClient({ apiKey: this.apiKey }); + const conn = await client.listen.v1.connect(this.connectArgs); + + conn.on("open", () => { + logger.info("Deepgram 接続確立"); + if (!this.reconnecting) { + this.sendToClient({ type: "ready", outputPath: this.outputPath }); + } + }); + + conn.on("message", (data) => this.handleTranscriptMessage(data)); + + conn.on("error", (error) => { + logger.error("Deepgram エラー", error); + this.sendToClient({ type: "error", code: "DEEPGRAM_ERROR", message: error.message }); + }); + + conn.on("close", () => { + logger.info("Deepgram 接続終了"); + }); + + conn.connect(); + await conn.waitForOpen(); + return conn; + } + + private handleTranscriptMessage(data: { type: string }): void { + if (data.type !== "Results") return; + + const result = data as unknown as { + is_final?: boolean; + speech_final?: boolean; + start: number; + channel: { alternatives: { transcript: string; words: { speaker?: number }[] }[] }; + }; + + const alt = result.channel.alternatives[0]; + if (!alt) return; + + const { transcript } = alt; + const isFinal = result.is_final ?? false; + const speechFinal = result.speech_final ?? false; + + this.sendToClient({ type: "transcript", text: transcript, isFinal }); + + if (!isFinal || !transcript.trim()) return; + + const ts = this.formatTimestamp(result.start + this.timestampOffsetMs / 1000); + this.writer.appendUtterance(transcript, ts, speechFinal); + this.llmInputBuffer.push(transcript); + } + + // --- Private: Reconnection --- + + private async reconnect(): Promise { + if (this.reconnecting) return; + this.reconnecting = true; + this.sendToClient({ type: "reconnecting" }); + this.clearKeepAlive(); + + const elapsedMs = Date.now() - this.meetingStartTime.getTime(); + + for (let attempt = 1; attempt <= this.config.reconnect.maxAttempts; attempt++) { + const backoff = Math.pow(2, attempt - 1) * 1000; + logger.info(`Deepgram 再接続 試行 ${attempt}/${this.config.reconnect.maxAttempts} (${backoff}ms後)`); + await this.wait(backoff); + + try { + this.connection = await this.openConnection(); + this.timestampOffsetMs = elapsedMs; + this.drainAudioBuffer(); + this.startKeepAlive(); + this.reconnecting = false; + this.sendToClient({ type: "reconnected" }); + logger.info("Deepgram 再接続成功"); + return; + } catch (e) { + logger.error(`再接続試行 ${attempt} 失敗`, e); + } + } + + this.reconnecting = false; + this.sendToClient({ + type: "error", + code: "DEEPGRAM_RECONNECT_FAILED", + message: `Deepgram への再接続に ${this.config.reconnect.maxAttempts} 回失敗しました`, + }); + } + + // --- Private: KeepAlive --- + + private startKeepAlive(): void { + this.clearKeepAlive(); + this.keepAliveTimer = setInterval(() => { + if (Date.now() - this.lastDataSentAt > this.config.keepAlive.idleThresholdMs) { + try { this.connection?.sendKeepAlive({ type: "KeepAlive" }); } catch { + logger.warn("KeepAlive 送信失敗"); + } + } + }, this.config.keepAlive.intervalMs); + } + + private clearKeepAlive(): void { + if (this.keepAliveTimer) { + clearInterval(this.keepAliveTimer); + this.keepAliveTimer = null; + } + } + + // --- Private: Audio buffer (reconnection) --- + + private bufferChunk(chunk: Buffer): void { + while ( + this.audioBufferBytes + chunk.byteLength > this.config.reconnect.maxBufferBytes && + this.audioBuffer.length > 0 + ) { + const dropped = this.audioBuffer.shift()!; + this.audioBufferBytes -= dropped.byteLength; + logger.warn("再接続バッファ上限超過: 古いチャンクを破棄"); + } + this.audioBuffer.push(chunk); + this.audioBufferBytes += chunk.byteLength; + } + + private drainAudioBuffer(): void { + const chunks = this.audioBuffer.splice(0); + this.audioBufferBytes = 0; + for (const chunk of chunks) { + try { this.connection?.sendMedia(chunk); } catch { break; } + } + } + + // --- Private: Utilities --- + + private sendToClient(msg: ServerMessage): void { + if (this.browserWs.readyState === this.browserWs.OPEN) { + this.browserWs.send(JSON.stringify(msg)); + } + } + + private formatTimestamp(startSeconds: number): string { + const d = new Date(this.meetingStartTime.getTime() + startSeconds * 1000); + return d.toLocaleTimeString("ja-JP", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + } + + private wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/server/env.ts b/server/env.ts new file mode 100644 index 0000000..6bc1df8 --- /dev/null +++ b/server/env.ts @@ -0,0 +1,133 @@ +import { config as dotenvConfig } from "dotenv"; +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import { z } from "zod"; +import { REQUIRED_KEYS } from "./constants.js"; +import { logger } from "./logger.js"; + +const envSchema = z.object({ + DEEPGRAM_API_KEY: z.string().min(1), + GOOGLE_GENERATIVE_AI_API_KEY: z.string().min(1), + PORT: z + .string() + .default("3001") + .transform(Number) + .pipe(z.number().int().min(1).max(65535)), +}); + +export type Env = z.infer; + +export class EnvError extends Error { + constructor(message: string) { + super(message); + this.name = "EnvError"; + } +} + +// プロセスライフタイムで1回だけ初期化される設定値(録音セッション状態とは無関係) +let _env: Env | null = null; + +export function getEnv(): Env { + if (!_env) _env = loadEnv(); + return _env; +} + +function loadEnv(): Env { + dotenvConfig(); + + const first = envSchema.safeParse(process.env); + if (first.success) return first.data; + + if (!fs.existsSync(".env") && tryOpInject()) { + dotenvConfig({ override: true }); + const retry = envSchema.safeParse(process.env); + if (retry.success) { + logger.info("1Password CLI で .env を自動生成しました"); + return retry.data; + } + } + + const { fieldErrors } = z.flattenError(first.error); + throw new EnvError(formatDiagnostics(fieldErrors)); +} + +function tryOpInject(): boolean { + try { + execSync("op --version", { stdio: "ignore", timeout: 3_000 }); + } catch { + return false; + } + try { + logger.info("1Password CLI で .env を生成中..."); + execSync("op inject -i .env.example -o .env", { + stdio: "inherit", + timeout: 30_000, + }); + return true; + } catch { + logger.warn("1Password CLI での .env 生成に失敗しました"); + return false; + } +} + +function hasOpCli(): boolean { + try { + execSync("op --version", { stdio: "ignore", timeout: 3_000 }); + return true; + } catch { + return false; + } +} + +function formatDiagnostics( + fieldErrors: Record, +): string { + const issues = Object.keys(fieldErrors) + .map((key) => { + const val = process.env[key]; + const status = + val === undefined ? "未設定" : val === "" ? "空です" : "値が不正です"; + return ` ${key}: ${status}`; + }) + .join("\n"); + + const opAvailable = hasOpCli(); + + const lines = [ + "", + "============================================================", + " 環境変数が設定されていません", + "============================================================", + "", + " 不足している変数:", + issues, + "", + " -- 解決方法 -----------------------------------------------", + "", + ]; + + if (opAvailable) { + lines.push( + " [方法1] 1Password CLI で自動生成(推奨)", + " $ op inject -i .env.example -o .env", + "", + ); + } + + lines.push( + opAvailable + ? " [方法2] 手動で .env ファイルを作成" + : " .env ファイルを手動で作成してください", + "", + ...REQUIRED_KEYS.map((key) => ` ${key}=your-key-here`), + "", + " API キーの取得先:", + " Deepgram : https://console.deepgram.com/", + " Google AI : https://aistudio.google.com/apikey", + "", + "============================================================", + "", + ); + + return lines.join("\n"); +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..aedb6d3 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,195 @@ +import http from "node:http"; +import express from "express"; +import { WebSocketServer, type WebSocket } from "ws"; +import { DeepgramClient } from "@deepgram/sdk"; +import { getEnv, EnvError } from "./env.js"; +import { logger } from "./logger.js"; +import { getConfig } from "./config.js"; +import { DeepgramRelay } from "./deepgram-relay.js"; +import { taskExtractionLoop } from "./task-extractor.js"; +import type { ClientMessage, ServerMessage, TaskStatus } from "./types.js"; + +async function validateDeepgramKey(apiKey: string): Promise { + try { + const client = new DeepgramClient({ apiKey }); + await client.manage.v1.projects.list(); + logger.info("Deepgram: OK"); + } catch (e) { + if (hasHttpStatus(e, 401, 403)) { + logger.error("Deepgram: APIキーが無効です。.env を確認してください"); + process.exit(1); + } + logger.warn("Deepgram: 検証リクエストに失敗しました(起動は続行)", e); + } +} + +async function validateGoogleAiKey(apiKey: string): Promise { + const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}&pageSize=1`; + try { + const res = await fetch(url); + if (res.status === 401 || res.status === 403) { + logger.error("Google AI: APIキーが無効です。.env を確認してください"); + process.exit(1); + } + if (!res.ok) { + logger.warn(`Google AI: 検証リクエストが ${res.status} を返しました(起動は続行)`); + return; + } + logger.info("Google AI: OK"); + } catch (e) { + logger.warn("Google AI: 検証リクエストに失敗しました(起動は続行)", e); + } +} + +function hasHttpStatus(e: unknown, ...codes: number[]): boolean { + if (!(e instanceof Error)) return false; + const record = e as Record; + const status = record["statusCode"] ?? record["status"]; + if (typeof status === "number") return codes.includes(status); + const msg = e.message.toLowerCase(); + return codes.some( + (code) => msg.includes(String(code)) || msg.includes(HTTP_STATUS_NAMES[code] ?? ""), + ); +} + +const HTTP_STATUS_NAMES: Record = { + 401: "unauthorized", + 403: "forbidden", +}; + +function sendToWs(ws: WebSocket, msg: ServerMessage): void { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify(msg)); + } +} + +function tryParseClientMessage(text: string): ClientMessage | null { + try { + const parsed = JSON.parse(text) as Record; + if (parsed.type === "stop") return { type: "stop" }; + return null; + } catch { + return null; + } +} + +async function main() { + const env = getEnv(); + const config = getConfig(); + + const participants = config.participants; + logger.info(`参加者: ${participants.length > 0 ? participants.join(", ") : "(未指定)"}`); + + await validateDeepgramKey(env.DEEPGRAM_API_KEY); + await validateGoogleAiKey(env.GOOGLE_GENERATIVE_AI_API_KEY); + + const app = express(); + const server = http.createServer(app); + const wss = new WebSocketServer({ noServer: true }); + + server.on("upgrade", (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); + + wss.on("connection", (ws: WebSocket) => { + void handleSession(ws, env.DEEPGRAM_API_KEY, participants); + }); + + const port = config.server.port; + server.listen(port, () => { + logger.info(`サーバー起動: http://localhost:${port}`); + }); +} + +async function handleSession( + ws: WebSocket, + apiKey: string, + participants: string[], +): Promise { + logger.info("ブラウザ WebSocket 接続"); + + const config = getConfig(); + const relay = new DeepgramRelay(apiKey, ws); + + let stopping = false; + let lastTaskStatus: TaskStatus = { taskCount: 0, failing: false }; + let taskAbortController: AbortController | null = null; + let taskExtractionPromise: Promise | null = null; + + try { + await relay.start(participants); + } catch (e) { + logger.error("Deepgram リレー起動失敗", e); + sendToWs(ws, { + type: "error", + code: "DEEPGRAM_CONNECT_FAILED", + message: "Deepgram への接続に失敗しました", + }); + ws.close(); + return; + } + + taskAbortController = new AbortController(); + taskExtractionPromise = taskExtractionLoop( + { + participants, + outputDir: config.output.dir, + timestamp: relay.timestamp, + llmInputBuffer: relay.llmInputBuffer, + onStatusChange: (status) => { + lastTaskStatus = status; + sendToWs(ws, { type: "task_status", ...status }); + }, + }, + taskAbortController.signal, + ); + + async function gracefulStop(): Promise { + await relay.stop(); + + sendToWs(ws, { type: "stopped", ...lastTaskStatus }); + ws.close(); + + taskAbortController?.abort(); + if (taskExtractionPromise) { + const result = await taskExtractionPromise.catch((): null => null); + if (result && result.taskCount > 0) { + logger.info(`タスク抽出完了: ${result.taskCount} 件`); + } + } + } + + ws.on("message", (data: Buffer | string, isBinary: boolean) => { + if (isBinary) { + relay.sendAudio(data as Buffer); + return; + } + + const msg = tryParseClientMessage(String(data)); + if (msg?.type === "stop" && !stopping) { + stopping = true; + logger.info("停止要求を受信"); + void gracefulStop(); + } + }); + + ws.on("close", () => { + logger.info("ブラウザ WebSocket 切断"); + if (!stopping) { + taskAbortController?.abort(); + taskExtractionPromise?.catch(() => {}); + void relay.stop().catch(() => {}); + } + }); +} + +main().catch((e) => { + if (e instanceof EnvError) { + console.error(e.message); + } else { + logger.error("起動エラー", e); + } + process.exit(1); +}); diff --git a/server/logger.ts b/server/logger.ts new file mode 100644 index 0000000..db3d286 --- /dev/null +++ b/server/logger.ts @@ -0,0 +1,10 @@ +const timestamp = () => new Date().toISOString(); + +export const logger = { + info: (msg: string, ...args: unknown[]) => + console.log(`[${timestamp()}] INFO ${msg}`, ...args), + warn: (msg: string, ...args: unknown[]) => + console.warn(`[${timestamp()}] WARN ${msg}`, ...args), + error: (msg: string, ...args: unknown[]) => + console.error(`[${timestamp()}] ERROR ${msg}`, ...args), +}; diff --git a/server/preflight.ts b/server/preflight.ts new file mode 100644 index 0000000..ac58553 --- /dev/null +++ b/server/preflight.ts @@ -0,0 +1,114 @@ +import { config as dotenvConfig } from "dotenv"; +import fs from "node:fs"; +import net from "node:net"; +import { REQUIRED_KEYS, DEFAULT_PORT } from "./constants.js"; + +const MIN_NODE_VERSION = 20; + +function fail(label: string, ...lines: string[]): never { + console.error(`\n[check] ${label} ... NG\n`); + for (const line of lines) console.error(line); + console.error(); + process.exit(1); +} + +function checkNodeVersion(): void { + const major = parseInt(process.versions.node.split(".")[0]!, 10); + if (major < MIN_NODE_VERSION) { + fail( + `Node.js v${process.versions.node}`, + ` Node.js v${MIN_NODE_VERSION} 以上が必要です(現在 v${process.versions.node})。`, + " https://nodejs.org/ から最新版をインストールしてください。", + ); + } +} + +function checkEnvFile(): void { + if (!fs.existsSync(".env")) { + fail( + ".env", + " .env ファイルが見つかりません。", + " 以下の手順で作成してください:", + "", + " 1. cp .env.example .env", + ' 2. テキストエディタで .env を開く(例: code .env)', + " 3. API キーを貼り付ける", + "", + " API キーの取得先:", + " Deepgram : https://console.deepgram.com/", + " Google AI : https://aistudio.google.com/apikey", + ); + } +} + +function checkRequiredKeys(): void { + for (const key of REQUIRED_KEYS) { + const val = process.env[key]; + if (!val || val.trim() === "") { + fail( + key, + ` ${key} が設定されていません。`, + " .env ファイルを開いて値を設定してください。", + "", + " API キーの取得先:", + " Deepgram : https://console.deepgram.com/", + " Google AI : https://aistudio.google.com/apikey", + ); + } + } +} + +function resolvePort(): number { + const portStr = process.env["PORT"] ?? String(DEFAULT_PORT); + const port = parseInt(portStr, 10); + if (isNaN(port) || port < 1 || port > 65535) { + fail("PORT", ` PORT の値が不正です: ${portStr}`); + } + return port; +} + +function checkPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + fail( + `ポート ${port}`, + ` ポート ${port} が使用中です。`, + " 前回のサーバーが残っている場合: just stop を実行", + " 他のアプリが使用中の場合: そのアプリを終了してください", + ); + } else if (err.code === "EACCES") { + fail( + `ポート ${port}`, + ` ポート ${port} にアクセス権がありません。`, + " 1024 以上のポート番号を .env の PORT に設定してください。", + ); + } else { + fail(`ポート ${port}`, ` ポートチェックでエラーが発生しました: ${err.message}`); + } + }); + server.listen({ port, host: "0.0.0.0" }, () => { + server.close(() => resolve()); + }); + }); +} + +async function main(): Promise { + checkNodeVersion(); + checkEnvFile(); + dotenvConfig(); + checkRequiredKeys(); + + const port = resolvePort(); + await checkPortAvailable(port); + + console.log( + `[check] 環境チェック完了 (Node.js v${process.versions.node}, .env, APIキー, ポート${port})`, + ); +} + +main().catch((e) => { + console.error("プリフライトチェックで予期しないエラーが発生しました:", e); + process.exit(1); +}); diff --git a/server/schemas.ts b/server/schemas.ts new file mode 100644 index 0000000..217f0de --- /dev/null +++ b/server/schemas.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const ExtractedTaskSchema = z.object({ + summary: z.string().describe("タスクの1行要約"), + assignee: z.string().nullable().describe("担当者名(推定)"), + deadline: z.string().nullable().describe("期限(言及があれば)"), + evidence: z.string().describe("根拠となる発話の引用"), +}); + +export const TaskExtractionResultSchema = z.object({ + tasks: z.array(ExtractedTaskSchema), +}); + +export type ExtractedTask = z.infer; +export type TaskExtractionResult = z.infer; diff --git a/server/task-extractor.ts b/server/task-extractor.ts new file mode 100644 index 0000000..c1f8b04 --- /dev/null +++ b/server/task-extractor.ts @@ -0,0 +1,190 @@ +import { setTimeout as delay } from "node:timers/promises"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { generateObject, APICallError } from "ai"; +import { google } from "@ai-sdk/google"; +import { getConfig } from "./config.js"; +import { TaskExtractionResultSchema, type ExtractedTask } from "./schemas.js"; +import type { TaskStatus } from "./types.js"; +import { logger } from "./logger.js"; + +export interface TaskExtractorOptions { + participants: string[]; + outputDir: string; + timestamp: string; + llmInputBuffer: string[]; + onStatusChange: (status: TaskStatus) => void; +} + +type TaskWithId = ExtractedTask & { id: string }; + +function isQuotaOrRateLimitError(error: unknown): boolean { + if (!APICallError.isInstance(error)) return false; + if (error.statusCode === 429) return true; + if (error.statusCode === 503) { + const body = error.responseBody ?? ""; + return body.includes("RESOURCE_EXHAUSTED") || body.includes("quota"); + } + return false; +} + +function buildPrompt(participants: string[], transcriptText: string): string { + const participantLine = participants.length > 0 + ? `参加者は ${participants.join("、")} です。` + : "参加者リストは未提供です。assignee は常に null にしてください。"; + + const assigneeRule = participants.length > 0 + ? `- assignee は参加者リスト(${participants.join("、")})の中からのみ選ぶ。リストにない名前を担当者にしてはならない +- 会話中で「〇〇さんお願い」「〇〇がやります」のように名前が明示されている場合のみ assignee を設定する +- 名前が明示されていない場合は assignee を null にする(推測しない)` + : "- assignee は常に null にする"; + + return `以下は会議の文字起こしの一部です。${participantLine} +話者の区別はありません(すべての発話が話者ラベルなしで記録されています)。 + +--- +${transcriptText} +--- + +上記の会話から以下を抽出してください。 + +タスク: +- 誰かが何かをやると約束した、または依頼された内容を抽出する +- 「検討します」「考えておきます」などの曖昧な表現もタスクとして抽出する +- 明確な行動(「作る」「送る」「確認する」「レビューする」等)だけでなく、検討・調査系の意思表示も含める +- evidence には、そのタスクの根拠となる発話を原文のまま引用する + +担当者の推定ルール: +${assigneeRule} +- 期限の言及がない場合は deadline を null にする + +エッジケース: +- テキストが2文以下の場合は tasks を空配列で返す +- タスクが見当たらない場合は tasks を空配列で返す(無理に抽出しない)`; +} + +function formatTasksMarkdown(tasks: TaskWithId[]): string { + const rows = tasks.map( + (t) => + `| ${t.id} | ${t.summary} | ${t.assignee ?? "-"} | ${t.deadline ?? "-"} | ${t.evidence} |`, + ); + + return [ + "# 抽出タスク", + "", + "| # | タスク | 担当 | 期限 | 根拠 |", + "|---|--------|------|------|------|", + ...rows, + "", + "---", + `*最終更新: ${new Date().toLocaleTimeString("ja-JP", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false })}*`, + "", + ].join("\n"); +} + +async function abortableSleep(ms: number, signal: AbortSignal): Promise { + try { + await delay(ms, undefined, { signal }); + } catch { + // AbortError on cancellation — expected + } +} + +export async function taskExtractionLoop( + options: TaskExtractorOptions, + signal: AbortSignal, +): Promise { + const { participants, outputDir, timestamp, llmInputBuffer, onStatusChange } = options; + const cfg = getConfig().taskExtraction; + const tasksFilePath = path.join(outputDir, `meeting-${timestamp}-tasks.md`); + const allTasks: TaskWithId[] = []; + let taskCounter = 0; + let consecutiveFailures = 0; + + function computeBackoffMs(): number { + if (consecutiveFailures === 0) return cfg.intervalMs; + return Math.min( + cfg.intervalMs * Math.pow(2, consecutiveFailures - 1), + cfg.maxBackoffMs, + ); + } + + async function extractAndWrite(lines: string[]): Promise { + const transcriptText = lines.join("\n"); + logger.info(`タスク抽出開始 (${lines.length} 行, ${transcriptText.length} 文字)`); + + const { object } = await generateObject({ + model: google(cfg.model), + schema: TaskExtractionResultSchema, + prompt: buildPrompt(participants, transcriptText), + maxRetries: cfg.llmMaxRetries, + abortSignal: signal, + }); + + if (object.tasks.length > 0) { + const newTasks = object.tasks.map((t) => ({ + ...t, + id: String(++taskCounter), + })); + allTasks.push(...newTasks); + await fs.writeFile(tasksFilePath, formatTasksMarkdown(allTasks), "utf-8"); + logger.info(`タスク ${newTasks.length} 件抽出 → ${tasksFilePath}`); + } else { + logger.info("タスクなし(今回のサイクル)"); + } + } + + function currentStatus(overrides?: Partial): TaskStatus { + return { + taskCount: allTasks.length, + failing: consecutiveFailures > 0, + ...overrides, + }; + } + + while (!signal.aborted) { + if (llmInputBuffer.length > 0) { + const snapshot = llmInputBuffer.splice(0); + try { + await extractAndWrite(snapshot); + consecutiveFailures = 0; + onStatusChange(currentStatus()); + } catch (e) { + consecutiveFailures++; + const isQuota = isQuotaOrRateLimitError(e); + + if (isQuota) { + logger.warn( + `タスク抽出失敗: API クォータ超過 (連続 ${consecutiveFailures} 回)。Google AI Studio でプランを確認してください`, + ); + } else { + logger.error(`タスク抽出失敗 (連続 ${consecutiveFailures} 回)`, e); + } + + logger.warn(`失敗した ${snapshot.length} 行を破棄(次サイクルの新規バッファで再試行)`); + + const message = isQuota + ? "タスク抽出が API 制限で停止中" + : "タスク抽出でエラーが発生中"; + onStatusChange(currentStatus({ message })); + } + } + + await abortableSleep(computeBackoffMs(), signal); + } + + if (llmInputBuffer.length > 0 && consecutiveFailures < cfg.maxConsecutiveFailures) { + try { + logger.info("最終 flush: タスク抽出"); + await extractAndWrite(llmInputBuffer.splice(0)); + } catch { + logger.warn("最終 flush 失敗"); + } + } else if (llmInputBuffer.length > 0) { + logger.warn( + `最終 flush スキップ: API が ${consecutiveFailures} 回連続で失敗中のため`, + ); + } + + return currentStatus(); +} diff --git a/server/transcript-writer.ts b/server/transcript-writer.ts new file mode 100644 index 0000000..9b27a95 --- /dev/null +++ b/server/transcript-writer.ts @@ -0,0 +1,72 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { getConfig } from "./config.js"; + +export class TranscriptWriter { + private writeQueue = Promise.resolve(); + private readonly filePath: string; + + constructor(timestamp: string) { + this.filePath = path.join( + getConfig().output.dir, + `meeting-${timestamp}-transcript.md`, + ); + } + + async init(participants: string[]): Promise { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + + const now = new Date(); + const dateStr = now.toLocaleDateString("ja-JP", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const timeStr = now.toLocaleTimeString("ja-JP", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + const header = [ + `# 会議メモ ${dateStr} ${timeStr}`, + "", + "## 参加者", + participants.length > 0 ? participants.join("、") : "(未指定)", + "", + "---", + "", + ].join("\n"); + + await fs.writeFile(this.filePath, header, "utf-8"); + } + + appendUtterance( + text: string, + timestamp: string, + isSpeechFinal: boolean, + ): void { + if (!text.trim()) return; + + let content = `**[${timestamp}]** ${text}\n`; + if (isSpeechFinal) { + content += "\n"; + } + + this.append(content); + } + + private append(text: string): void { + this.writeQueue = this.writeQueue.then(() => + fs.appendFile(this.filePath, text, "utf-8"), + ); + } + + async flush(): Promise { + await this.writeQueue; + } + + getOutputPath(): string { + return this.filePath; + } +} diff --git a/server/types.ts b/server/types.ts new file mode 100644 index 0000000..670a565 --- /dev/null +++ b/server/types.ts @@ -0,0 +1,17 @@ +export interface TaskStatus { + taskCount: number; + failing: boolean; + message?: string; +} + +export type ServerMessage = + | { type: "ready"; outputPath: string } + | { type: "transcript"; text: string; isFinal: boolean } + | { type: "reconnecting" } + | { type: "reconnected" } + | { type: "stopping" } + | { type: "stopped" } & TaskStatus + | { type: "task_status" } & TaskStatus + | { type: "error"; code: string; message: string }; + +export type ClientMessage = { type: "stop" }; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..4554c35 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,9 @@ +import { RecordingPanel } from "./components/recording-panel"; + +export function App() { + return ( +
+ +
+ ); +} diff --git a/src/components/recording-panel.tsx b/src/components/recording-panel.tsx new file mode 100644 index 0000000..bba5efb --- /dev/null +++ b/src/components/recording-panel.tsx @@ -0,0 +1,146 @@ +import { Mic, Square, RotateCcw, CheckCircle, AlertTriangle } from "lucide-react"; +import { useRecorder, type RecorderState, type CompletionNotice } from "../hooks/use-recorder"; +import { Button } from "./ui/button"; +import { Badge } from "./ui/badge"; + +export function RecordingPanel() { + const { state, start, stop, retry, completionNotice } = useRecorder(); + + return ( +
+
+

リアルタイム議事録

+ +
+ +
+ + +
+ + {state.status === "recording" && ( +

+ 出力先: {state.outputPath} +

+ )} + + {state.status === "error" && ( +

+ 録音済みの内容は保存されています +

+ )} + + {completionNotice && state.status === "idle" && ( + + )} +
+ ); +} + +const BADGE_CONFIG: Record = { + idle: { variant: "secondary", label: "待機中" }, + connecting: { variant: "warning", label: "接続中" }, + recording: { variant: "success", label: "録音中" }, + reconnecting: { variant: "warning", label: "再接続中", pulse: true }, + stopping: { variant: "secondary", label: "停止処理中" }, + error: { variant: "destructive", label: "エラー" }, +}; + +function StatusBadge({ status }: { status: RecorderState["status"] }) { + const cfg = BADGE_CONFIG[status]; + return ( + + {cfg.label} + + ); +} + +function ActionButton({ + state, + onStart, + onStop, + onRetry, +}: { + state: RecorderState; + onStart: () => void; + onStop: () => void; + onRetry: () => void; +}) { + switch (state.status) { + case "idle": + return ( + + ); + case "connecting": + case "stopping": + return ( + + ); + case "recording": + case "reconnecting": + return ( + + ); + case "error": + return ( + + ); + } +} + +function SnippetArea({ state }: { state: RecorderState }) { + switch (state.status) { + case "idle": + return null; + case "connecting": + return

接続しています...

; + case "recording": + return ( +
+

+ {state.lastTranscript ?? "音声を待っています..."} +

+ {state.taskFailing && ( +

+ {state.taskMessage ?? "タスク抽出でエラーが発生中"} +

+ )} +
+ ); + case "reconnecting": + return ( +

+ 接続を回復しています。音声は記録されています +

+ ); + case "stopping": + return

停止処理中...

; + case "error": + return

{state.message}

; + } +} + +function CompletionBanner({ notice }: { notice: CompletionNotice }) { + const Icon = notice.warning ? AlertTriangle : CheckCircle; + const colors = notice.warning + ? { border: "border-yellow-200", bg: "bg-yellow-50", icon: "text-yellow-600", text: "text-yellow-800" } + : { border: "border-green-200", bg: "bg-green-50", icon: "text-green-600", text: "text-green-800" }; + + return ( +
+ +

{notice.text}

+
+ ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..fce419e --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,27 @@ +import type { HTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +const badgeVariants = { + default: "bg-primary text-primary-foreground", + secondary: "bg-secondary text-secondary-foreground", + destructive: "bg-destructive/10 text-destructive border-destructive/20", + success: "bg-green-100 text-green-800 border-green-200", + warning: "bg-yellow-100 text-yellow-800 border-yellow-200", +}; + +export interface BadgeProps extends HTMLAttributes { + variant?: keyof typeof badgeVariants; +} + +export function Badge({ className, variant = "default", ...props }: BadgeProps) { + return ( +
+ ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..50ae0e0 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,30 @@ +import { forwardRef, type ButtonHTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +const buttonVariants = { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 border border-destructive/20", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", +}; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: keyof typeof buttonVariants; +} + +export const Button = forwardRef( + ({ className, variant = "default", ...props }, ref) => { + return ( +