From 6da1d3af2dfc41db25277e0bcf7495eba7dfe8bd Mon Sep 17 00:00:00 2001 From: Eotel <49769982+Eotel@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:31:13 +0900 Subject: [PATCH] feat: add room home and caption reset Add home-based registration and reconnection, isolated room captions and AI context, and confirmation for reset and leaving unsaved work. Keep settings across restarts, store private editing links with 0600 permissions, and reject stale tabs and retired utterance replays. Preserve hidden state on reconnect and start only the configured input. Integrate with main at de2898a. Retain the current AI model selection, contextual correction and inspector controls. Apply the cancellation fix submitted in statelyai/xstate#5823 during locked dependency installation. Validation: bash scripts/check.sh passes (141 frontend tests, 194 backend tests, generated API agreement, type checks and build). A clean npm ci applies the dependency patch and passes its StrictMode regression. Real browser tests use a dedicated local API and synthetic captions for registration, reconnect, reset/cancel, stale saves, restart persistence, and desktop/mobile layouts. The UI review was an independent pass in the same conversation because separate sessions are unavailable. Final disposition: ship. Usage docs and screenshots are updated; the completed temporary implementation plan is removed. No live UD Talk, microphone or LLM connection was used for this change. The upstream fix passes its workspace tests and remote checks. Upstream GitHub permissions prevent assigning its PR to Eotel. No implementation work remains on this isolated branch; integration into the active checkout is separate. --- .env.example | 1 + .../surfaces/frontend-src-views-home-tsx.md | 28 + .impeccable/surfaces/operator.md | 2 +- ARCHITECTURE.md | 9 +- PRODUCT.md | 2 +- README.md | 8 +- backend/app/connections.py | 18 +- backend/app/main.py | 174 ++- backend/app/relay.py | 11 + backend/app/room_models.py | 65 + backend/app/room_scope.py | 19 + backend/app/room_store.py | 66 + backend/app/rooms.py | 188 +++ backend/app/workflow.py | 10 + backend/tests/test_connection_lifecycle.py | 19 + backend/tests/test_rooms.py | 216 +++ docs/assets/manual/00-home.jpg | Bin 0 -> 32310 bytes docs/assets/manual/00-reset.jpg | Bin 0 -> 66680 bytes docs/assets/manual/00-room-settings.jpg | Bin 0 -> 57367 bytes docs/current-behavior.md | 17 +- docs/development.md | 11 +- docs/frontend-architecture.md | 6 +- docs/generated/openapi.json | 1260 ++++++++++++++++- docs/index.md | 2 + docs/manual.md | 18 +- docs/rooms.md | 44 + frontend/package-lock.json | 735 +++++++++- frontend/package.json | 6 +- frontend/patches/README.md | 7 + frontend/patches/xstate+5.33.2.patch | 88 ++ frontend/src/adapters/microphone.ts | 8 +- frontend/src/adapters/operator-keyboard.ts | 1 + frontend/src/adapters/rooms.ts | 35 + frontend/src/api.ts | 110 +- .../src/components/ui/confirmation-dialog.tsx | 27 + frontend/src/components/ui/form.tsx | 9 + frontend/src/domain.ts | 31 +- frontend/src/generated/api.d.ts | 583 +++++++- frontend/src/machines/home.ts | 96 ++ frontend/src/machines/room.ts | 38 + frontend/src/main.tsx | 7 +- frontend/src/presenters/home.ts | 35 + frontend/src/presenters/operator.ts | 4 +- frontend/src/presenters/room.ts | 32 + frontend/src/rooms.css | 54 + frontend/src/rooms.test.tsx | 186 +++ frontend/src/roots.test.tsx | 1 + frontend/src/roots.tsx | 63 +- frontend/src/views/home.tsx | 78 + frontend/src/views/operator/toolbar.tsx | 2 +- frontend/src/views/room.tsx | 20 + frontend/vite.config.ts | 6 +- 52 files changed, 4257 insertions(+), 199 deletions(-) create mode 100644 .impeccable/surfaces/frontend-src-views-home-tsx.md create mode 100644 backend/app/room_models.py create mode 100644 backend/app/room_scope.py create mode 100644 backend/app/room_store.py create mode 100644 backend/app/rooms.py create mode 100644 backend/tests/test_rooms.py create mode 100644 docs/assets/manual/00-home.jpg create mode 100644 docs/assets/manual/00-reset.jpg create mode 100644 docs/assets/manual/00-room-settings.jpg create mode 100644 docs/rooms.md create mode 100644 frontend/patches/README.md create mode 100644 frontend/patches/xstate+5.33.2.patch create mode 100644 frontend/src/adapters/rooms.ts create mode 100644 frontend/src/components/ui/confirmation-dialog.tsx create mode 100644 frontend/src/components/ui/form.tsx create mode 100644 frontend/src/machines/home.ts create mode 100644 frontend/src/machines/room.ts create mode 100644 frontend/src/presenters/home.ts create mode 100644 frontend/src/presenters/room.ts create mode 100644 frontend/src/rooms.css create mode 100644 frontend/src/rooms.test.tsx create mode 100644 frontend/src/views/home.tsx create mode 100644 frontend/src/views/room.tsx diff --git a/.env.example b/.env.example index 0f06620..c81fd39 100644 --- a/.env.example +++ b/.env.example @@ -10,4 +10,5 @@ CAPTION_RELAY_STT_USAGE_PATH= CAPTION_RELAY_AI_TIMEOUT_SECONDS=15 # Optional absolute path; empty/unset uses the user's data directory. CAPTION_RELAY_DICTIONARY_PATH= +CAPTION_RELAY_ROOMS_PATH= CAPTION_RELAY_UDTALK_URL= diff --git a/.impeccable/surfaces/frontend-src-views-home-tsx.md b/.impeccable/surfaces/frontend-src-views-home-tsx.md new file mode 100644 index 0000000..2230117 --- /dev/null +++ b/.impeccable/surfaces/frontend-src-views-home-tsx.md @@ -0,0 +1,28 @@ +--- +version: 1 +slug: "frontend-src-views-home-tsx" +primary_target: "frontend/src/views/home.tsx" +related_targets: ["frontend/src/views/room.tsx"] +--- + +# ホームとルームの操作 + +Mode: Operate + +## Direction contract + +THESIS: 運用者が以前使ったルームへ戻り、新しいルームを登録し、次のトークのために字幕だけを消せるようにする。 + +OWN-WORLD: 既存の白い作業面、灰色の細い区切り、黒い主操作、青いフォーカス、Lucideの線画を引き継ぐ。余白は8・16・24・32pxを基準にし、ホームの本文は16px、行間24pxとする。削除を確定するボタンは白文字と暗い赤で区別する。 + +STORY: ホームで名前と最終利用日時からルームを選ぶ。追加時は名前・配信先・必要な参加リンクを入れる。接続後は字幕の確認と訂正に集中し、リセットと離脱は対象と下書きの有無を確かめる。 + +FIRST VIEWPORT: 上部にCaption Relayと新しいルームへの接続。下は区切り線のある一列のルーム一覧。各行の左に名前と接続方法・最終利用日時、右に接続設定と接続を置く。狭い画面では操作を次の行の右へ回す。ルーム画面は既存の操作画面の上に名前、ホーム、リセットを置く。 + +FORM: 既存のOperate画面の拡張。新しい世界や構成案の選択は行わず、受け入れ済みのホーム・ルーム操作を既存の部品と配色で実装する。 + +FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance + +## Constraints + +同時に入力するのは一つのルーム。字幕と背景を混ぜない。参加リンクは履歴のAPI応答へ返さない。リセットは確認後にCaption Relayの字幕と下書きだけを消し、UDトークの字幕とルーム設定を残す。Rootは一つとし、Viewは通知、Mediatorは判断、Adapterは通信を担当する。 diff --git a/.impeccable/surfaces/operator.md b/.impeccable/surfaces/operator.md index d5d0c7f..4e3f327 100644 --- a/.impeccable/surfaces/operator.md +++ b/.impeccable/surfaces/operator.md @@ -18,6 +18,6 @@ FINISH: unreviewed and undocumented is unfinished; this build ends with the fini ## Constraints -人の修正は明示的に保存する。外部AIへの接続は後の段階に行い、今回は選択・振り分けと結果の受け取りまで。画面はRoot配下のPassive Viewとし、操作をChain経由で状態機械のMediatorへ渡す。認証・永続化・会場実機の試験は現行PoCの対象外。 +人の修正は明示的に保存する。外部AIへの接続は後の段階に行い、今回は選択・振り分けと結果の受け取りまで。画面はRoot配下のPassive Viewとし、操作をChain経由で状態機械のMediatorへ渡す。認証・字幕の永続化・会場実機の試験は現行PoCの対象外。ルームの設定は保存する。 INTERACTION: 右ペインの境界は24pxの操作領域でドラッグでき、右端で収納する。幅と未保存入力を保持して再表示し、キーボードでも操作できる。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3723d55..cf2bc68 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # システム構成 -実装確認日:2026-10-11 +実装確認日:2026-10-25 Caption Relay は、単一の FastAPI プロセスが字幕の状態を持つローカル PoC。React の操作画面は HTTP で取得・更新し、観客画面は SSE で公開字幕を受け取る。現在の振る舞いは[現行 PoC の仕様](docs/current-behavior.md)、起動方法は[開発手順](docs/development.md)を参照する。 @@ -9,6 +9,7 @@ Caption Relay は、単一の FastAPI プロセスが字幕の状態を持つロ | 場所 | 責務 | |---|---| | [backend/app/main.py](backend/app/main.py) | HTTP ルート、エラー応答、SSE、ビルド済み画面の配信 | +| [backend/app/rooms.py](backend/app/rooms.py)・[room_store.py](backend/app/room_store.py) | 一つの入力接続とルームごとのWorkflow、設定の保存・復元、リセットと古い操作の拒否 | | [backend/app/relay.py](backend/app/relay.py) | セッション状態、入力の改版・再送判定、手修正、公開可否、購読キュー | | [backend/app/workflow.py](backend/app/workflow.py) | 入力、AI処理の開始・取消、訂正先への送信、保存時の改版照合 | | [backend/app/connections.py](backend/app/connections.py) | 接続モードと入力の開始・終了、接続変更と保存の直列化 | @@ -33,6 +34,8 @@ Caption Relay は、単一の FastAPI プロセスが字幕の状態を持つロ ## データの流れ +ホームは `Rooms` の接続履歴を読み、ルームごとの `CaptionWorkflow` を選ぶ。同時に入力するのは一つのルームだけで、切り替え時に前の入力とAI要求を終了する。字幕は各Relayのメモリ、名前・接続先・背景・AI・表示・公開設定は `RoomStore` のファイルに保持する。単語辞書は全ルームで共有する。保存範囲と利用手順は[ルームの接続と字幕リセット](docs/rooms.md)を参照する。 + ```mermaid flowchart LR ud[UDトーク] -->|Zoom形式のPOST| api @@ -56,7 +59,7 @@ flowchart LR FastAPIはPydanticモデルで入出力を検証し、入力と訂正保存を `CaptionWorkflow` へ渡す。状態遷移と公開可否は `CaptionRelay`、外部接続の寿命と送信先は `Connections` が所有する。AI要求はバックグラウンドで実行し、元の発話の更新とモード変更で終了する。保存と接続変更は同時に実行しない。訂正先への送信前後に原文・表示文の改版を照合し、古い原文で新しい発話を上書きしない。存在しない字幕と改版競合はAPI層で404・409に変換する。 -`create_app()`ごとに独立した字幕状態を作る。字幕状態を共有するデータベースはなく、再起動で消えるため、複数ワーカーで運用しない。字幕・接続・AI設定・測定結果をこのプロセスに保持する。各AI提供元のキーは環境変数で読み、画面へ返さない。通常起動では単語辞書とマイク認識の試験使用量を再起動をまたぐファイルに保存する。テスト用の `create_app()` は辞書を注入しない限りメモリ上に保持する。詳細は[単語辞書](docs/dictionary.md)を参照する。 +`create_app()`ごとに独立したルーム管理を作る。字幕状態を共有するデータベースはなく、再起動で消えるため、複数ワーカーで運用しない。字幕とAIの測定結果はこのプロセスに保持する。各AI提供元のキーは環境変数で読み、画面へ返さない。通常起動ではルームの設定、単語辞書、マイク認識の試験使用量をファイルに保存する。テスト用の `create_app()` は保存先を注入しない限りメモリ上に保持する。詳細は[ルーム](docs/rooms.md)と[単語辞書](docs/dictionary.md)を参照する。 接続の初期値は独立配信。Zoom形式の送信先はプロセス内で生成し、再起動と接続変更で更新する。従来のWeb入力Adapterは `CAPTION_RELAY_UDTALK_URL` を設定した場合に開始する。編集参加へ切り替えると両方の入力を止め、有効な編集用URLの参加を開始する。入力の変換は各Adapter、公開と手修正の判断はRelayが所有する。経路・根拠・制約は[UDトークの入力接続](docs/udtalk-input.md)を参照する。 @@ -68,6 +71,8 @@ FastAPIはPydanticモデルで入出力を検証し、入力と訂正保存を ` ## API と公開データの境界 +操作APIは `X-Caption-Relay-Room` と `X-Caption-Relay-Generation` で対象とリセットの世代を固定する。書き込みは現在接続中のルームだけに許可し、古い世代には409を返す。観客APIの `room_id` とSSEは取得開始時のRelayを保持し、ルーム切り替えで別の字幕へ切り替えない。リセットは保存・外部送信と直列化し、AI要求の取消完了後に字幕を消す。字幕の改版番号を巻き戻さず、リセット済みの発話IDを再送でも採用しない。 + Zoom受信先の資格情報は操作画面用の接続状態だけに返す。編集用URLは適用時だけ送信し、応答・字幕ID・ログへ含めない。AIキーは提供元の認証ヘッダーで送り、画面には設定済みかどうかだけを返す。アクセスログはURLの資格情報を記録しないよう起動スクリプトで無効にする。アプリ全体の認証はなく、loopbackでのローカル利用を前提とする。 バックエンドは FastAPI と Pydantic v2 を使用する。入力・応答と内部の状態をモデルで型付けし、`backend/pyproject.toml` の pyright 厳格モードを通す。基底の `Model` は余分な項目と暗黙の型変換を拒否し、代入も検証する。 diff --git a/PRODUCT.md b/PRODUCT.md index e24f631..9d71115 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -42,7 +42,7 @@ web - 「字幕を隠す」は観客への表示を止める操作。入力と編集は継続する。「字幕を表示」では新しい発話から再開し、非表示期間の発話を自動公開しない。 - 観客には公開を許可した字幕だけを送り、原文や管理情報を送らない。観客画面は初期設定で公開字幕を残しながら末尾へスクロールする。過去の字幕を読み返し、「最新へ」で追従を再開できる。背景・文字・書体・サイズ・行間を調整でき、直近の発話だけの表示にも切り替えられる。 - Gemini・Claude・OpenAIのAPIによる校正と、費用・実測速度に基づく振り分けを設定できる。AIは初期値オフで、キーを設定した提供元へ次の確定字幕から依頼する。GPTの実生成と、Apple認識からGPT-5.4 Miniによる自動校正・編集・配信を合成音声で確認した。[AIの確認記録](docs/ai-routing.md#実接続での確認)を参照する。各社の品質・速度比較と請求確定額は未確認。 -- 認証と字幕の永続化は未実装。字幕と画面の設定は再起動で消え、単語辞書は保存先に残る。アプリはローカルの単一プロセスで使う。独立配信はUDトークのZoom連携から受信する。編集参加は試験接続として提供し、純正への手動訂正・AI訂正の反映を合成音声で確認した。[実接続の範囲](docs/udtalk-input.md#復旧後の実接続確認)を参照する。 +- 認証と字幕の永続化は未実装。字幕は再起動で消え、ルームの接続履歴・設定と単語辞書は保存先に残る。アプリはローカルの単一プロセスで使う。独立配信はUDトークのZoom連携から受信する。編集参加は試験接続として提供し、純正への手動訂正・AI訂正の反映を合成音声で確認した。[実接続の範囲](docs/udtalk-input.md#復旧後の実接続確認)を参照する。 振る舞いの詳細、テストとの対応、その他の未実装事項は [現行 PoC の仕様](docs/current-behavior.md)を参照する。将来要件を含む [v0.2 の設計案](docs/design-v0.2.md)を、現行機能の説明に使わない。 diff --git a/README.md b/README.md index c5a01b2..93bd2a7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ UDトークまたはマイク認識の字幕を、AIと手動で訂正して観 UDトークがない場合は、独立配信の入力元を「マイク」にし、OpenAIまたは接続先MacのApple認識を選べます。準備と開始・停止は[マイク入力](docs/microphone-input.md)を参照してください。両方とも合成音声で実認識を確認しています。物理マイクと会場での認識は未確認です。 -AIは初期値オフです。サーバーにGemini・Claude・OpenAIのAPIキーを設定すると、次の確定字幕から校正を実行します。APIキー未設定時と失敗時は原文を保持します。認証と字幕の永続化はなく、ローカルの単一プロセスで使用します。再起動すると字幕と画面の設定は消えます。単語辞書は保存先に残ります。 +AIは初期値オフです。サーバーにGemini・Claude・OpenAIのAPIキーを設定すると、次の確定字幕から校正を実行します。APIキー未設定時と失敗時は原文を保持します。認証と字幕の永続化はなく、ローカルの単一プロセスで使用します。字幕は再起動で消えます。ルームの接続履歴・設定と単語辞書は保存先に残ります。 ## 起動 @@ -23,13 +23,15 @@ bash scripts/dev.sh | 画面 | URL | |---|---| -| 操作画面 | | -| 観客画面 | | +| ホーム | | +| 観客画面 | 接続後の操作画面のモニターアイコンから開きます | `setup.sh` は依存関係を導入し、画面をビルドします。終了時はサーバーを Ctrl+C で止めます。フロントエンドの変更を即時確認する手順は[開発手順](docs/development.md)、Codex Cloud と SSH 接続は[リモート開発](docs/remote-development.md)に記載しています。 ## 試す +ホームからルームへ接続します。初回は「独立配信」を選ぶか、「新しいルームに接続」で名前と接続先を登録します。ホームへの帰還と字幕リセットはルーム画面の上部にあります。詳しくは[ルームの接続と字幕リセット](docs/rooms.md)を参照してください。 + 1. 接続アイコンから「字幕入力の接続設定」を開き、「独立配信」の送信先をUDトークのZoom連携に設定します。実機を使わない画面確認は「準備」の「テスト字幕を送る」で行えます。 2. 表示文をクリックして訂正欄を開き、修正して「保存して公開」を押します。未保存の下書きは観客へ出ません。Ctrl / Command + Enterでも保存できます。 3. 「新着の公開」で自動公開・確認して公開を選びます。「字幕を隠す」は表示を止め、「字幕を表示」は新しい発話から再開します。 diff --git a/backend/app/connections.py b/backend/app/connections.py index b719b1a..983f960 100644 --- a/backend/app/connections.py +++ b/backend/app/connections.py @@ -37,14 +37,20 @@ def __init__(self, accept: Callable[[SourceEvent, bool], Caption | SourceIgnored def start(self) -> None: self._running = True - self.web.start() + if self.mode == "host" and self.input_source == "udtalk": + self.web.start() async def stop(self) -> None: - await self.microphone.stop() - await self.web.stop() - if self.editor is not None: - await self.editor.stop() - self._running = False + async with self._changes: + await self.microphone.stop() + await self.web.stop() + if self.editor is not None: + await self.editor.stop() + self._running = False + + async def reset_captions(self, reset: Callable[[], Awaitable[None]]) -> None: + async with self._changes: + await reset() def status(self) -> ConnectionStatus: if self.editor is not None: diff --git a/backend/app/main.py b/backend/app/main.py index ef32d66..182f08b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,10 +4,11 @@ from datetime import UTC, datetime from pathlib import Path from collections.abc import Mapping +from typing import Annotated -from fastapi import FastAPI, HTTPException, Query, Request, WebSocket +from fastapi import Depends, FastAPI, HTTPException, Query, Request, WebSocket from urllib.parse import urlsplit -from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse +from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, StreamingResponse from fastapi.staticfiles import StaticFiles import httpx2 as httpx @@ -20,6 +21,10 @@ from .udtalk_web import UdtalkStatus from .udtalk_editor import EditorError from .workflow import CaptionWorkflow +from .rooms import RoomConflict, RoomNotFound, Rooms +from .room_models import RoomCatalog, RoomDetails, RoomDraft, RoomReset +from .room_scope import RoomScope, room_scope +from .room_store import RoomStorageError, RoomStore, rooms_path from .zoom_input import ReceiverError from .transcription.openai import Connector, openai_connect @@ -31,21 +36,37 @@ def create_app(relay: CaptionRelay | None = None, *, udtalk_url: str | None = No environment: Mapping[str, str] | None = None, ai_transport: httpx.AsyncBaseTransport | None = None, editor_transport: httpx.AsyncBaseTransport | None = None, audio_connector: Connector = openai_connect, - dictionary: WordDictionary | None = None) -> FastAPI: + dictionary: WordDictionary | None = None, room_store: RoomStore | None = None) -> FastAPI: service = relay if relay is not None else CaptionRelay() workflow = CaptionWorkflow(service, udtalk_url, environment if environment is not None else os.environ, ai_transport, editor_transport, audio_connector, dictionary) + def new_workflow() -> CaptionWorkflow: + return CaptionWorkflow(CaptionRelay(), None, environment if environment is not None else os.environ, + ai_transport, editor_transport, audio_connector, workflow.dictionary) + rooms = Rooms(workflow, new_workflow, room_store if room_store is not None else RoomStore()) @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncGenerator[None]: - workflow.start() + rooms.start() try: yield finally: - await workflow.stop() + await rooms.stop() application = FastAPI(title="Caption Relay", version="0.2.0", lifespan=lifespan) + @application.exception_handler(RoomNotFound) + async def room_not_found(_: Request, error: RoomNotFound) -> JSONResponse: + return JSONResponse({"detail": str(error)}, status_code=404) + + @application.exception_handler(RoomConflict) + async def room_conflict(_: Request, error: RoomConflict) -> JSONResponse: + return JSONResponse({"detail": str(error)}, status_code=409) + + @application.exception_handler(RoomStorageError) + async def room_storage_error(_: Request, error: RoomStorageError) -> JSONResponse: + return JSONResponse({"detail": str(error)}, status_code=503) + @application.exception_handler(EditorError) async def editor_error(_: Request, error: EditorError) -> PlainTextResponse: return PlainTextResponse(str(error), status_code=502) @@ -56,60 +77,109 @@ async def editor_error(_: Request, error: EditorError) -> PlainTextResponse: async def index() -> FileResponse: return FileResponse(FRONTEND / "index.html") + @application.get("/rooms/{room_id}", response_class=FileResponse) + async def room_index(room_id: str) -> FileResponse: + return FileResponse(FRONTEND / "index.html") + + @application.get("/api/rooms", response_model=RoomCatalog) + async def room_catalog() -> RoomCatalog: + return rooms.catalog() + + @application.post("/api/rooms", response_model=RoomDetails, status_code=201) + async def add_room(draft: RoomDraft) -> RoomDetails: + try: + return await rooms.save_room(draft) + except EditorError as error: + raise HTTPException(422, str(error)) from None + + @application.get("/api/rooms/{room_id}", response_model=RoomDetails) + async def room_details(room_id: str) -> RoomDetails: + return rooms.details(room_id) + + @application.put("/api/rooms/{room_id}", response_model=RoomDetails) + async def update_room(room_id: str, draft: RoomDraft) -> RoomDetails: + try: + return await rooms.save_room(draft, room_id) + except EditorError as error: + raise HTTPException(422, str(error)) from None + + @application.post("/api/rooms/{room_id}/connect", response_model=RoomDetails) + async def connect_room(room_id: str) -> RoomDetails: + try: + return await rooms.connect(room_id) + except EditorError as error: + raise HTTPException(422, str(error)) from None + + @application.post("/api/rooms/{room_id}/leave", response_model=RoomCatalog) + async def leave_room(room_id: str, operation: RoomReset) -> RoomCatalog: + return await rooms.leave(room_id, operation.generation) + + @application.post("/api/rooms/{room_id}/reset", response_model=RoomDetails) + async def reset_room(room_id: str, operation: RoomReset) -> RoomDetails: + return await rooms.reset(room_id, operation.generation) + @application.get("/view/demo", response_class=FileResponse) async def view() -> FileResponse: return FileResponse(FRONTEND / "view.html") @application.get("/api/session", response_model=Session) - async def session() -> Session: - return service.session() + async def session(scope: Annotated[RoomScope, Depends(room_scope)]) -> Session: + return rooms.workflow(scope.room_id, scope.generation).relay.session() @application.get("/api/events", response_model=list[Caption]) - async def events() -> list[Caption]: - return service.captions() + async def events(scope: Annotated[RoomScope, Depends(room_scope)]) -> list[Caption]: + return rooms.workflow(scope.room_id, scope.generation).relay.captions() @application.post("/api/source/events", response_model=Caption | SourceIgnored) - async def source_event(event: SourceEvent) -> Caption | SourceIgnored: - if workflow.connections.input_source != "udtalk": + async def source_event(event: SourceEvent, scope: Annotated[RoomScope, Depends(room_scope)]) -> Caption | SourceIgnored: + target = rooms.workflow(scope.room_id, scope.generation, active=True) + if target.connections.input_source != "udtalk": raise HTTPException(409, "マイク以外の入力は停止しています。") - return workflow.source_event(event) + return target.source_event(event) @application.get("/api/source/udtalk", response_model=UdtalkStatus) - async def udtalk_status() -> UdtalkStatus: - return workflow.connections.web.status.model_copy(deep=True) + async def udtalk_status(scope: Annotated[RoomScope, Depends(room_scope)]) -> UdtalkStatus: + return rooms.workflow(scope.room_id, scope.generation).connections.web.status.model_copy(deep=True) @application.get("/api/connection", response_model=ConnectionStatus) - async def connection() -> ConnectionStatus: - return workflow.connections.status() + async def connection(scope: Annotated[RoomScope, Depends(room_scope)]) -> ConnectionStatus: + return rooms.workflow(scope.room_id, scope.generation).connections.status() @application.put("/api/connection", response_model=ConnectionStatus) - async def set_connection(settings: ConnectionSettings) -> ConnectionStatus: + async def set_connection(settings: ConnectionSettings, scope: Annotated[RoomScope, Depends(room_scope)]) -> ConnectionStatus: try: - return await workflow.configure(settings) + return await rooms.configure(scope.room_id, scope.generation, settings) except EditorError as error: raise HTTPException(422, str(error)) from None @application.websocket("/api/source/audio") - async def microphone_audio(socket: WebSocket) -> None: + async def microphone_audio(socket: WebSocket, room_id: str | None = None, generation: int | None = None) -> None: + try: + target = rooms.workflow(room_id, generation, active=True) + except (RoomConflict, RoomNotFound): + await socket.close(code=1008) + return origin = socket.headers.get("origin") parsed = urlsplit(origin or "") if (parsed.scheme not in {"http", "https"} or parsed.netloc != socket.headers.get("host") - or workflow.connections.mode != "host" or workflow.connections.input_source != "microphone"): + or target.connections.mode != "host" or target.connections.input_source != "microphone"): await socket.close(code=1008) return await socket.accept() - await workflow.connections.microphone.serve(socket, workflow.connections.recognizer) + await target.connections.microphone.serve(socket, target.connections.recognizer) - def authorize_receiver(signature: str) -> None: - if not workflow.connections.receiver.authorized(signature): + def authorize_receiver(signature: str) -> CaptionWorkflow: + target = rooms.workflow(active=True) + if not target.connections.receiver.authorized(signature): raise HTTPException(403, "invalid receiver token") - if workflow.connections.mode != "host" or workflow.connections.input_source != "udtalk": + if target.connections.mode != "host" or target.connections.input_source != "udtalk": raise HTTPException(409, "receiver is inactive") + return target @application.get("/closedcaption/seq", response_class=PlainTextResponse) async def receiver_sequence(signature: str = "") -> str: - authorize_receiver(signature) - return str(workflow.connections.receiver.sequence) + target = authorize_receiver(signature) + return str(target.connections.receiver.sequence) @application.post("/closedcaption", response_class=PlainTextResponse) async def receive_caption(request: Request, seq: int = Query(ge=1), uttrId: str = Query(min_length=1, max_length=256), @@ -124,16 +194,16 @@ async def receive_caption(request: Request, seq: int = Query(ge=1), uttrId: str raise HTTPException(413, "caption too large") try: # Reading a streamed body can span a connection change. - authorize_receiver(signature) - workflow.connections.receive(seq, uttrId, lang, body.decode("utf-8")) + target = authorize_receiver(signature) + target.connections.receive(seq, uttrId, lang, body.decode("utf-8")) except (UnicodeDecodeError, ReceiverError) as error: raise HTTPException(400, "invalid caption packet") from error return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "") @application.patch("/api/captions/{caption_id}", response_model=Caption, responses={409: {"model": ConflictResponse}}) - async def patch_caption(caption_id: str, patch: CaptionPatch) -> Caption: + async def patch_caption(caption_id: str, patch: CaptionPatch, scope: Annotated[RoomScope, Depends(room_scope)]) -> Caption: try: - return await workflow.patch_caption(caption_id, patch) + return await rooms.patch(scope.room_id, scope.generation, caption_id, patch) except CaptionNotFound as error: raise HTTPException(404, "caption not found") from error except RevisionConflict as error: @@ -141,58 +211,60 @@ async def patch_caption(caption_id: str, patch: CaptionPatch) -> Caption: raise HTTPException(409, detail=detail.model_dump(mode="json")) from error @application.put("/api/captions/{caption_id}/formatted", response_model=Caption | SourceIgnored) - async def formatted_result(caption_id: str, result: FormattedResult) -> Caption | SourceIgnored: + async def formatted_result(caption_id: str, result: FormattedResult, scope: Annotated[RoomScope, Depends(room_scope)]) -> Caption | SourceIgnored: try: - return await workflow.formatted_result(caption_id, result) + return await rooms.formatted(scope.room_id, scope.generation, caption_id, result) except CaptionNotFound as error: raise HTTPException(404, "caption not found") from error @application.post("/api/commands", response_model=Visibility) - async def command(command: Command) -> Visibility: - return service.command(command) + async def command(command: Command, scope: Annotated[RoomScope, Depends(room_scope)]) -> Visibility: + return rooms.command(scope.room_id, scope.generation, command) @application.put("/api/publication-mode", response_model=PublicationMode) - async def publication_mode(mode: PublicationMode) -> PublicationMode: - return service.publication_mode(mode) + async def publication_mode(mode: PublicationMode, scope: Annotated[RoomScope, Depends(room_scope)]) -> PublicationMode: + return rooms.publication_mode(scope.room_id, scope.generation, mode) @application.put("/api/display", response_model=DisplaySettings) - async def display_settings(settings: DisplaySettings) -> DisplaySettings: - return service.display_settings(settings) + async def display_settings(settings: DisplaySettings, scope: Annotated[RoomScope, Depends(room_scope)]) -> DisplaySettings: + return rooms.set_display(scope.room_id, scope.generation, settings) @application.get("/api/ai", response_model=AiRouting) - async def ai_routing() -> AiRouting: - return service.ai_routing() + async def ai_routing(scope: Annotated[RoomScope, Depends(room_scope)]) -> AiRouting: + return rooms.workflow(scope.room_id, scope.generation).relay.ai_routing() @application.put("/api/ai", response_model=AiRouting) - async def ai_settings(settings: AiSettings) -> AiRouting: - return service.ai_settings(settings) + async def ai_settings(settings: AiSettings, scope: Annotated[RoomScope, Depends(room_scope)]) -> AiRouting: + return rooms.set_ai(scope.room_id, scope.generation, settings) @application.get("/api/dictionary", response_model=DictionarySnapshot) - async def get_dictionary() -> DictionarySnapshot: - return workflow.dictionary.snapshot() + async def get_dictionary(scope: Annotated[RoomScope, Depends(room_scope)]) -> DictionarySnapshot: + return rooms.workflow(scope.room_id, scope.generation).dictionary.snapshot() @application.put("/api/dictionary", response_model=DictionarySnapshot, responses={409: {"model": DictionaryError}, 503: {"model": DictionaryError}}) - async def set_dictionary(update: DictionaryUpdate) -> DictionarySnapshot: + async def set_dictionary(update: DictionaryUpdate, scope: Annotated[RoomScope, Depends(room_scope)]) -> DictionarySnapshot: try: - return workflow.dictionary.update(update) + return rooms.workflow(scope.room_id, scope.generation, active=True).dictionary.update(update) except DictionaryConflict as error: raise HTTPException(409, str(error)) from None except DictionaryStorageError as error: raise HTTPException(503, str(error)) from None @application.get("/api/view", response_model=PublicSnapshot) - async def public_view() -> PublicSnapshot: - return service.public_snapshot() + async def public_view(room_id: str | None = None) -> PublicSnapshot: + return rooms.workflow(room_id).relay.public_snapshot() @application.get("/api/view/events", response_class=StreamingResponse) - async def public_events() -> StreamingResponse: + async def public_events(room_id: str | None = None) -> StreamingResponse: + target = rooms.workflow(room_id) async def stream() -> AsyncIterator[str]: - async for snapshot in service.snapshots(): + async for snapshot in target.relay.snapshots(): yield f"event: snapshot\ndata: {snapshot.model_dump_json()}\n\n" return StreamingResponse(stream(), media_type="text/event-stream") return application -app = create_app(udtalk_url=os.environ.get("CAPTION_RELAY_UDTALK_URL"), dictionary=WordDictionary(dictionary_path(os.environ))) +app = create_app(udtalk_url=os.environ.get("CAPTION_RELAY_UDTALK_URL"), dictionary=WordDictionary(dictionary_path(os.environ)), + room_store=RoomStore(rooms_path(os.environ))) diff --git a/backend/app/relay.py b/backend/app/relay.py index 9aa8dbb..d0ecce7 100644 --- a/backend/app/relay.py +++ b/backend/app/relay.py @@ -28,6 +28,7 @@ def __init__(self) -> None: self._caption_positions: dict[str, int] = {} self._published_ids: list[str] = [] self._seen_events: set[str] = set() + self._retired_utterances: set[str] = set() self._subscribers: set[asyncio.Queue[PublicSnapshot]] = set() self._visible = True self._revision = 0 @@ -48,6 +49,14 @@ def session(self) -> Session: def captions(self) -> list[Caption]: return [caption.model_copy(deep=True) for caption in self._captions.values()] + def reset_captions(self) -> None: + self._retired_utterances.update(self._captions) + self._captions.clear() + self._caption_positions.clear() + self._published_ids.clear() + self._revision += 1 + self.connection_mode(self._connection_mode) + def public_snapshot(self) -> PublicSnapshot: if self._connection_mode == "client": return PublicSnapshot(visible=False, captions=[], display=self._display.model_copy(deep=True)) @@ -110,6 +119,8 @@ def _publish(self) -> None: queue.put_nowait(snapshot.model_copy(deep=True)) def source_event(self, event: SourceEvent, *, remote_edit: bool = False) -> Caption | SourceIgnored: + if event.utterance_id in self._retired_utterances: + return SourceIgnored(status="stale") if event.source_event_id in self._seen_events: return SourceIgnored(status="duplicate") self._seen_events.add(event.source_event_id) diff --git a/backend/app/room_models.py b/backend/app/room_models.py new file mode 100644 index 0000000..244c9f1 --- /dev/null +++ b/backend/app/room_models.py @@ -0,0 +1,65 @@ +"""Saved room settings and the credential-free home API.""" + +from datetime import datetime + +from pydantic import Field, field_validator, model_validator + +from .models import AiSettings, ConnectionSettings, DisplaySettings, InputSource, Model, RecognizerId + + +class RoomDraft(Model): + name: str = Field(min_length=1, max_length=120) + connection: ConnectionSettings = Field(default_factory=ConnectionSettings) + ai: AiSettings = Field(default_factory=AiSettings) + + @field_validator("name") + @classmethod + def nonblank_name(cls, value: str) -> str: + if not value.strip(): + raise ValueError("room name must not be blank") + return value.strip() + + +class SavedRoom(RoomDraft): + id: str = Field(pattern=r"^[a-z0-9-]{1,64}$") + last_used_at: datetime | None = None + generation: int = Field(default=0, ge=0) + display: DisplaySettings = Field(default_factory=DisplaySettings) + auto_publish: bool = True + visible: bool = True + + +class RoomFile(Model): + rooms: list[SavedRoom] = Field(max_length=100) + + @model_validator(mode="after") + def unique_rooms(self) -> "RoomFile": + if len({room.id for room in self.rooms}) != len(self.rooms): + raise ValueError("room identifiers must be unique") + return self + + +class RoomSummary(Model): + id: str + name: str + last_used_at: datetime | None + generation: int + caption_count: int + mode: str + input_source: InputSource + recognizer: RecognizerId + background: str + + +class RoomDetails(RoomSummary): + connection: ConnectionSettings + ai: AiSettings + + +class RoomCatalog(Model): + active_room_id: str | None + rooms: list[RoomSummary] + + +class RoomReset(Model): + generation: int = Field(ge=0) diff --git a/backend/app/room_scope.py b/backend/app/room_scope.py new file mode 100644 index 0000000..860b856 --- /dev/null +++ b/backend/app/room_scope.py @@ -0,0 +1,19 @@ +"""Bind an operator request to a room and a caption generation.""" + +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Header + + +@dataclass(frozen=True) +class RoomScope: + room_id: str | None + generation: int | None + + +def room_scope( + room_id: Annotated[str | None, Header(alias="X-Caption-Relay-Room", min_length=1, max_length=64)] = None, + generation: Annotated[int | None, Header(alias="X-Caption-Relay-Generation", ge=0)] = None, +) -> RoomScope: + return RoomScope(room_id, generation) diff --git a/backend/app/room_store.py b/backend/app/room_store.py new file mode 100644 index 0000000..6c2611b --- /dev/null +++ b/backend/app/room_store.py @@ -0,0 +1,66 @@ +"""Persist connection bookmarks privately; captions and API keys are never stored.""" + +import os +import tempfile +from collections.abc import Mapping +from pathlib import Path + +from .room_models import RoomFile, SavedRoom + + +class RoomStorageError(Exception): + pass + + +def rooms_path(environment: Mapping[str, str]) -> Path: + configured = environment.get("CAPTION_RELAY_ROOMS_PATH", "").strip() + base = environment.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share") + return Path(configured).expanduser() if configured else Path(base).expanduser() / "caption-relay" / "rooms.json" + + +class RoomStore: + def __init__(self, path: Path | None = None) -> None: + self._path = path + self.restored = path is not None and path.exists() + self._rooms: dict[str, SavedRoom] = {"default": SavedRoom(id="default", name="独立配信")} + if path is not None and path.exists(): + try: + if path.stat().st_size > 4194304: + raise ValueError("room file too large") + data = RoomFile.model_validate_json(path.read_bytes()) + self._rooms = {room.id: room for room in data.rooms} + self._rooms.setdefault("default", SavedRoom(id="default", name="独立配信")) + except (OSError, ValueError): + raise RoomStorageError("ルームの接続履歴を読み込めません。保存ファイルを確認してください。") from None + + def all(self) -> list[SavedRoom]: + return [room.model_copy(deep=True) for room in self._rooms.values()] + + def get(self, room_id: str) -> SavedRoom: + return self._rooms[room_id].model_copy(deep=True) + + def put(self, room: SavedRoom) -> None: + records = {**self._rooms, room.id: room.model_copy(deep=True)} + try: + data = RoomFile(rooms=list(records.values())) + except ValueError: + raise RoomStorageError("登録できるルームは100件までです。") from None + if self._path is not None: + temporary: str | None = None + try: + self._path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=self._path.parent, delete=False) as output: + temporary = output.name + output.write(data.model_dump_json(indent=2)) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, self._path) + except OSError: + raise RoomStorageError("ルームの接続履歴を保存できません。保存先と空き容量を確認してください。") from None + finally: + if temporary is not None: + try: + Path(temporary).unlink(missing_ok=True) + except OSError: + pass + self._rooms = records diff --git a/backend/app/rooms.py b/backend/app/rooms.py new file mode 100644 index 0000000..45b2808 --- /dev/null +++ b/backend/app/rooms.py @@ -0,0 +1,188 @@ +"""One active connection, with isolated room state and saved connection bookmarks.""" + +import asyncio +from collections.abc import Callable +from datetime import UTC, datetime +from uuid import uuid4 + +from .models import ( + AiRouting, AiSettings, Caption, CaptionPatch, Command, ConnectionSettings, ConnectionStatus, + DisplaySettings, FormattedResult, PublicationMode, SourceIgnored, Visibility, +) +from .room_models import RoomCatalog, RoomDetails, RoomDraft, RoomSummary, SavedRoom +from .room_store import RoomStore +from .udtalk_editor import EditorError, decode_editor_url +from .workflow import CaptionWorkflow + + +class RoomNotFound(Exception): + pass + + +class RoomConflict(Exception): + pass + + +def validate_connection(settings: ConnectionSettings) -> None: + if settings.mode == "client": + if settings.input_source != "udtalk" or not settings.editor_url: + raise EditorError("UDトークの編集用の参加リンクを入力してください。") + decode_editor_url(settings.editor_url) + elif settings.editor_url: + raise EditorError("独立配信では編集用リンクを使いません。") + + +class Rooms: + def __init__(self, initial: CaptionWorkflow, factory: Callable[[], CaptionWorkflow], store: RoomStore) -> None: + self.store = store + self._factory = factory + self._workflows = {"default": initial} + self.active_room_id: str | None = None if store.restored else "default" + self._configured: dict[str, ConnectionSettings] = {} + self._changes = asyncio.Lock() + + def start(self) -> None: + if self.active_room_id is not None: + self._workflows["default"].start() + + async def stop(self) -> None: + async with self._changes: + self.active_room_id = None + for workflow in self._workflows.values(): + await workflow.stop() + + def _record(self, room_id: str) -> SavedRoom: + try: + return self.store.get(room_id) + except KeyError: + raise RoomNotFound("ルームが見つかりません。ホームから選び直してください。") from None + + def workflow(self, room_id: str | None = None, generation: int | None = None, *, active: bool = False) -> CaptionWorkflow: + identifier = room_id or self.active_room_id or "default" + room = self._record(identifier) + if generation is not None and generation != room.generation: + raise RoomConflict("字幕がリセットされました。画面を読み直してください。") + if active and self.active_room_id != identifier: + raise RoomConflict("このルームの接続は終了しました。ホームから接続してください。") + if identifier not in self._workflows: + workflow = self._factory() + workflow.relay.ai_settings(room.ai) + workflow.relay.display_settings(room.display) + workflow.relay.publication_mode(PublicationMode(auto_publish=room.auto_publish)) + if not room.visible: + workflow.relay.command(Command(command="hide")) + self._workflows[identifier] = workflow + return self._workflows[identifier] + + def details(self, room_id: str) -> RoomDetails: + room = self._record(room_id) + workflow = self._workflows.get(room_id) + return RoomDetails(id=room.id, name=room.name, generation=room.generation, + last_used_at=room.last_used_at, caption_count=workflow.relay.session().count if workflow else 0, + mode=room.connection.mode, input_source=room.connection.input_source, recognizer=room.connection.recognizer, + background=room.ai.background, ai=room.ai, + connection=room.connection.model_copy(update={"editor_url": None})) + + def catalog(self) -> RoomCatalog: + records = sorted(self.store.all(), key=lambda room: (room.last_used_at or datetime.min.replace(tzinfo=UTC), room.id), reverse=True) + return RoomCatalog(active_room_id=self.active_room_id, + rooms=[RoomSummary.model_validate(self.details(room.id).model_dump(exclude={"ai", "connection"})) for room in records]) + + async def save_room(self, draft: RoomDraft, room_id: str | None = None) -> RoomDetails: + async with self._changes: + existing = self._record(room_id) if room_id is not None else None + settings = draft.connection.model_copy(deep=True) + if existing is not None and settings.mode == "client" and settings.editor_url is None and existing.connection.mode == "client": + settings.editor_url = existing.connection.editor_url + validate_connection(settings) + room = existing or SavedRoom(id=str(uuid4()), name=draft.name) + room = room.model_copy(update={"name": draft.name, "connection": settings, "ai": draft.ai.model_copy(deep=True)}) + self.store.put(room) + if room.id in self._workflows: + self._workflows[room.id].relay.ai_settings(room.ai) + return self.details(room.id) + + async def connect(self, room_id: str) -> RoomDetails: + async with self._changes: + room = self._record(room_id) + validate_connection(room.connection) + room.last_used_at = datetime.now(UTC) + self.store.put(room) + if self.active_room_id != room_id or self._configured.get(room_id) != room.connection: + previous = self._workflows.get(self.active_room_id or "") + self.active_room_id = None + if previous is not None: + await previous.stop() + target = self.workflow(room_id) + try: + target.relay.ai_settings(room.ai) + target.relay.display_settings(room.display) + target.relay.publication_mode(PublicationMode(auto_publish=room.auto_publish)) + target.relay.command(Command(command="show" if room.visible else "hide")) + await target.configure(room.connection) + target.start() + except BaseException: + await target.stop() + raise + self.active_room_id = room_id + self._configured[room_id] = room.connection.model_copy(deep=True) + return self.details(room_id) + + async def leave(self, room_id: str, generation: int) -> RoomCatalog: + async with self._changes: + target = self.workflow(room_id, generation, active=True) + self.active_room_id = None + await target.stop() + return self.catalog() + + async def reset(self, room_id: str, generation: int) -> RoomDetails: + async with self._changes: + target = self.workflow(room_id, generation, active=True) + room = self._record(room_id) + room.generation += 1 + self.store.put(room) + await target.reset_captions() + return self.details(room_id) + + async def configure(self, room_id: str | None, generation: int | None, settings: ConnectionSettings) -> ConnectionStatus: + async with self._changes: + target = self.workflow(room_id, generation, active=True) + validate_connection(settings) + room = self._record(room_id or self.active_room_id or "default") + self.store.put(room.model_copy(update={"connection": settings.model_copy(deep=True)})) + result = await target.configure(settings) + self._configured[room.id] = settings.model_copy(deep=True) + return result + + def set_ai(self, room_id: str | None, generation: int | None, settings: AiSettings) -> AiRouting: + target = self.workflow(room_id, generation, active=True) + room = self._record(room_id or self.active_room_id or "default") + self.store.put(room.model_copy(update={"ai": settings.model_copy(deep=True)})) + return target.relay.ai_settings(settings) + + def set_display(self, room_id: str | None, generation: int | None, settings: DisplaySettings) -> DisplaySettings: + target = self.workflow(room_id, generation, active=True) + room = self._record(room_id or self.active_room_id or "default") + self.store.put(room.model_copy(update={"display": settings.model_copy(deep=True)})) + return target.relay.display_settings(settings) + + def command(self, room_id: str | None, generation: int | None, command: Command) -> Visibility: + target = self.workflow(room_id, generation, active=True) + room = self._record(room_id or self.active_room_id or "default") + self.store.put(room.model_copy(update={"visible": command.command == "show"})) + return target.relay.command(command) + + def publication_mode(self, room_id: str | None, generation: int | None, mode: PublicationMode) -> PublicationMode: + target = self.workflow(room_id, generation, active=True) + room = self._record(room_id or self.active_room_id or "default") + self.store.put(room.model_copy(update={"auto_publish": mode.auto_publish})) + return target.relay.publication_mode(mode) + + async def patch(self, room_id: str | None, generation: int | None, caption_id: str, patch: CaptionPatch) -> Caption: + async with self._changes: + return await self.workflow(room_id, generation, active=True).patch_caption(caption_id, patch) + + async def formatted(self, room_id: str | None, generation: int | None, caption_id: str, + result: FormattedResult) -> Caption | SourceIgnored: + async with self._changes: + return await self.workflow(room_id, generation, active=True).formatted_result(caption_id, result) diff --git a/backend/app/workflow.py b/backend/app/workflow.py index 20e9bcb..174cfd1 100644 --- a/backend/app/workflow.py +++ b/backend/app/workflow.py @@ -41,6 +41,16 @@ async def stop(self) -> None: finally: self._ai_paused -= 1 + async def reset_captions(self) -> None: + self._ai_paused += 1 + try: + async def reset() -> None: + await self._cancel_ai() + self.relay.reset_captions() + await self.connections.reset_captions(reset) + finally: + self._ai_paused -= 1 + async def _cancel_ai(self) -> None: self._generation += 1 self._ai_paused += 1 diff --git a/backend/tests/test_connection_lifecycle.py b/backend/tests/test_connection_lifecycle.py index acaee7c..5058a7d 100644 --- a/backend/tests/test_connection_lifecycle.py +++ b/backend/tests/test_connection_lifecycle.py @@ -56,6 +56,25 @@ async def verify(): asyncio.run(verify()) +@pytest.mark.parametrize("mode", ["client", "microphone"]) +def test_starting_a_saved_connection_does_not_restart_the_inactive_web_input(mode): + async def verify(): + server = NativeServer() + workflow = CaptionWorkflow(CaptionRelay(), URL, {}, editor_transport=httpx.MockTransport(server.handle)) + settings = (ConnectionSettings(mode="client", editor_url=editor_url()) if mode == "client" + else ConnectionSettings(input_source="microphone")) + await workflow.configure(settings) + viewer = ViewerServer() + workflow.connections.web = UdtalkWebInput(URL, workflow.source_event, httpx.MockTransport(viewer.handle)) + try: + workflow.start() + assert workflow.connections.web.status.state == "disabled" + assert viewer.polls == [] + finally: + await workflow.stop() + asyncio.run(verify()) + + @pytest.mark.parametrize("shutdown", [False, True]) def test_transition_does_not_schedule_ai_for_input_received_while_cancelling(shutdown): async def verify(): diff --git a/backend/tests/test_rooms.py b/backend/tests/test_rooms.py new file mode 100644 index 0000000..73f8f2d --- /dev/null +++ b/backend/tests/test_rooms.py @@ -0,0 +1,216 @@ +import asyncio +import json +import stat + +import httpx2 as httpx +import pytest +from fastapi.testclient import TestClient + +from app.main import create_app +from app.models import AiSettings, ConnectionSettings, SourceEvent +from app.relay import CaptionRelay +from app.room_store import RoomStorageError, RoomStore +from app.rooms import Rooms +from app.workflow import CaptionWorkflow +from test_udtalk_editor import NativeServer, editor_url, speech, wait_until + + +def create_room(client, name, background=""): + response = client.post("/api/rooms", json={"name": name, "ai": {"background": background}}) + response.raise_for_status() + return response.json() + + +def scope(room): + return {"X-Caption-Relay-Room": room["id"], "X-Caption-Relay-Generation": str(room["generation"])} + + +def receive(client, room, text, identifier="same"): + response = client.post("/api/source/events", headers=scope(room), json={ + "utterance_id": identifier, "revision": 1, "text": text, "source_event_id": identifier + "-1", + }) + response.raise_for_status() + return response.json() + + +def test_switching_rooms_preserves_each_caption_and_background_but_rejects_old_tab_writes(): + with TestClient(create_app(environment={})) as client: + first = create_room(client, "作家へのインタビュー", "写真と布の展示") + second = create_room(client, "別のトーク", "街の記録") + client.post(f"/api/rooms/{first['id']}/connect").raise_for_status() + old = receive(client, first, "最初のルームの字幕") + first_receiver = client.get("/api/connection", headers=scope(first)).json()["receiver_path"] + client.post(f"/api/rooms/{second['id']}/connect").raise_for_status() + receive(client, second, "別ルームの字幕") + assert client.get("/api/events", headers=scope(first)).json()[0]["text"] == "最初のルームの字幕" + assert client.get("/api/events", headers=scope(second)).json()[0]["text"] == "別ルームの字幕" + assert client.get("/api/ai", headers=scope(second)).json()["settings"]["background"] == "街の記録" + assert client.patch(f"/api/captions/{old['id']}", headers=scope(first), json={ + "text": "間違ったルームへの保存", "base_revision": old["revision"], + }).status_code == 409 + assert client.post(first_receiver + "&seq=1&uttrId=old", headers={"content-type": "text/plain"}, content="遅れた入力").status_code == 403 + assert client.post(f"/api/rooms/{first['id']}/reset", json={"generation": first["generation"]}).status_code == 409 + client.post(f"/api/rooms/{first['id']}/connect").raise_for_status() + assert client.get("/api/ai", headers=scope(first)).json()["settings"]["background"] == "写真と布の展示" + assert client.get("/api/view", params={"room_id": second["id"]}).json()["captions"][0]["text"] == "別ルームの字幕" + assert client.get("/api/rooms").json()["rooms"][0]["id"] == first["id"] + + +def test_room_bookmarks_settings_and_private_links_survive_restart_without_saving_captions(tmp_path): + path = tmp_path / "rooms.json" + server = NativeServer() + url = editor_url() + with TestClient(create_app(environment={}, room_store=RoomStore(path), editor_transport=httpx.MockTransport(server.handle))) as client: + room = create_room(client, "保存するトーク", "会場と作品の背景") + client.post(f"/api/rooms/{room['id']}/connect").raise_for_status() + receive(client, room, "保存しない字幕") + client.put("/api/display", headers=scope(room), json={"font_size": 72}).raise_for_status() + client.put("/api/connection", headers=scope(room), json={"mode": "client", "editor_url": url}).raise_for_status() + assert url not in client.get("/api/rooms").text + assert url not in client.get(f"/api/rooms/{room['id']}").text + assert "会場と作品" not in client.get("/api/view", params={"room_id": room["id"]}).text + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert url in path.read_text() + assert "保存しない字幕" not in path.read_text() + with TestClient(create_app(environment={}, room_store=RoomStore(path), editor_transport=httpx.MockTransport(server.handle))) as client: + assert client.get("/api/rooms").json()["active_room_id"] is None + assert client.get(f"/api/rooms/{room['id']}").json()["background"] == "会場と作品の背景" + client.post(f"/api/rooms/{room['id']}/connect").raise_for_status() + assert client.get("/api/events", headers=scope(room)).json() == [] + assert client.get("/api/session", headers=scope(room)).json()["display"]["font_size"] == 72 + assert any(route == "/push/signTalk/" for route, _ in server.requests) + + +def test_reset_clears_only_current_captions_rejects_replays_and_keeps_settings(): + relay = CaptionRelay() + with TestClient(create_app(relay, environment={})) as client: + room = client.get("/api/rooms/default").json() + before = receive(client, room, "リセット前") + client.put("/api/ai", headers=scope(room), json={"background": "残す背景"}).raise_for_status() + client.put("/api/display", headers=scope(room), json={"font_size": 64}).raise_for_status() + result = client.post("/api/rooms/default/reset", json={"generation": 0}) + result.raise_for_status() + after = result.json() + assert after["caption_count"] == 0 and after["generation"] == 1 + assert after["background"] == "残す背景" + assert client.get("/api/events", headers=scope(room)).status_code == 409 + assert client.get("/api/view", params={"room_id": "default"}).json()["captions"] == [] + assert client.get("/api/session", headers=scope(after)).json()["display"]["font_size"] == 64 + assert receive(client, after, "古い発話の再送")["status"] == "stale" + new = receive(client, after, "新しい発話", "new") + assert new["revision"] > before["revision"] + assert client.get("/api/events", headers=scope(after)).json()[0]["text"] == "新しい発話" + assert relay.ai_context(new["id"]).recent_utterances == [] + assert relay.ai_context(new["id"]).background == "残す背景" + assert client.post("/api/rooms/default/reset", json={"generation": 0}).status_code == 409 + + +def test_reset_cancels_inflight_ai_before_clearing(): + async def verify(): + started = asyncio.Event() + cancelled = asyncio.Event() + async def ai_response(_): + started.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + workflow = CaptionWorkflow(CaptionRelay(), None, {"OPENAI_API_KEY": "test-key"}, httpx.MockTransport(ai_response)) + rooms = Rooms(workflow, lambda: CaptionWorkflow(CaptionRelay(), None, {}), RoomStore()) + workflow.relay.ai_settings(AiSettings(strategy="manual")) + workflow.source_event(SourceEvent(utterance_id="old", revision=1, text="原文", source_event_id="one")) + await asyncio.wait_for(started.wait(), 1) + result = await rooms.reset("default", 0) + assert cancelled.is_set() and workflow._tasks == {} + assert result.caption_count == 0 and workflow.relay.captions() == [] + assert workflow.source_event(SourceEvent(utterance_id="old", revision=2, text="遅延入力", source_event_id="two")).status == "stale" + await rooms.stop() + asyncio.run(verify()) + + +def test_reset_does_not_edit_native_history_and_keeps_receiving_new_utterances(): + async def verify(): + server = NativeServer() + workflow = CaptionWorkflow(CaptionRelay(), None, {}, editor_transport=httpx.MockTransport(server.handle)) + rooms = Rooms(workflow, lambda: CaptionWorkflow(CaptionRelay(), None, {}), RoomStore()) + rooms.start() + try: + await rooms.configure(None, None, ConnectionSettings(mode="client", editor_url=editor_url())) + await wait_until(lambda: workflow.connections.status().state == "live") + server.history.append(speech("リセット前の字幕")) + await wait_until(lambda: len(workflow.relay.captions()) == 1) + before = list(server.history) + await rooms.reset("default", 0) + assert server.history == before + assert not any(route == "/push/editMessage/" for route, _ in server.requests) + server.history.append(speech("リセット後の字幕", root="new")) + await wait_until(lambda: len(workflow.relay.captions()) == 1) + assert workflow.relay.captions()[0].text == "リセット後の字幕" + finally: + await rooms.stop() + asyncio.run(verify()) + + +def test_storage_failure_preserves_captions_and_reset_generation(tmp_path, monkeypatch): + path = tmp_path / "rooms.json" + with TestClient(create_app(environment={}, room_store=RoomStore(path))) as client: + room = create_room(client, "保存失敗の確認") + client.post(f"/api/rooms/{room['id']}/connect").raise_for_status() + receive(client, room, "失敗しても残す字幕") + before = path.read_bytes() + def fail_replace(*_): + raise OSError("storage unavailable") + monkeypatch.setattr("app.room_store.os.replace", fail_replace) + assert client.post(f"/api/rooms/{room['id']}/reset", json={"generation": 0}).status_code == 503 + assert client.get(f"/api/rooms/{room['id']}").json()["generation"] == 0 + assert client.get("/api/events", headers=scope(room)).json()[0]["text"] == "失敗しても残す字幕" + assert path.read_bytes() == before + + +def test_leave_disconnects_current_room_and_stale_leave_cannot_disconnect_another(): + with TestClient(create_app(environment={})) as client: + first = create_room(client, "一つ目") + second = create_room(client, "二つ目") + client.post(f"/api/rooms/{first['id']}/connect").raise_for_status() + client.post(f"/api/rooms/{second['id']}/connect").raise_for_status() + assert client.post(f"/api/rooms/{first['id']}/leave", json={"generation": 0}).status_code == 409 + assert client.get("/api/rooms").json()["active_room_id"] == second["id"] + assert client.post(f"/api/rooms/{second['id']}/leave", json={"generation": 0}).json()["active_room_id"] is None + assert client.post("/api/source/events", headers=scope(second), json={ + "utterance_id": "after-leave", "revision": 1, "text": "終了後の入力", "source_event_id": "after-leave", + }).status_code == 409 + + +def test_invalid_or_read_only_bookmarks_do_not_disconnect_the_current_room(): + with TestClient(create_app(environment={})) as client: + before = client.get("/api/connection").json() + for url in ("https://example.invalid/", editor_url(editOnly="0", readOnly="1")): + result = client.post("/api/rooms", json={"name": "無効なリンク", "connection": {"mode": "client", "editor_url": url}}) + assert result.status_code == 422 + assert client.get("/api/connection").json() == before + assert client.post("/api/rooms", json={"name": " "}).status_code == 422 + assert client.get("/api/rooms/missing").status_code == 404 + + +def test_corrupt_saved_rooms_are_not_overwritten(tmp_path): + path = tmp_path / "rooms.json" + path.write_text('{"rooms": "broken"}') + with pytest.raises(RoomStorageError): + RoomStore(path) + assert json.loads(path.read_text()) == {"rooms": "broken"} + + +@pytest.mark.parametrize("use_default", [True, False]) +def test_reconnecting_after_restart_restores_hidden_state_for_builtin_and_saved_rooms(tmp_path, use_default): + path = tmp_path / "rooms.json" + with TestClient(create_app(environment={}, room_store=RoomStore(path))) as client: + room = client.get("/api/rooms/default").json() if use_default else create_room(client, "非表示で保存") + client.post(f"/api/rooms/{room['id']}/connect").raise_for_status() + client.post("/api/commands", headers=scope(room), json={"command": "hide"}).raise_for_status() + with TestClient(create_app(environment={}, room_store=RoomStore(path))) as client: + client.post(f"/api/rooms/{room['id']}/connect").raise_for_status() + assert client.get("/api/session", headers=scope(room)).json()["visible"] is False + receive(client, room, "再起動後の非公開字幕") + public = client.get("/api/view", params={"room_id": room["id"]}).json() + assert public["visible"] is False + assert public["captions"] == [] diff --git a/docs/assets/manual/00-home.jpg b/docs/assets/manual/00-home.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ae0d5ddf430d82fa9dfa28a5a427d6316e7520a3 GIT binary patch literal 32310 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 WO2A6MO2A6MO2A6se^0>uwdU_ByMzk> literal 0 HcmV?d00001 diff --git a/docs/assets/manual/00-reset.jpg b/docs/assets/manual/00-reset.jpg new file mode 100644 index 0000000000000000000000000000000000000000..18f8206d3d0607b802321e55543178efc26495c7 GIT binary patch literal 66680 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
  • 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、観客画面は 。Viteが `/api` のHTTP・音声WebSocketと `/closedcaption` をバックエンドへ転送するため、接続設定の送信先も利用できる。終了時は各サーバーをCtrl+Cで止める。 +ホームは 。接続後の操作画面と、そこから開く観客画面もViteで表示する。Viteが `/api` のHTTP・音声WebSocketと `/closedcaption` をバックエンドへ転送するため、接続設定の送信先も利用できる。終了時は各サーバーをCtrl+Cで止める。 ## 環境変数 @@ -48,6 +48,7 @@ npm --prefix frontend run dev | `CAPTION_RELAY_STT_JPY_PER_USD` | 音声認識の試験費用の円換算。税・地域処理を含む | 未設定 | | `CAPTION_RELAY_STT_USAGE_PATH` | 音声認識の累積使用量台帳 | 利用者のキャッシュ配下 | | `CAPTION_RELAY_AI_TIMEOUT_SECONDS` | AI要求全体の制限時間。0より大きく120以下 | `15` | +| `CAPTION_RELAY_ROOMS_PATH` | [ルームの接続履歴・設定](rooms.md)の保存先。編集用の参加リンクを含む | `$XDG_DATA_HOME/caption-relay/rooms.json`。未設定なら `~/.local/share/caption-relay/rooms.json` | | `CAPTION_RELAY_DICTIONARY_PATH` | [単語辞書](dictionary.md)の保存先ファイル | `$XDG_DATA_HOME/caption-relay/dictionary.json`。未設定なら `~/.local/share/caption-relay/dictionary.json` | バックエンドのポートを変更するときは、`CAPTION_RELAY_PORT` と Vite の `CAPTION_RELAY_API_URL` を合わせて設定する。値の記入例は [.env.example](../.env.example) を参照する。 @@ -64,10 +65,12 @@ bash scripts/dev.sh 入力待ちでキーを貼り付け、Enterを押す。キーをコマンド履歴に書かない。ClaudeとOpenAIも対応する変数で同様に設定する。操作画面のAI設定はキーの有無を表示するが、キー本体を返さない。AIは初期値オフで、画面で設定を保存した次の確定字幕から呼び出す。 -UDトークのZoom送信先は画面の接続設定から取得する。編集用の参加リンクもこの画面で適用し、サーバーのメモリだけに保持する。再起動と配信先の変更で受信先の資格情報が変わるため、UDトーク側も設定し直す。「送られる字幕」だけを変更した場合は送信先と受信番号を保つ。送信先と参加リンクをログ・スクリーンショット・PRに含めない。`dev.sh`はURLの資格情報を記録しないようHTTPアクセスログを無効にする。 +UDトークのZoom送信先は画面の接続設定から取得する。編集用の参加リンクはホームまたはこの画面で適用し、ルームの設定ファイルへ0600で保存する。API応答には含めない。再起動と配信先の変更で受信先の資格情報が変わるため、UDトーク側も設定し直す。「送られる字幕」だけを変更した場合は送信先と受信番号を保つ。送信先と参加リンクをログ・スクリーンショット・PRに含めない。`dev.sh`はURLの資格情報を記録しないようHTTPアクセスログを無効にする。 ## 検証 +依存関係の導入時に、XStateのPromise取消の修正パッチを `postinstall` で適用する。上流PRと更新時の確認事項は[パッチの説明](../frontend/patches/README.md)を参照する。適用エラーを無視して起動しない。 + ```bash bash scripts/check.sh ``` diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md index c648090..8e93e0f 100644 --- a/docs/frontend-architecture.md +++ b/docs/frontend-architecture.md @@ -1,6 +1,6 @@ # フロントエンドの構成 -実装確認日:2026-10-11 +実装確認日:2026-10-25 すべてのアプリケーションコンポーネントを Root 配下に置き、Root、Passive View、Presenter、Mediator、Adapter に責務を分ける。現在の操作画面と観客画面は TypeScript、React、XState、shadcn/ui を使用する。システム全体の責務とバックエンドは[システム構成](../ARCHITECTURE.md)、画面の振る舞いは[現行 PoC の仕様](current-behavior.md)を参照する。 @@ -73,6 +73,10 @@ Mediatorは字幕IDの索引を保持し、取得・保存・競合結果とと ## 状態遷移 +ホームの `HomeRoot` は `machines/home.ts` を使い、一覧取得、登録・設定変更、接続を順に行う。`RoomRoot` は `machines/room.ts` で接続と確認付きリセット・離脱を裁定する。字幕画面がある間は `OperatorRoot` がRootを一つ所有し、ルームと字幕の各Mediatorへ `snapshot.can(event)` で通知する。Rootを入れ子にしない。 + +`adapters/rooms.ts` はルームAPIとページ遷移を扱う。操作APIと音声WebSocketにはルームとリセットの世代を付け、観客リンクには `room_id` を付ける。リセットが成功したら新しい世代で操作画面を作り直して下書きを捨てる。ホームへ戻る際は、操作画面と離脱確認の購読を解放した後でページを移る。確認ダイアログ内のEscapeは、背後の字幕編集へ通知しない。 + `src/machines/operator.ts`が取得、保存、表示切り替え、公開方法、表示設定、AI設定、接続設定、テスト入力を裁定する。編集行と下書き、最新位置への追従もこのMediatorが保持する。設定パネルは一つの選択状態で管理し、同時に一つだけ開く。閉じても未保存の設定を保持する。接続設定の参加リンクは適用成功時にブラウザの入力状態から消す。 説明欄の開閉は Mediator の集合で管理する。欄ごとに独立し、定期取得とパネルの切り替えでも保持する。再読み込み時は初期状態に戻す。字幕ごとの説明は字幕IDと結び付け、存在しない字幕への操作を拒否する。Presenter は該当する欄の開閉が変わった場合にだけ、その字幕行のパラメータを作り直す。 diff --git a/docs/generated/openapi.json b/docs/generated/openapi.json index 3a3afc5..38e1d7e 100644 --- a/docs/generated/openapi.json +++ b/docs/generated/openapi.json @@ -16,6 +16,315 @@ } } }, + "/rooms/{room_id}": { + "get": { + "summary": "Room Index", + "operationId": "room_index_rooms__room_id__get", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Room Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/rooms": { + "get": { + "summary": "Room Catalog", + "operationId": "room_catalog_api_rooms_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomCatalog" + } + } + } + } + } + }, + "post": { + "summary": "Add Room", + "operationId": "add_room_api_rooms_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomDraft" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomDetails" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/rooms/{room_id}": { + "get": { + "summary": "Room Details", + "operationId": "room_details_api_rooms__room_id__get", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Room Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomDetails" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "summary": "Update Room", + "operationId": "update_room_api_rooms__room_id__put", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Room Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomDraft" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomDetails" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/rooms/{room_id}/connect": { + "post": { + "summary": "Connect Room", + "operationId": "connect_room_api_rooms__room_id__connect_post", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Room Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomDetails" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/rooms/{room_id}/leave": { + "post": { + "summary": "Leave Room", + "operationId": "leave_room_api_rooms__room_id__leave_post", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Room Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomReset" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomCatalog" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/rooms/{room_id}/reset": { + "post": { + "summary": "Reset Room", + "operationId": "reset_room_api_rooms__room_id__reset_post", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Room Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomReset" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoomDetails" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/view/demo": { "get": { "summary": "View", @@ -31,6 +340,43 @@ "get": { "summary": "Session", "operationId": "session_api_session_get", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -41,6 +387,16 @@ } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -49,20 +405,67 @@ "get": { "summary": "Events", "operationId": "events_api_events_get", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { + "type": "array", "items": { "$ref": "#/components/schemas/Caption" }, - "type": "array", "title": "Response Events Api Events Get" } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -71,15 +474,52 @@ "post": { "summary": "Source Event", "operationId": "source_event_api_source_events_post", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SourceEvent" } } - }, - "required": true + } }, "responses": { "200": { @@ -113,35 +553,129 @@ } } }, - "/api/source/udtalk": { + "/api/source/udtalk": { + "get": { + "summary": "Udtalk Status", + "operationId": "udtalk_status_api_source_udtalk_get", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UdtalkStatus" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/connection": { "get": { - "summary": "Udtalk Status", - "operationId": "udtalk_status_api_source_udtalk_get", + "summary": "Connection", + "operationId": "connection_api_connection_get", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UdtalkStatus" + "$ref": "#/components/schemas/ConnectionStatus" } } } - } - } - } - }, - "/api/connection": { - "get": { - "summary": "Connection", - "operationId": "connection_api_connection_get", - "responses": { - "200": { - "description": "Successful Response", + }, + "422": { + "description": "Validation Error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConnectionStatus" + "$ref": "#/components/schemas/HTTPValidationError" } } } @@ -151,15 +685,52 @@ "put": { "summary": "Set Connection", "operationId": "set_connection_api_connection_put", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConnectionSettings" } } - }, - "required": true + } }, "responses": { "200": { @@ -309,6 +880,41 @@ "type": "string", "title": "Caption Id" } + }, + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } } ], "requestBody": { @@ -368,6 +974,41 @@ "type": "string", "title": "Caption Id" } + }, + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } } ], "requestBody": { @@ -416,15 +1057,52 @@ "post": { "summary": "Command", "operationId": "command_api_commands_post", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Command" } } - }, - "required": true + } }, "responses": { "200": { @@ -454,15 +1132,52 @@ "put": { "summary": "Publication Mode", "operationId": "publication_mode_api_publication_mode_put", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicationMode" } } - }, - "required": true + } }, "responses": { "200": { @@ -492,15 +1207,52 @@ "put": { "summary": "Display Settings", "operationId": "display_settings_api_display_put", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DisplaySettings" } } - }, - "required": true + } }, "responses": { "200": { @@ -530,6 +1282,43 @@ "get": { "summary": "Ai Routing", "operationId": "ai_routing_api_ai_get", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -540,21 +1329,68 @@ } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } }, "put": { "summary": "Ai Settings", "operationId": "ai_settings_api_ai_put", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AiSettings" } } - }, - "required": true + } }, "responses": { "200": { @@ -584,6 +1420,43 @@ "get": { "summary": "Get Dictionary", "operationId": "get_dictionary_api_dictionary_get", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -594,21 +1467,68 @@ } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } }, "put": { "summary": "Set Dictionary", "operationId": "set_dictionary_api_dictionary_put", + "parameters": [ + { + "name": "X-Caption-Relay-Room", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Room" + } + }, + { + "name": "X-Caption-Relay-Generation", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ], + "title": "X-Caption-Relay-Generation" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DictionaryUpdate" } } - }, - "required": true + } }, "responses": { "200": { @@ -622,24 +1542,24 @@ } }, "409": { - "description": "Conflict", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DictionaryError" } } - } + }, + "description": "Conflict" }, "503": { - "description": "Service Unavailable", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DictionaryError" } } - } + }, + "description": "Service Unavailable" }, "422": { "description": "Validation Error", @@ -658,6 +1578,24 @@ "get": { "summary": "Public View", "operationId": "public_view_api_view_get", + "parameters": [ + { + "name": "room_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Room Id" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -668,6 +1606,16 @@ } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -676,9 +1624,37 @@ "get": { "summary": "Public Events", "operationId": "public_events_api_view_events_get", + "parameters": [ + { + "name": "room_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Room Id" + } + } + ], "responses": { "200": { "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -1725,6 +2701,220 @@ ], "title": "RecognizerOption" }, + "RoomCatalog": { + "properties": { + "active_room_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Active Room Id" + }, + "rooms": { + "items": { + "$ref": "#/components/schemas/RoomSummary" + }, + "type": "array", + "title": "Rooms" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "active_room_id", + "rooms" + ], + "title": "RoomCatalog" + }, + "RoomDetails": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "last_used_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + }, + "generation": { + "type": "integer", + "title": "Generation" + }, + "caption_count": { + "type": "integer", + "title": "Caption Count" + }, + "mode": { + "type": "string", + "title": "Mode" + }, + "input_source": { + "type": "string", + "enum": [ + "udtalk", + "microphone" + ], + "title": "Input Source" + }, + "recognizer": { + "type": "string", + "enum": [ + "openai", + "apple" + ], + "title": "Recognizer" + }, + "background": { + "type": "string", + "title": "Background" + }, + "connection": { + "$ref": "#/components/schemas/ConnectionSettings" + }, + "ai": { + "$ref": "#/components/schemas/AiSettings" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "last_used_at", + "generation", + "caption_count", + "mode", + "input_source", + "recognizer", + "background", + "connection", + "ai" + ], + "title": "RoomDetails" + }, + "RoomDraft": { + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Name" + }, + "connection": { + "$ref": "#/components/schemas/ConnectionSettings" + }, + "ai": { + "$ref": "#/components/schemas/AiSettings" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name" + ], + "title": "RoomDraft" + }, + "RoomReset": { + "properties": { + "generation": { + "type": "integer", + "minimum": 0.0, + "title": "Generation" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "generation" + ], + "title": "RoomReset" + }, + "RoomSummary": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "last_used_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Used At" + }, + "generation": { + "type": "integer", + "title": "Generation" + }, + "caption_count": { + "type": "integer", + "title": "Caption Count" + }, + "mode": { + "type": "string", + "title": "Mode" + }, + "input_source": { + "type": "string", + "enum": [ + "udtalk", + "microphone" + ], + "title": "Input Source" + }, + "recognizer": { + "type": "string", + "enum": [ + "openai", + "apple" + ], + "title": "Recognizer" + }, + "background": { + "type": "string", + "title": "Background" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name", + "last_used_at", + "generation", + "caption_count", + "mode", + "input_source", + "recognizer", + "background" + ], + "title": "RoomSummary" + }, "Session": { "properties": { "name": { diff --git a/docs/index.md b/docs/index.md index edaa180..76c5a6c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,6 +30,8 @@ ## 文書と実装の対応 +ホームからの登録・再接続と字幕リセットは[ルームの接続と字幕リセット](rooms.md)を参照する。 + 現行仕様はコードとテスト、API のフィールドは [Pydantic モデル](../backend/app/models.py)と[生成した OpenAPI](generated/openapi.json)に照らして確認する。設計案の API、画面、性能目標を実装済みの機能として扱わない。外部連携は実接続と測定の結果で判断する。 文書を更新するときは、同じ詳細を複数の文書に書かず、担当する文書へリンクする。現行仕様・構成・設定を確認した場合は確認日も更新する。文書の追加・移動では、この一覧と相対リンクを更新する。 diff --git a/docs/manual.md b/docs/manual.md index d4362d5..3b717b2 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -2,9 +2,21 @@ 1. プロジェクトのルートで `bash scripts/setup.sh` を実行します。初回と依存関係を更新した後に必要です。 2. AI校正を使う場合は、起動前に[APIキーを環境変数へ設定](development.md#環境変数)します。AIを使わない場合は不要です。 -3. `bash scripts/dev.sh` を実行し、操作画面 を開きます。 +3. `bash scripts/dev.sh` を実行し、ホーム を開きます。 -![操作画面の最初の状態。上部に公開方法と設定の操作がある](assets/manual/01-start.jpg) +![ホームのルーム一覧。名前と最終利用日時から接続先を選ぶ](assets/manual/00-home.jpg) + +## ルームを選ぶ + +1. 登録済みのルームは「接続」を押します。初回の画面確認には「独立配信」を使えます。 +2. 追加するときは「新しいルームに接続」を押し、名前と配信先を選びます。UDトークへ反映する場合は編集用の参加リンクを入力します。 +3. 必要ならトークの背景を記入し、「登録して接続」を押します。登録済みの設定は一覧の設定アイコンから変更できます。 + +![ルーム名・配信先・入力元・背景の設定](assets/manual/00-room-settings.jpg) + +ルーム画面の左上のホームアイコンで戻れます。別のルームへ接続すると、前のルームの入力を終了します。次のトークの前に字幕を消す場合は「字幕をリセット」を押し、確認画面で実行します。ルームの設定とUDトーク側の字幕は残ります。詳しくは[ルームの接続と字幕リセット](rooms.md)を参照してください。 + +![ルーム名と削除対象を示す字幕リセットの確認](assets/manual/00-reset.jpg) ![操作画面の上部。公開方法、準備、各設定、観客画面の入口](assets/manual/01-toolbar.jpg) @@ -154,4 +166,4 @@ UDトーク入力ではAI校正が辞書を参照し、UDトーク側の認識 3. 観客画面を全画面表示していたら解除します。 4. サーバーを起動したターミナルで Ctrl+C を押します。 -字幕履歴、接続設定、表示設定、AI設定はサーバーのメモリにあり、再起動すると消えます。辞書だけは接続先の端末に残ります。認証と永続的な字幕保存は提供していません。 +字幕履歴は再起動すると消えます。ルームの接続先・背景・AI・表示設定と単語辞書は、接続先の端末に残ります。認証と永続的な字幕保存は提供していません。 diff --git a/docs/rooms.md b/docs/rooms.md new file mode 100644 index 0000000..bd75a5a --- /dev/null +++ b/docs/rooms.md @@ -0,0 +1,44 @@ +# ルームの接続と字幕リセット + +ホームから、以前使ったルームへの再接続と、新しいルームの登録ができます。字幕の操作は、接続後のルーム画面で行います。 + +## 登録・再接続 + +1. ホームの「新しいルームに接続」を押します。 +2. ルーム名と配信先を選びます。「UDトークへ反映」では編集用の参加リンク、「独立配信」では字幕の入力元を指定します。必要ならトークの背景を記入します。 +3. 「登録して接続」を押します。登録済みのルームは一覧の「接続」から開けます。設定アイコンから名前・接続先・背景を変更できます。 + +字幕入力の接続は同時に一つです。別のルームへ接続すると、前のルームの入力と処理中のAI校正を終了します。ルームを切り替えても、それまで受け取った字幕はルームごとに保持します。ホームへ戻る操作でも、現在の入力を終了します。 + +形式が不正なリンクと閲覧専用リンクは、接続を切り替える前に拒否します。保存済みの編集用リンクは画面へ返しません。接続先を変えるときだけ、新しいリンクを入力します。参加キーの有効性はUDトークの応答で判断します。 + +## 字幕をリセットする + +1. ルーム画面の「字幕をリセット」を押します。 +2. 確認画面でルーム名を確かめ、「字幕をリセット」を押します。「キャンセル」またはEscapeでは字幕と下書きを残します。 + +リセットは、そのルームの字幕、公開字幕、未保存の下書き、処理中のAI校正を消します。ルーム名、参加リンク、背景、AI・表示・公開設定、単語辞書は残ります。UDトーク側の字幕を削除・訂正する要求は送りません。受信済みの発話が再送されても、消した字幕を復元しません。 + +未保存の変更がある状態でホームへ戻る場合も、破棄するか確認します。別のルームへ切り替えた後の古いタブからは保存・リセットを受け付けません。観客画面は、操作画面のモニターアイコンから開いてください。開いたルームの字幕だけを受け取ります。 + +## 保存される情報 + +| 情報 | ルーム切り替え | サーバー再起動 | +|---|---|---| +| 名前・参加リンク・背景・最終利用日時 | 保持 | 保持 | +| AI・表示・公開設定 | 保持 | 保持 | +| 受信した字幕・公開した字幕 | ルームごとに保持 | 消える | +| 未保存の下書き | ホームへ戻ると破棄 | 消える | +| 単語辞書 | 全ルームで共有 | 保持 | + +再起動後はホームから接続します。録音は自動で開始しません。背景はルームごとに保存し、AI校正に渡します。リセットでは直前の字幕の文脈を消します。 + +保存先は `CAPTION_RELAY_ROOMS_PATH`、未指定なら `$XDG_DATA_HOME/caption-relay/rooms.json` です。`XDG_DATA_HOME` も未指定なら `~/.local/share/caption-relay/rooms.json` を使います。編集用の参加リンクは権限を持つため、このファイルを公開・共有しないでください。ファイルの権限は所有者だけが読み書きできる0600にします。APIキーと字幕は保存しません。上限は100ルームです。 + +ローカルの単一プロセス用で、アプリ全体の認証や複数ワーカーには対応していません。設定ファイルを読めないときは、空の内容で上書きせず起動を止めます。保存失敗時もエラーを返し、字幕とリセット前の状態を保ちます。 + +## 検証 + +[バックエンドのテスト](../backend/tests/test_rooms.py)で、ルーム間の分離、設定の保存・復元、参加リンクの非公開、リセット、処理中AIの終了、古いタブと入力再送、保存失敗を検証します。UDトークへの要求は模擬HTTPで確認します。[画面のテスト](../frontend/src/rooms.test.tsx)で、登録・再接続、入力保持、確認のキャンセル、下書きの破棄、通信へのルーム情報の付与を検証します。 + +実ブラウザでは専用のローカルサーバーと合成のテスト字幕を使い、追加、切り替え、古いタブからの保存拒否、リセットの確認・キャンセル、再起動後の再接続を確認しました。この変更で実際のUDトークやAI提供元への接続試験は行っていません。外部連携の確認範囲は[入力接続](udtalk-input.md)と[AIの記録](ai-routing.md)を参照してください。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1cd7d96..1428aca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "name": "caption-relay-frontend", + "hasInstallScript": true, "dependencies": { "@fontsource-variable/geist": "^5.3.0", "@xstate/react": "^6.1.0", @@ -17,7 +18,7 @@ "react-dom": "^19.3.0", "tailwind-merge": "^3.7.0", "tw-animate-css": "^1.4.0", - "xstate": "^5.33.2" + "xstate": "5.33.2" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -29,6 +30,7 @@ "@vitejs/plugin-react": "^6.1.1", "jsdom": "^30.1.1", "openapi-typescript": "^7.13.0", + "patch-package": "8.0.1", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", "vite": "^8.3.2", @@ -2809,6 +2811,13 @@ } } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2904,6 +2913,69 @@ "require-from-string": "^2.0.2" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -2914,6 +2986,52 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -2921,6 +3039,22 @@ "dev": true, "license": "MIT" }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2954,6 +3088,26 @@ "node": ">=20" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/colorette": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", @@ -2961,6 +3115,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -3036,6 +3205,24 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3071,6 +3258,21 @@ "license": "MIT", "peer": true }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/enhanced-resolve": { "version": "5.26.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", @@ -3098,6 +3300,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -3105,6 +3327,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3150,6 +3385,44 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3165,6 +3438,41 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -3174,6 +3482,33 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3181,6 +3516,55 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-encoding-sniffer": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", @@ -3221,6 +3605,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3228,6 +3638,33 @@ "dev": true, "license": "MIT" }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3325,6 +3762,59 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3644,6 +4134,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -3651,6 +4151,43 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3677,6 +4214,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -3691,6 +4238,23 @@ "node": ">=12.20.0" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openapi-typescript": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", @@ -3743,6 +4307,46 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4066,6 +4670,70 @@ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -4184,6 +4852,29 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tough-cookie": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", @@ -4269,6 +4960,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/uri-js-replace": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", @@ -4834,6 +5535,22 @@ "node": "^22.14.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", @@ -4874,6 +5591,22 @@ "url": "https://opencollective.com/xstate" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yaml-ast-parser": { "version": "0.0.43", "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", diff --git a/frontend/package.json b/frontend/package.json index b692e3d..ce3f144 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "check:api": "uv run --project ../backend python ../scripts/export_openapi.py --check && node scripts/check-api.mjs", "typecheck": "tsc --noEmit", "build": "npm run typecheck && vite build", - "test": "vitest run" + "test": "vitest run", + "postinstall": "patch-package --error-on-fail" }, "dependencies": { "@fontsource-variable/geist": "^5.3.0", @@ -25,7 +26,7 @@ "react-dom": "^19.3.0", "tailwind-merge": "^3.7.0", "tw-animate-css": "^1.4.0", - "xstate": "^5.33.2" + "xstate": "5.33.2" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -37,6 +38,7 @@ "@vitejs/plugin-react": "^6.1.1", "jsdom": "^30.1.1", "openapi-typescript": "^7.13.0", + "patch-package": "8.0.1", "tailwindcss": "^4.3.3", "typescript": "^5.9.3", "vite": "^8.3.2", diff --git a/frontend/patches/README.md b/frontend/patches/README.md new file mode 100644 index 0000000..add761a --- /dev/null +++ b/frontend/patches/README.md @@ -0,0 +1,7 @@ +# XStateのPromise取消 + +`xstate+5.33.2.patch` は、開発用のStrict Modeで取り消した通信の結果が、再開後の読み込みへ届く不具合を修正する。[上流への修正PR](https://github.com/statelyai/xstate/pull/5823)と同じ取消判定を、アプリが使うCJS・ESMの通常用・開発用バンドルに適用する。 + +`npm ci` の `postinstall` で適用し、適用できない場合は失敗させる。アプリ側の通信を取り消さない回避策は加えない。[ホームのStrict Modeテスト](../src/rooms.test.tsx)が、取消後も初回の読み込みを完了できることを検証する。 + +修正を含む正式リリースへ更新する際は、上流の収録内容を確認し、このパッチと `patch-package`、`postinstall` を削除する。再インストール、回帰テスト、開発用サーバーでの初回表示を確認する。 diff --git a/frontend/patches/xstate+5.33.2.patch b/frontend/patches/xstate+5.33.2.patch new file mode 100644 index 0000000..4c2146f --- /dev/null +++ b/frontend/patches/xstate+5.33.2.patch @@ -0,0 +1,88 @@ +diff --git a/node_modules/xstate/dist/xstate-actors.cjs.js b/node_modules/xstate/dist/xstate-actors.cjs.js +index de217a3..198e0ed 100644 +--- a/node_modules/xstate/dist/xstate-actors.cjs.js ++++ b/node_modules/xstate/dist/xstate-actors.cjs.js +@@ -799,7 +799,7 @@ function fromPromise(promiseCreator) { + emit + })); + resolvedPromise.then(response => { +- if (self.getSnapshot().status !== 'active') { ++ if (controller.signal.aborted || self.getSnapshot().status !== 'active') { + return; + } + controllerMap.delete(self); +@@ -808,7 +808,7 @@ function fromPromise(promiseCreator) { + data: response + }); + }, errorData => { +- if (self.getSnapshot().status !== 'active') { ++ if (controller.signal.aborted || self.getSnapshot().status !== 'active') { + return; + } + controllerMap.delete(self); +diff --git a/node_modules/xstate/dist/xstate-actors.development.cjs.js b/node_modules/xstate/dist/xstate-actors.development.cjs.js +index 0768a2b..b3887da 100644 +--- a/node_modules/xstate/dist/xstate-actors.development.cjs.js ++++ b/node_modules/xstate/dist/xstate-actors.development.cjs.js +@@ -799,7 +799,7 @@ function fromPromise(promiseCreator) { + emit + })); + resolvedPromise.then(response => { +- if (self.getSnapshot().status !== 'active') { ++ if (controller.signal.aborted || self.getSnapshot().status !== 'active') { + return; + } + controllerMap.delete(self); +@@ -808,7 +808,7 @@ function fromPromise(promiseCreator) { + data: response + }); + }, errorData => { +- if (self.getSnapshot().status !== 'active') { ++ if (controller.signal.aborted || self.getSnapshot().status !== 'active') { + return; + } + controllerMap.delete(self); +diff --git a/node_modules/xstate/dist/xstate-actors.development.esm.js b/node_modules/xstate/dist/xstate-actors.development.esm.js +index e039d2d..e85ee2c 100644 +--- a/node_modules/xstate/dist/xstate-actors.development.esm.js ++++ b/node_modules/xstate/dist/xstate-actors.development.esm.js +@@ -795,7 +795,7 @@ function fromPromise(promiseCreator) { + emit + })); + resolvedPromise.then(response => { +- if (self.getSnapshot().status !== 'active') { ++ if (controller.signal.aborted || self.getSnapshot().status !== 'active') { + return; + } + controllerMap.delete(self); +@@ -804,7 +804,7 @@ function fromPromise(promiseCreator) { + data: response + }); + }, errorData => { +- if (self.getSnapshot().status !== 'active') { ++ if (controller.signal.aborted || self.getSnapshot().status !== 'active') { + return; + } + controllerMap.delete(self); +diff --git a/node_modules/xstate/dist/xstate-actors.esm.js b/node_modules/xstate/dist/xstate-actors.esm.js +index d1447b4..19dcd16 100644 +--- a/node_modules/xstate/dist/xstate-actors.esm.js ++++ b/node_modules/xstate/dist/xstate-actors.esm.js +@@ -795,7 +795,7 @@ function fromPromise(promiseCreator) { + emit + })); + resolvedPromise.then(response => { +- if (self.getSnapshot().status !== 'active') { ++ if (controller.signal.aborted || self.getSnapshot().status !== 'active') { + return; + } + controllerMap.delete(self); +@@ -804,7 +804,7 @@ function fromPromise(promiseCreator) { + data: response + }); + }, errorData => { +- if (self.getSnapshot().status !== 'active') { ++ if (controller.signal.aborted || self.getSnapshot().status !== 'active') { + return; + } + controllerMap.delete(self); diff --git a/frontend/src/adapters/microphone.ts b/frontend/src/adapters/microphone.ts index f36f4e8..c15bbbf 100644 --- a/frontend/src/adapters/microphone.ts +++ b/frontend/src/adapters/microphone.ts @@ -1,6 +1,6 @@ -import { OperationError, type MicrophoneAdapter, type MicrophoneSession, type TranscriptionStatus } from "../domain"; +import { OperationError, type MicrophoneAdapter, type MicrophoneSession, type TranscriptionStatus, type RoomBinding } from "../domain"; -export function createMicrophoneAdapter(): MicrophoneAdapter { +export function createMicrophoneAdapter(room?: RoomBinding): MicrophoneAdapter { return { available: Boolean(typeof navigator.mediaDevices?.getUserMedia === "function" && typeof AudioContext !== "undefined" && typeof AudioWorkletNode !== "undefined"), async devices() { @@ -47,6 +47,10 @@ export function createMicrophoneAdapter(): MicrophoneAdapter { await context.resume(); if (signal.aborted || released) throw new OperationError("マイクの開始を中止しました。"); const url = new URL("/api/source/audio", window.location.href); + if (room) { + url.searchParams.set("room_id", room.roomId); + url.searchParams.set("generation", String(room.generation)); + } url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; socket = new WebSocket(url); const connection = socket; diff --git a/frontend/src/adapters/operator-keyboard.ts b/frontend/src/adapters/operator-keyboard.ts index 67f2839..52039a5 100644 --- a/frontend/src/adapters/operator-keyboard.ts +++ b/frontend/src/adapters/operator-keyboard.ts @@ -6,6 +6,7 @@ export function observeOperatorKeys( handle: (event: ViewEvent) => boolean, ): () => void { const notify = (event: KeyboardEvent) => { + if (event.target instanceof Element && event.target.closest("dialog[open]")) return; if (handle({ type: "settings.keypressed", key: event.key, isComposing: event.isComposing, defaultPrevented: event.defaultPrevented })) event.preventDefault(); }; diff --git a/frontend/src/adapters/rooms.ts b/frontend/src/adapters/rooms.ts new file mode 100644 index 0000000..a10ebd6 --- /dev/null +++ b/frontend/src/adapters/rooms.ts @@ -0,0 +1,35 @@ +import { OperationError, type RoomBinding, type RoomCatalog, type RoomDetails, type RoomDraft, type RoomsApi } from "../domain"; + +async function request(path: string, signal: AbortSignal, method = "GET", payload?: unknown): Promise { + const response = await fetch(path, { method, signal, + ...(payload === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }) }); + if (!response.ok) { + const body = await response.json().catch(() => ({})) as { detail?: unknown }; + throw new OperationError(typeof body.detail === "string" ? body.detail : "ルームの接続を確認できませんでした。もう一度お試しください。"); + } + return response.json() as Promise; +} + +const resource = (id: string) => "/api/rooms/" + encodeURIComponent(id); +export const roomUrl = (id: string) => "/rooms/" + encodeURIComponent(id); + +export function roomIdFromPath(path: string): string | null { + return /^\/rooms\/([a-z0-9-]{1,64})\/?$/.exec(path)?.[1] ?? null; +} + +export function navigate(path: string): void { window.location.assign(path); } + +export const roomsApi: RoomsApi = { + catalog: signal => request("/api/rooms", signal), + details: (id, signal) => request(resource(id), signal), + save: (draft: RoomDraft, id, signal) => request(id ? resource(id) : "/api/rooms", signal, id ? "PUT" : "POST", draft), + connect: (id, signal) => request(resource(id) + "/connect", signal, "POST"), + async leave(room: RoomBinding, signal) { + const response = await fetch(resource(room.roomId) + "/leave", { method: "POST", signal, + headers: { "content-type": "application/json" }, body: JSON.stringify({ generation: room.generation }) }); + // A stale tab can return home, but cannot disconnect the newer connection. + if (response.ok || response.status === 409) return; + throw new OperationError("ルームの接続を終了できませんでした。もう一度お試しください。"); + }, + reset: (room, signal) => request(resource(room.roomId) + "/reset", signal, "POST", { generation: room.generation }), +}; diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9d7996b..4074d90 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import { CaptionConflict, OperationError, type ConnectionStatus, type AiRouting, type AudienceApi, type Caption, type DisplaySettings, type OperatorApi, type PublicSnapshot, type DictionarySnapshot } from "./domain"; +import { CaptionConflict, OperationError, type ConnectionStatus, type AiRouting, type AudienceApi, type Caption, type DisplaySettings, type OperatorApi, type PublicSnapshot, type DictionarySnapshot, type RoomBinding } from "./domain"; import { defaultDisplay } from "./display"; import type { components } from "./generated/api"; @@ -24,57 +24,69 @@ const json = (method: string, data: unknown): RequestInit => ({ method, headers: { "content-type": "application/json" }, body: JSON.stringify(data), }); -export const operatorApi: OperatorApi = { - async load(signal) { - const [session, captions, ai, connection, dictionary] = await Promise.all([ - request("/api/session", signal), - request("/api/events", signal), - request("/api/ai", signal), - request("/api/connection", signal), - request("/api/dictionary", signal), - ]); - return { visible: session.visible, captions, autoPublish: session.auto_publish ?? true, - display: { ...defaultDisplay, ...session.display }, ai, connection, dictionary, - receiverUrl: connection.receiver_path ? new URL(connection.receiver_path, window.location.origin).href : "" }; - }, - save(draft, signal) { - return request(`/api/captions/${encodeURIComponent(draft.captionId)}`, signal, - json("PATCH", { text: draft.text, base_revision: draft.baseRevision, base_source_revision: draft.baseSourceRevision } satisfies components["schemas"]["CaptionPatch"])); - }, - async setVisible(visible, signal) { - const result = await request("/api/commands", signal, - json("POST", { command: visible ? "show" : "hide" } satisfies components["schemas"]["Command"])); - return result.visible; - }, - async sendTest(signal) { - const id = `test-${crypto.randomUUID()}`; - await request("/api/source/events", signal, json("POST", { - utterance_id: id, revision: 1, text: "えー,本日は,本日はありがとうございます.", - is_final: true, source_event_id: id, removed: false, - } satisfies components["schemas"]["SourceEvent"])); - }, - async setPublicationMode(mode, signal) { - const result = await request("/api/publication-mode", signal, - json("PUT", mode)); - return result; - }, - async setDisplay(settings, signal) { - return request("/api/display", signal, json("PUT", settings)); - }, - async setAi(settings, signal) { - return request("/api/ai", signal, json("PUT", settings)); - }, - async setConnection(settings, signal) { - return request("/api/connection", signal, json("PUT", settings)); - }, - async setDictionary(settings, signal) { - return request("/api/dictionary", signal, json("PUT", settings)); - }, -}; +export function createOperatorApi(room?: RoomBinding): OperatorApi { + const scopedRequest = (path: string, signal: AbortSignal, options: RequestInit = {}) => { + const headers = new Headers(options.headers); + if (room) { + headers.set("X-Caption-Relay-Room", room.roomId); + headers.set("X-Caption-Relay-Generation", String(room.generation)); + } + return request(path, signal, { ...options, headers }); + }; + return { + async load(signal) { + const [session, captions, ai, connection, dictionary] = await Promise.all([ + scopedRequest("/api/session", signal), + scopedRequest("/api/events", signal), + scopedRequest("/api/ai", signal), + scopedRequest("/api/connection", signal), + scopedRequest("/api/dictionary", signal), + ]); + return { visible: session.visible, captions, autoPublish: session.auto_publish ?? true, + display: { ...defaultDisplay, ...session.display }, ai, connection, dictionary, + receiverUrl: connection.receiver_path ? new URL(connection.receiver_path, window.location.origin).href : "" }; + }, + save(draft, signal) { + return scopedRequest(`/api/captions/${encodeURIComponent(draft.captionId)}`, signal, + json("PATCH", { text: draft.text, base_revision: draft.baseRevision, base_source_revision: draft.baseSourceRevision } satisfies components["schemas"]["CaptionPatch"])); + }, + async setVisible(visible, signal) { + const result = await scopedRequest("/api/commands", signal, + json("POST", { command: visible ? "show" : "hide" } satisfies components["schemas"]["Command"])); + return result.visible; + }, + async sendTest(signal) { + const id = `test-${crypto.randomUUID()}`; + await scopedRequest("/api/source/events", signal, json("POST", { + utterance_id: id, revision: 1, text: "えー,本日は,本日はありがとうございます.", + is_final: true, source_event_id: id, removed: false, + } satisfies components["schemas"]["SourceEvent"])); + }, + async setPublicationMode(mode, signal) { + const result = await scopedRequest("/api/publication-mode", signal, + json("PUT", mode)); + return result; + }, + async setDisplay(settings, signal) { + return scopedRequest("/api/display", signal, json("PUT", settings)); + }, + async setAi(settings, signal) { + return scopedRequest("/api/ai", signal, json("PUT", settings)); + }, + async setConnection(settings, signal) { + return scopedRequest("/api/connection", signal, json("PUT", settings)); + }, + async setDictionary(settings, signal) { + return scopedRequest("/api/dictionary", signal, json("PUT", settings)); + }, + }; +} +export const operatorApi = createOperatorApi(); export const audienceApi: AudienceApi = { subscribe(emit) { - const stream = new EventSource("/api/view/events"); + const roomId = new URLSearchParams(window.location.search).get("room_id"); + const stream = new EventSource("/api/view/events" + (roomId ? "?room_id=" + encodeURIComponent(roomId) : "")); stream.addEventListener("open", () => emit({ type: "stream.opened" })); stream.addEventListener("error", () => emit({ type: "stream.failed" })); stream.addEventListener("snapshot", event => { diff --git a/frontend/src/components/ui/confirmation-dialog.tsx b/frontend/src/components/ui/confirmation-dialog.tsx new file mode 100644 index 0000000..8e0e0ac --- /dev/null +++ b/frontend/src/components/ui/confirmation-dialog.tsx @@ -0,0 +1,27 @@ +import { useEffect, useRef } from "react"; +import { viewHierarchy } from "../../events/hierarchy"; +import type { ConfirmationParameters } from "../../presenters/room"; +import { Button } from "./button"; + +export function ConfirmationDialog({ parameters }: { parameters: ConfirmationParameters }) { + const dialog = useRef(null); + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (parameters.open && !element.open) { + element.showModal(); + element.querySelector("[data-confirm-cancel]")?.focus(); + } else if (!parameters.open && element.open) element.close(); + }, [parameters.open]); + return + {emit => { event.preventDefault(); emit(parameters.cancel); }}> +

    {parameters.title}

    +

    {parameters.body}

    +
    + + +
    +
    } +
    ; +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx new file mode 100644 index 0000000..95b3294 --- /dev/null +++ b/frontend/src/components/ui/form.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; +import type { ViewEvent } from "../../domain"; +import { viewHierarchy } from "../../events/hierarchy"; + +export function Form({ intent, className, children }: { intent: ViewEvent; className?: string; children: ReactNode }) { + return + {emit =>
    { event.preventDefault(); emit(intent); }}>{children}
    } +
    ; +} diff --git a/frontend/src/domain.ts b/frontend/src/domain.ts index 8e3f391..2cd455b 100644 --- a/frontend/src/domain.ts +++ b/frontend/src/domain.ts @@ -11,6 +11,35 @@ export type TranscriptionStatus = Required; export type DictionarySnapshot = Omit, "entries"> & { entries: DictionaryWord[] }; export type DictionaryUpdate = Omit, "entries"> & { entries: DictionaryWord[] }; +export type RoomSummary = components["schemas"]["RoomSummary"]; +export type RoomDetails = components["schemas"]["RoomDetails"]; +export type RoomCatalog = components["schemas"]["RoomCatalog"]; +export type RoomDraft = components["schemas"]["RoomDraft"]; +export interface RoomBinding { roomId: string; generation: number } +export interface RoomsApi { + catalog(signal: AbortSignal): Promise; + details(roomId: string, signal: AbortSignal): Promise; + save(draft: RoomDraft, roomId: string | null, signal: AbortSignal): Promise; + connect(roomId: string, signal: AbortSignal): Promise; + leave(room: RoomBinding, signal: AbortSignal): Promise; + reset(room: RoomBinding, signal: AbortSignal): Promise; +} +export type HomeEvent = + | { type: "home.retry.requested" } + | { type: "home.new.requested" } + | { type: "home.edit.requested"; roomId: string } + | { type: "home.connect.requested"; roomId: string } + | { type: "home.form.changed"; field: "name" | "background" | "mode" | "inputSource" | "editorUrl"; value: string } + | { type: "home.form.cancelled" } + | { type: "home.form.keypressed"; key: string; ctrlKey: boolean; metaKey: boolean; isComposing: boolean } + | { type: "home.save.requested" }; +export type RoomEvent = + | { type: "room.retry.requested" } + | { type: "room.home.requested" } + | { type: "room.reset.requested" } + | { type: "room.confirm.accepted" } + | { type: "room.confirm.cancelled" } + | { type: "room.unsaved.changed"; unsaved: boolean }; export interface MicrophoneDevice { id: string; label: string } export interface MicrophoneSession { label: string; stop(): Promise } export interface MicrophoneAdapter { @@ -124,7 +153,7 @@ export type AudienceEvent = | { type: "stream.snapshot"; snapshot: PublicSnapshot }; export type AudienceViewEvent = Extract; -export type ViewEvent = OperatorEvent | AudienceViewEvent; +export type ViewEvent = OperatorEvent | AudienceViewEvent | HomeEvent | RoomEvent; export interface FullscreenAdapter { available: boolean; diff --git a/frontend/src/generated/api.d.ts b/frontend/src/generated/api.d.ts index 1c044ae..f503c31 100644 --- a/frontend/src/generated/api.d.ts +++ b/frontend/src/generated/api.d.ts @@ -21,6 +21,110 @@ export interface paths { patch?: never; trace?: never; }; + "/rooms/{room_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Room Index */ + get: operations["room_index_rooms__room_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/rooms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Room Catalog */ + get: operations["room_catalog_api_rooms_get"]; + put?: never; + /** Add Room */ + post: operations["add_room_api_rooms_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/rooms/{room_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Room Details */ + get: operations["room_details_api_rooms__room_id__get"]; + /** Update Room */ + put: operations["update_room_api_rooms__room_id__put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/rooms/{room_id}/connect": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Connect Room */ + post: operations["connect_room_api_rooms__room_id__connect_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/rooms/{room_id}/leave": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Leave Room */ + post: operations["leave_room_api_rooms__room_id__leave_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/rooms/{room_id}/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reset Room */ + post: operations["reset_room_api_rooms__room_id__reset_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/view/demo": { parameters: { query?: never; @@ -763,6 +867,81 @@ export interface components { /** Message */ message: string; }; + /** RoomCatalog */ + RoomCatalog: { + /** Active Room Id */ + active_room_id: string | null; + /** Rooms */ + rooms: components["schemas"]["RoomSummary"][]; + }; + /** RoomDetails */ + RoomDetails: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Last Used At */ + last_used_at: string | null; + /** Generation */ + generation: number; + /** Caption Count */ + caption_count: number; + /** Mode */ + mode: string; + /** + * Input Source + * @enum {string} + */ + input_source: "udtalk" | "microphone"; + /** + * Recognizer + * @enum {string} + */ + recognizer: "openai" | "apple"; + /** Background */ + background: string; + connection: components["schemas"]["ConnectionSettings"]; + ai: components["schemas"]["AiSettings"]; + }; + /** RoomDraft */ + RoomDraft: { + /** Name */ + name: string; + connection?: components["schemas"]["ConnectionSettings"]; + ai?: components["schemas"]["AiSettings"]; + }; + /** RoomReset */ + RoomReset: { + /** Generation */ + generation: number; + }; + /** RoomSummary */ + RoomSummary: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Last Used At */ + last_used_at: string | null; + /** Generation */ + generation: number; + /** Caption Count */ + caption_count: number; + /** Mode */ + mode: string; + /** + * Input Source + * @enum {string} + */ + input_source: "udtalk" | "microphone"; + /** + * Recognizer + * @enum {string} + */ + recognizer: "openai" | "apple"; + /** Background */ + background: string; + }; /** Session */ Session: { /** @@ -903,6 +1082,255 @@ export interface operations { }; }; }; + room_index_rooms__room_id__get: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + room_catalog_api_rooms_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomCatalog"]; + }; + }; + }; + }; + add_room_api_rooms_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoomDraft"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + room_details_api_rooms__room_id__get: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_room_api_rooms__room_id__put: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoomDraft"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + connect_room_api_rooms__room_id__connect_post: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + leave_room_api_rooms__room_id__leave_post: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoomReset"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomCatalog"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + reset_room_api_rooms__room_id__reset_post: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoomReset"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; view_view_demo_get: { parameters: { query?: never; @@ -924,7 +1352,10 @@ export interface operations { session_api_session_get: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -939,12 +1370,24 @@ export interface operations { "application/json": components["schemas"]["Session"]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; events_api_events_get: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -959,12 +1402,24 @@ export interface operations { "application/json": components["schemas"]["Caption"][]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; source_event_api_source_events_post: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -997,7 +1452,10 @@ export interface operations { udtalk_status_api_source_udtalk_get: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -1012,12 +1470,24 @@ export interface operations { "application/json": components["schemas"]["UdtalkStatus"]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; connection_api_connection_get: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -1032,12 +1502,24 @@ export interface operations { "application/json": components["schemas"]["ConnectionStatus"]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; set_connection_api_connection_put: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -1135,7 +1617,10 @@ export interface operations { patch_caption_api_captions__caption_id__patch: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path: { caption_id: string; }; @@ -1179,7 +1664,10 @@ export interface operations { formatted_result_api_captions__caption_id__formatted_put: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path: { caption_id: string; }; @@ -1214,7 +1702,10 @@ export interface operations { command_api_commands_post: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -1247,7 +1738,10 @@ export interface operations { publication_mode_api_publication_mode_put: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -1280,7 +1774,10 @@ export interface operations { display_settings_api_display_put: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -1313,7 +1810,10 @@ export interface operations { ai_routing_api_ai_get: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -1328,12 +1828,24 @@ export interface operations { "application/json": components["schemas"]["AiRouting"]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; ai_settings_api_ai_put: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -1366,7 +1878,10 @@ export interface operations { get_dictionary_api_dictionary_get: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -1381,12 +1896,24 @@ export interface operations { "application/json": components["schemas"]["DictionarySnapshot"]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; set_dictionary_api_dictionary_put: { parameters: { query?: never; - header?: never; + header?: { + "X-Caption-Relay-Room"?: string | null; + "X-Caption-Relay-Generation"?: number | null; + }; path?: never; cookie?: never; }; @@ -1436,7 +1963,9 @@ export interface operations { }; public_view_api_view_get: { parameters: { - query?: never; + query?: { + room_id?: string | null; + }; header?: never; path?: never; cookie?: never; @@ -1452,11 +1981,22 @@ export interface operations { "application/json": components["schemas"]["PublicSnapshot"]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; public_events_api_view_events_get: { parameters: { - query?: never; + query?: { + room_id?: string | null; + }; header?: never; path?: never; cookie?: never; @@ -1470,6 +2010,15 @@ export interface operations { }; content?: never; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; } diff --git a/frontend/src/machines/home.ts b/frontend/src/machines/home.ts new file mode 100644 index 0000000..c932386 --- /dev/null +++ b/frontend/src/machines/home.ts @@ -0,0 +1,96 @@ +import { assign, fromPromise, setup, type SnapshotFrom } from "xstate"; +import { defaultAiSettings } from "../ai"; +import { OperationError, type AiSettings, type HomeEvent, type RoomCatalog, type RoomDetails, type RoomsApi, type ViewEvent } from "../domain"; + +interface Form { + name: string; + mode: "host" | "client"; + inputSource: "udtalk" | "microphone"; + editorUrl: string; + ai: AiSettings; + storedLink: boolean; + connectionSettings: RoomDetails["connection"]; +} +interface Context { + api: RoomsApi; + catalog: RoomCatalog; + form: Form; + formOpen: boolean; + editingRoomId: string | null; + requestedRoomId: string; + destination: string | null; + message: string; + focusVersion: number; +} + +const emptyForm = (): Form => ({ name: "", mode: "client", inputSource: "udtalk", editorUrl: "", ai: { ...defaultAiSettings }, storedLink: false, + connectionSettings: { mode: "client", input_source: "udtalk", recognizer: "openai", zoom_text_format: "plain" } }); +const message = (error: unknown) => error instanceof OperationError ? error.message : "ルームの接続を確認できませんでした。もう一度お試しください。"; +const knownRoom = (context: Context, event: HomeEvent) => "roomId" in event && context.catalog.rooms.some(room => room.id === event.roomId); + +export const homeMachine = setup({ + types: {} as { context: Context; events: ViewEvent; input: { api: RoomsApi } }, + actors: { + catalog: fromPromise(({ input, signal }: { input: RoomsApi; signal: AbortSignal }) => input.catalog(signal)), + details: fromPromise(({ input, signal }: { input: { api: RoomsApi; id: string }; signal: AbortSignal }) => input.api.details(input.id, signal)), + connect: fromPromise(({ input, signal }: { input: { api: RoomsApi; id: string }; signal: AbortSignal }) => input.api.connect(input.id, signal)), + save: fromPromise(({ input, signal }: { input: Context; signal: AbortSignal }) => input.api.save({ name: input.form.name.trim(), ai: input.form.ai, + connection: { ...input.form.connectionSettings, mode: input.form.mode, input_source: input.form.mode === "client" ? "udtalk" : input.form.inputSource, + editor_url: input.form.mode === "client" ? input.form.editorUrl.trim() || null : null } }, input.editingRoomId, signal)), + }, + guards: { + knownRoom: ({ context, event }) => knownRoom(context, event as HomeEvent), + validForm: ({ context }) => context.formOpen && Boolean(context.form.name.trim()) && (context.form.mode === "host" || Boolean(context.form.editorUrl.trim()) || context.form.storedLink), + submitKey: ({ context, event }) => event.type === "home.form.keypressed" && event.key === "Enter" && (event.ctrlKey || event.metaKey) && !event.isComposing + && context.formOpen && Boolean(context.form.name.trim()) && (context.form.mode === "host" || Boolean(context.form.editorUrl.trim()) || context.form.storedLink), + }, + actions: { + newForm: assign(({ context }) => ({ form: emptyForm(), formOpen: true, editingRoomId: null, message: "", focusVersion: context.focusVersion + 1 })), + changeForm: assign(({ context, event }) => { + if (event.type !== "home.form.changed" || !context.formOpen) return {}; + const form = { ...context.form }; + if (event.field === "name") form.name = event.value; + if (event.field === "background") form.ai = { ...form.ai, background: event.value }; + if (event.field === "editorUrl") form.editorUrl = event.value; + if (event.field === "mode" && (event.value === "host" || event.value === "client")) form.mode = event.value; + if (event.field === "inputSource" && (event.value === "udtalk" || event.value === "microphone")) form.inputSource = event.value; + return { form, message: "" }; + }), + requestRoom: assign(({ event }) => "roomId" in event ? { requestedRoomId: event.roomId, message: "" } : {}), + }, +}).createMachine({ + id: "home", initial: "loading", + context: ({ input }) => ({ api: input.api, catalog: { active_room_id: null, rooms: [] }, form: emptyForm(), formOpen: false, + editingRoomId: null, requestedRoomId: "", destination: null, message: "", focusVersion: 0 }), + states: { + loading: { invoke: { src: "catalog", input: ({ context }) => context.api, + onDone: { target: "ready", actions: assign(({ event }) => ({ catalog: event.output, message: "" })) }, + onError: { target: "failed", actions: assign(({ event }) => ({ message: message(event.error) })) } } }, + failed: { on: { "home.retry.requested": "loading" } }, + ready: { on: { + "home.new.requested": { actions: "newForm" }, + "home.form.changed": { actions: "changeForm" }, + "home.form.cancelled": { actions: assign({ formOpen: false, message: "", editingRoomId: null }) }, + "home.save.requested": { guard: "validForm", target: "saving" }, + "home.form.keypressed": { guard: "submitKey", target: "saving" }, + "home.connect.requested": { guard: "knownRoom", target: "connecting", actions: "requestRoom" }, + "home.edit.requested": { guard: "knownRoom", target: "reading", actions: "requestRoom" }, + } }, + reading: { invoke: { src: "details", input: ({ context }) => ({ api: context.api, id: context.requestedRoomId }), + onDone: { target: "ready", actions: assign(({ context, event }) => { const room: RoomDetails = event.output; return { + editingRoomId: room.id, formOpen: true, form: { name: room.name, mode: room.connection.mode ?? "host", inputSource: room.connection.input_source ?? "udtalk", + editorUrl: "", storedLink: room.connection.mode === "client", ai: { ...defaultAiSettings, ...room.ai }, connectionSettings: room.connection }, focusVersion: context.focusVersion + 1, + }; }) }, onError: { target: "ready", actions: assign(({ event }) => ({ message: message(event.error) })) } } }, + saving: { invoke: { src: "save", input: ({ context }) => context, + onDone: { target: "connecting", actions: assign(({ context, event }) => ({ requestedRoomId: event.output.id, editingRoomId: event.output.id, + catalog: { ...context.catalog, rooms: [event.output, ...context.catalog.rooms.filter(room => room.id !== event.output.id)] }, + form: { name: event.output.name, mode: event.output.connection.mode ?? "host", inputSource: event.output.connection.input_source ?? "udtalk", + editorUrl: "", storedLink: event.output.connection.mode === "client", ai: { ...defaultAiSettings, ...event.output.ai }, connectionSettings: event.output.connection } })) }, + onError: { target: "ready", actions: assign(({ event }) => ({ message: message(event.error) })) } } }, + connecting: { invoke: { src: "connect", input: ({ context }) => ({ api: context.api, id: context.requestedRoomId }), + onDone: { target: "connected", actions: assign(({ event }) => ({ destination: event.output.id, form: emptyForm() })) }, + onError: { target: "ready", actions: assign(({ event }) => ({ message: message(event.error) })) } } }, + connected: { type: "final" }, + }, +}); +export type HomeSnapshot = SnapshotFrom; diff --git a/frontend/src/machines/room.ts b/frontend/src/machines/room.ts new file mode 100644 index 0000000..5a26c96 --- /dev/null +++ b/frontend/src/machines/room.ts @@ -0,0 +1,38 @@ +import { assign, fromPromise, setup, type SnapshotFrom } from "xstate"; +import { OperationError, type RoomBinding, type RoomDetails, type RoomsApi, type ViewEvent } from "../domain"; + +interface Context { api: RoomsApi; id: string; room: RoomDetails | null; unsaved: boolean; message: string; destination: boolean } +const binding = (context: Context): RoomBinding => ({ roomId: context.id, generation: context.room?.generation ?? 0 }); +const message = (error: unknown) => error instanceof OperationError ? error.message : "ルームの状態を確認できませんでした。もう一度お試しください。"; + +export const roomMachine = setup({ + types: {} as { context: Context; events: ViewEvent; input: { api: RoomsApi; id: string } }, + actors: { + connect: fromPromise(({ input, signal }: { input: Context; signal: AbortSignal }) => input.api.connect(input.id, signal)), + reset: fromPromise(({ input, signal }: { input: Context; signal: AbortSignal }) => input.api.reset(binding(input), signal)), + leave: fromPromise(({ input, signal }: { input: Context; signal: AbortSignal }) => input.api.leave(binding(input), signal)), + }, + guards: { unsaved: ({ context }) => context.unsaved }, +}).createMachine({ + id: "room", initial: "connecting", context: ({ input }) => ({ api: input.api, id: input.id, room: null, unsaved: false, message: "", destination: false }), + on: { "room.unsaved.changed": { actions: assign(({ event }) => ({ unsaved: event.unsaved })) } }, + states: { + connecting: { invoke: { src: "connect", input: ({ context }) => context, + onDone: { target: "ready", actions: assign(({ event }) => ({ room: event.output, message: "" })) }, + onError: { target: "failed", actions: assign(({ event }) => ({ message: message(event.error) })) } } }, + failed: { on: { "room.retry.requested": "connecting", "room.home.requested": "departed" } }, + ready: { on: { + "room.home.requested": [{ guard: "unsaved", target: "confirmLeave" }, { target: "leaving" }], + "room.reset.requested": "confirmReset", + } }, + confirmReset: { on: { "room.confirm.cancelled": "ready", "room.confirm.accepted": "resetting" } }, + confirmLeave: { on: { "room.confirm.cancelled": "ready", "room.confirm.accepted": "leaving" } }, + resetting: { invoke: { src: "reset", input: ({ context }) => context, + onDone: { target: "ready", actions: assign(({ event }) => ({ room: event.output, unsaved: false, message: "字幕をリセットしました。" })) }, + onError: { target: "ready", actions: assign(({ event }) => ({ message: message(event.error) })) } } }, + leaving: { invoke: { src: "leave", input: ({ context }) => context, + onDone: "departed", onError: { target: "ready", actions: assign(({ event }) => ({ message: message(event.error) })) } } }, + departed: { type: "final", entry: assign({ destination: true }) }, + }, +}); +export type RoomSnapshot = SnapshotFrom; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4cf5468..810a208 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,9 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { OperatorRoot } from "./roots"; +import { HomeRoot, RoomRoot } from "./roots"; +import { roomIdFromPath } from "./adapters/rooms"; import "./styles.css"; +import "./rooms.css"; -createRoot(document.getElementById("root")!).render(); +const roomId = roomIdFromPath(window.location.pathname); +createRoot(document.getElementById("root")!).render({roomId ? : }); diff --git a/frontend/src/presenters/home.ts b/frontend/src/presenters/home.ts new file mode 100644 index 0000000..33d8243 --- /dev/null +++ b/frontend/src/presenters/home.ts @@ -0,0 +1,35 @@ +import type { HomeSnapshot } from "../machines/home"; + +const usedAt = new Intl.DateTimeFormat("ja-JP", { dateStyle: "medium", timeStyle: "short" }); + +export function presentHome(snapshot: HomeSnapshot) { + const { context } = snapshot; + const busy = !snapshot.matches("ready") && !snapshot.matches("failed"); + return { + loading: snapshot.matches("loading"), failed: snapshot.matches("failed"), busy, + newDisabled: !snapshot.can({ type: "home.new.requested" }), + message: context.message, destination: context.destination, formOpen: context.formOpen, + form: { + name: context.form.name, background: context.form.ai.background, mode: context.form.mode, + inputSource: context.form.inputSource, editorUrl: context.form.editorUrl, + title: context.editingRoomId ? "ルームの接続設定" : "新しいルームに接続", + submitLabel: context.editingRoomId ? "保存して接続" : "登録して接続", + linkLabel: context.form.storedLink ? "新しい参加リンク" : "編集用の参加リンク", + linkRequired: context.form.mode === "client" && !context.form.storedLink, + showLink: context.form.mode === "client", showInputSource: context.form.mode === "host", + storedLinkHint: context.form.storedLink ? "変更するときだけ入力" : "", + saveDisabled: !snapshot.can({ type: "home.save.requested" }), busy, + focusVersion: context.focusVersion, + }, + rooms: context.catalog.rooms.map(room => ({ + id: room.id, name: room.name, + lastUsed: room.last_used_at ? usedAt.format(new Date(room.last_used_at)) : "", + captionCount: room.caption_count, + connectionLabel: room.mode === "client" ? "UDトークへ反映" : room.input_source === "microphone" ? "独立配信・マイク" : "独立配信・UDトーク", + active: room.id === context.catalog.active_room_id, + connecting: snapshot.matches("connecting") && room.id === context.requestedRoomId, + disabled: busy, + })), + }; +} +export type HomeParameters = ReturnType; diff --git a/frontend/src/presenters/operator.ts b/frontend/src/presenters/operator.ts index 0f33e9c..1b3fd7d 100644 --- a/frontend/src/presenters/operator.ts +++ b/frontend/src/presenters/operator.ts @@ -67,6 +67,7 @@ export interface CaptionMonitorParameters { } export interface ToolbarParameters { + audienceHref: string; helpDisclosure: DisclosureParameters; preparationDisclosure: DisclosureParameters; preparationHint: string; @@ -231,7 +232,7 @@ export interface OperatorParameters { const rowCache = new WeakMap(); -export function presentOperator(snapshot: OperatorSnapshot): OperatorParameters { +export function presentOperator(snapshot: OperatorSnapshot, roomId?: string): OperatorParameters { const { context } = snapshot; const clientMode = context.connection.mode === "client"; let currentCaption: Caption | undefined; @@ -341,6 +342,7 @@ export function presentOperator(snapshot: OperatorSnapshot): OperatorParameters version: context.settingsFocusRequest, } : null, toolbar: { + audienceHref: "/view/demo" + (roomId ? "?room_id=" + encodeURIComponent(roomId) : ""), helpDisclosure: presentDisclosure("publication-help", context.openedDisclosures), preparationDisclosure: presentDisclosure("preparation", context.openedDisclosures), preparationHint: !context.visible ? "非表示中のため、テスト字幕は一覧に追加され、観客には表示されません。" diff --git a/frontend/src/presenters/room.ts b/frontend/src/presenters/room.ts new file mode 100644 index 0000000..00d3ecb --- /dev/null +++ b/frontend/src/presenters/room.ts @@ -0,0 +1,32 @@ +import type { ViewEvent } from "../domain"; +import type { RoomSnapshot } from "../machines/room"; + +export interface ConfirmationParameters { + open: boolean; + title: string; + body: string; + confirmLabel: string; + confirm: ViewEvent; + cancel: ViewEvent; +} + +export function presentRoom(snapshot: RoomSnapshot) { + const { context } = snapshot; + const reset = snapshot.matches("confirmReset"); + return { + room: context.room, showOperator: context.room !== null && !snapshot.matches("departed"), + loading: snapshot.matches("connecting"), failed: snapshot.matches("failed"), + disabled: !snapshot.matches("ready"), + homeDisabled: !snapshot.can({ type: "room.home.requested" }), + busy: snapshot.matches("leaving") || snapshot.matches("resetting"), + destination: context.destination, message: context.message, + confirmation: { + open: reset || snapshot.matches("confirmLeave"), + title: reset ? "字幕をリセットしますか?" : "変更を破棄して戻りますか?", + body: reset ? `「${context.room?.name ?? ""}」の字幕と未保存の訂正を消します。ルームの設定とUDトーク側の字幕は残ります。` : "未保存の変更を破棄してホームへ戻ります。", + confirmLabel: reset ? "字幕をリセット" : "破棄して戻る", + confirm: { type: "room.confirm.accepted" }, cancel: { type: "room.confirm.cancelled" }, + } satisfies ConfirmationParameters, + }; +} +export type RoomParameters = ReturnType; diff --git a/frontend/src/rooms.css b/frontend/src/rooms.css new file mode 100644 index 0000000..5170764 --- /dev/null +++ b/frontend/src/rooms.css @@ -0,0 +1,54 @@ +.home-screen { max-width: 1040px; min-height: 100svh; margin: 0 auto; padding: 40px 32px; color: #171717; background: #fff; font-size: 16px; line-height: 24px; } +.home-screen ::selection, .room-confirmation ::selection { color: #172554; background: #dbeafe; } +.home-screen :focus-visible, .room-actions-bar :focus-visible, .room-confirmation :focus-visible { outline: 2px solid #1d4ed8; outline-offset: 2px; } +.home-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-bottom: 32px; border-bottom: 1px solid #d4d4d4; } +.home-header h1 { font-size: 24px; font-weight: 700; line-height: 32px; } +.home-screen button, .room-actions-bar button, .room-confirmation button { min-height: 44px; gap: 8px; border-radius: 4px; padding: 8px 16px; } +.home-screen h2 { font-size: 18px; font-weight: 600; line-height: 32px; } +.home-rooms, .home-form-section { margin-top: 32px; } +.home-form-section { padding-bottom: 32px; border-bottom: 1px solid #d4d4d4; } +.home-form { max-width: 640px; margin-top: 16px; } +.home-form fieldset { display: grid; min-width: 0; gap: 8px; } +.home-form label:not(:first-child) { margin-top: 8px; } +.home-form input, .home-form select, .home-form textarea { width: 100%; min-width: 0; border: 1px solid #a3a3a3; border-radius: 4px; padding: 8px; color: #171717; background: #fff; font: inherit; line-height: 24px; } +.home-form textarea { resize: vertical; box-shadow: none; } +.home-form input::placeholder, .home-form textarea::placeholder { color: #525252; } +.home-form-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.home-hint { color: #525252; font-size: 12px; line-height: 24px; } +.home-room { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px 0; border-bottom: 1px solid #e5e5e5; } +.home-room-info { min-width: 0; } +.home-room h3 { overflow-wrap: anywhere; font-size: 18px; font-weight: 600; line-height: 24px; } +.home-room-meta { display: flex; flex-wrap: wrap; gap: 8px 16px; color: #525252; font-size: 12px; line-height: 24px; margin-top: 8px; } +.home-room-meta > span { display: inline-flex; align-items: center; gap: 8px; } +.home-room-meta .home-room-active { color: #15406a; font-weight: 600; } +.home-room-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.home-screen .home-room-settings, .room-actions-bar .room-home-button { width: 44px; padding: 8px; } +.home-error { margin: 24px 0; color: #991b1b; } +.home-loading { display: flex; align-items: center; gap: 8px; margin-top: 32px; color: #525252; } +.room-screen { display: flex; flex-direction: column; height: 100svh; min-height: 400px; } +.room-screen > .operator-shell { height: auto; min-height: 0; flex: 1; } +.room-actions-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; padding: 8px 24px; border-bottom: 1px solid #d4d4d4; color: #171717; background: #fafafa; } +.room-actions-bar h2 { flex: 1; min-width: 0; font-size: 16px; font-weight: 600; overflow-wrap: anywhere; } +.room-actions-bar > p { display: flex; align-items: center; gap: 8px; } +.room-notice { flex-basis: 100%; padding-left: 56px; font-size: 12px; line-height: 24px; } +.room-confirmation { max-width: min(480px, calc(100vw - 32px)); margin: auto; padding: 24px; border: 1px solid #737373; border-radius: 8px; color: #171717; background: #fff; } +.room-confirmation::backdrop { background: rgb(0 0 0 / .35); } +.room-confirmation h2 { font-size: 20px; font-weight: 600; line-height: 32px; } +.room-confirmation p { margin-top: 16px; line-height: 24px; } +.room-confirmation-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin-top: 24px; } +.room-confirmation button[data-variant="destructive"] { color: #fff; background: #991b1b; } +.room-confirmation button[data-variant="destructive"]:hover { background: #7f1d1d; } +.home-loading-icon { animation: room-loading 1s linear infinite; } +@keyframes room-loading { to { transform: rotate(360deg); } } +@media (max-width: 600px) { + .home-screen { padding: 24px 16px; } + .home-header { align-items: flex-start; flex-direction: column; gap: 16px; padding-bottom: 24px; } + .home-room { align-items: flex-start; flex-direction: column; gap: 16px; } + .home-room-actions { align-self: flex-end; } + .room-actions-bar { padding: 8px 16px; } +} +@media (max-width: 720px) and (max-height: 700px) { + .room-screen { height: auto; min-height: 100svh; } + .room-screen > .operator-shell { flex: none; min-height: 0; } +} +@media (prefers-reduced-motion: reduce) { .home-loading-icon { animation: none; } } diff --git a/frontend/src/rooms.test.tsx b/frontend/src/rooms.test.tsx new file mode 100644 index 0000000..d44a842 --- /dev/null +++ b/frontend/src/rooms.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, expect, it, vi } from "vitest"; +import { HomeRoot, RoomRoot } from "./roots"; +import { StrictMode } from "react"; +import { roomsApi } from "./adapters/rooms"; +import { createOperatorApi } from "./api"; +import { defaultAiSettings } from "./ai"; +import { OperationError, type Caption, type OperatorApi, type RoomDetails, type RoomsApi } from "./domain"; + +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function () { this.setAttribute("open", ""); }; + HTMLDialogElement.prototype.close = function () { this.removeAttribute("open"); }; +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +const room: RoomDetails = { id: "room-one", name: "作品のトーク", generation: 0, last_used_at: null, caption_count: 0, + mode: "host", input_source: "udtalk", recognizer: "openai", background: "展示の背景", ai: { ...defaultAiSettings, background: "展示の背景" }, + connection: { mode: "host", input_source: "udtalk", recognizer: "openai", zoom_text_format: "plain" } }; +const rooms = (): RoomsApi => ({ + catalog: vi.fn().mockResolvedValue({ active_room_id: null, rooms: [room] }), details: vi.fn().mockResolvedValue(room), + save: vi.fn().mockResolvedValue(room), connect: vi.fn().mockResolvedValue(room), leave: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue({ ...room, generation: 1 }), +}); +const caption: Caption = { id: "one", utterance_id: "one", received_at: "2026-10-06T01:02:03Z", source_revision: 1, + original: "原文", text: "字幕", revision: 1, human_edited: false, published: true, publish_eligible: true, source_final: true, ai_status: "off" }; +const operator = (): OperatorApi => ({ + load: vi.fn().mockResolvedValue({ visible: true, captions: [caption] }), save: vi.fn(), setVisible: vi.fn(), sendTest: vi.fn(), + setPublicationMode: vi.fn(), setDisplay: vi.fn(), setAi: vi.fn(), setConnection: vi.fn(), setDictionary: vi.fn(), +}); +const microphone = () => ({ available: false, devices: async () => [], start: vi.fn() }); + +it("loads the home after StrictMode cancels its first request", async () => { + const fetch = vi.fn((_path: string, { signal }: RequestInit) => new Promise((resolve, reject) => { + if (signal?.aborted) { reject(new DOMException("aborted", "AbortError")); return; } + const timer = setTimeout(() => resolve(new Response(JSON.stringify({ active_room_id: null, rooms: [room] }))), 0); + signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("aborted", "AbortError")); }, { once: true }); + })); + vi.stubGlobal("fetch", fetch); + render(); + await screen.findByRole("heading", { name: room.name }); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it("registers a room from the form once and connects before navigating", async () => { + const api = rooms(); + const go = vi.fn(); + let complete!: (room: RoomDetails) => void; + vi.mocked(api.save).mockReturnValue(new Promise(resolve => { complete = resolve; })); + render(); + const user = userEvent.setup(); + await screen.findByText(room.name); + await user.click(screen.getByRole("button", { name: "新しいルームに接続" })); + await user.type(screen.getByLabelText("ルーム名"), "新しい展示"); + await user.selectOptions(screen.getByLabelText("配信先"), "host"); + fireEvent.change(screen.getByLabelText("トークの背景"), { target: { value: "登壇者と作品" } }); + await user.keyboard("{Control>}{Enter}{/Control}"); + fireEvent.submit(screen.getByLabelText("ルーム名").closest("form")!); + expect(api.save).toHaveBeenCalledTimes(1); + expect(api.save).toHaveBeenCalledWith(expect.objectContaining({ name: "新しい展示", ai: expect.objectContaining({ background: "登壇者と作品" }), + connection: expect.objectContaining({ mode: "host", editor_url: null }) }), null, expect.any(AbortSignal)); + expect(go).not.toHaveBeenCalled(); + await act(async () => complete(room)); + await waitFor(() => expect(go).toHaveBeenCalledWith("/rooms/room-one")); + expect(api.connect).toHaveBeenCalledWith(room.id, expect.any(AbortSignal)); +}); + +it("retains a rejected link for correction and can reconnect from the room list", async () => { + const api = rooms(); + vi.mocked(api.save).mockRejectedValue(new OperationError("編集用の参加リンクを確認してください。")); + const go = vi.fn(); + render(); + const user = userEvent.setup(); + await screen.findByText(room.name); + await user.click(screen.getByRole("button", { name: "新しいルームに接続" })); + await user.type(screen.getByLabelText("ルーム名"), "接続テスト"); + fireEvent.change(screen.getByLabelText("編集用の参加リンク"), { target: { value: "invalid-link" } }); + await user.click(screen.getByRole("button", { name: "登録して接続" })); + await screen.findByRole("alert"); + expect((screen.getByLabelText("編集用の参加リンク") as HTMLTextAreaElement).value).toBe("invalid-link"); + await user.click(screen.getByRole("button", { name: "キャンセル" })); + await user.click(screen.getByRole("button", { name: room.name + "に接続" })); + await waitFor(() => expect(go).toHaveBeenCalledWith("/rooms/room-one")); +}); + +it("edits stored room settings without requiring the private link again", async () => { + const api = rooms(); + vi.mocked(api.details).mockResolvedValue({ ...room, mode: "client", connection: { ...room.connection, mode: "client", editor_url: null } }); + render(); + const user = userEvent.setup(); + await screen.findByText(room.name); + await user.click(screen.getByRole("button", { name: room.name + "の接続設定" })); + const background = await screen.findByLabelText("トークの背景"); + expect((background as HTMLTextAreaElement).value).toBe("展示の背景"); + await user.click(screen.getByRole("button", { name: "保存して接続" })); + await waitFor(() => expect(api.save).toHaveBeenCalledWith(expect.objectContaining({ connection: expect.objectContaining({ mode: "client", editor_url: null }) }), room.id, expect.any(AbortSignal))); +}); + +it("keeps a newly saved room in the list when its first connection fails", async () => { + const api = rooms(); + vi.mocked(api.catalog).mockResolvedValue({ active_room_id: null, rooms: [] }); + vi.mocked(api.connect).mockRejectedValueOnce(new OperationError("接続を確認してください。")); + const go = vi.fn(); + render(); + const user = userEvent.setup(); + await waitFor(() => expect(screen.getByRole("button", { name: "新しいルームに接続" }).disabled).toBe(false)); + await user.click(screen.getByRole("button", { name: "新しいルームに接続" })); + await user.type(screen.getByLabelText("ルーム名"), room.name); + await user.selectOptions(screen.getByLabelText("配信先"), "host"); + await user.click(screen.getByRole("button", { name: "登録して接続" })); + await screen.findByRole("alert"); + await user.click(screen.getByRole("button", { name: room.name + "に接続" })); + await waitFor(() => expect(go).toHaveBeenCalledWith("/rooms/room-one")); + expect(api.save).toHaveBeenCalledTimes(1); + expect(api.connect).toHaveBeenCalledTimes(2); +}); + +it("cancels reset without losing a draft and discards it only after a successful reset", async () => { + const api = rooms(); + const first = operator(); + const next = operator(); + vi.mocked(next.load).mockResolvedValue({ visible: true, captions: [] }); + const factory = vi.fn(binding => binding.generation === 0 ? first : next); + render(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "編集:字幕" })); + const editor = screen.getByRole("textbox", { name: "表示文" }); + fireEvent.change(editor, { target: { value: "未保存の訂正" } }); + await user.click(screen.getByRole("button", { name: "字幕をリセット" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "キャンセル" })); + expect((editor as HTMLTextAreaElement).value).toBe("未保存の訂正"); + expect(api.reset).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "字幕をリセット" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "字幕をリセット" })); + await waitFor(() => expect(next.load).toHaveBeenCalled()); + expect(api.reset).toHaveBeenCalledWith({ roomId: room.id, generation: 0 }, expect.any(AbortSignal)); + expect(screen.queryByRole("textbox", { name: "表示文" })).toBeNull(); + expect(first.save).not.toHaveBeenCalled(); + expect(screen.getByRole("link", { name: "観客画面" }).getAttribute("href")).toBe("/view/demo?room_id=room-one"); +}); + +it("keeps unsaved work after a reset failure and confirms leaving before removing the page guard", async () => { + const api = rooms(); + vi.mocked(api.reset).mockRejectedValue(new OperationError("保存先を確認してください。")); + const op = operator(); + const go = vi.fn(() => { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + }); + render( op} microphone={microphone} go={go} />); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "編集:字幕" })); + const editor = screen.getByRole("textbox", { name: "表示文" }); + fireEvent.change(editor, { target: { value: "残す訂正" } }); + await user.click(screen.getByRole("button", { name: "字幕をリセット" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "字幕をリセット" })); + await screen.findByText("保存先を確認してください。"); + expect((editor as HTMLTextAreaElement).value).toBe("残す訂正"); + await user.click(screen.getByRole("button", { name: "ホームへ戻る" })); + expect(api.leave).not.toHaveBeenCalled(); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "キャンセル" })); + expect(go).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "ホームへ戻る" })); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "破棄して戻る" })); + await waitFor(() => expect(go).toHaveBeenCalledWith("/")); + expect(api.leave).toHaveBeenCalledWith({ roomId: room.id, generation: 0 }, expect.any(AbortSignal)); +}); + +it("binds every operator read and write to the room and reset generation", async () => { + const fetch = vi.fn().mockImplementation((path: string) => Promise.resolve(new Response(JSON.stringify({ + "/api/session": { visible: true }, "/api/events": [], "/api/ai": {}, "/api/connection": {}, "/api/dictionary": {}, + }[path] ?? caption), { status: 200, headers: { "content-type": "application/json" } }))); + vi.stubGlobal("fetch", fetch); + const api = createOperatorApi({ roomId: room.id, generation: 7 }); + const signal = new AbortController().signal; + await api.load(signal); + await api.save({ captionId: caption.id, text: "訂正", baseRevision: 1 }, signal); + expect(fetch).toHaveBeenCalledTimes(6); + for (const [, options] of fetch.mock.calls) { + expect(options.headers.get("X-Caption-Relay-Room")).toBe(room.id); + expect(options.headers.get("X-Caption-Relay-Generation")).toBe("7"); + } + expect(fetch.mock.calls.at(-1)?.[1].headers.get("content-type")).toBe("application/json"); +}); diff --git a/frontend/src/roots.test.tsx b/frontend/src/roots.test.tsx index 94f473a..8a11bdf 100644 --- a/frontend/src/roots.test.tsx +++ b/frontend/src/roots.test.tsx @@ -744,6 +744,7 @@ it("selects a model directly and saves background after toggling AI off", async }); const toolbar: ToolbarParameters & { preparationHint: string } = { + audienceHref: "/view/demo", helpDisclosure: { id: "publication-help", open: false }, preparationDisclosure: { id: "preparation", open: false }, visibilityIcon: "hide", testDisabled: false, toggleDisabled: false, toggleLabel: "字幕を隠す", mode: "auto", modeDisabled: false, diff --git a/frontend/src/roots.tsx b/frontend/src/roots.tsx index c31262e..ddf000d 100644 --- a/frontend/src/roots.tsx +++ b/frontend/src/roots.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { useActorRef, useSelector } from "@xstate/react"; -import { operatorApi, audienceApi } from "./api"; +import { operatorApi, audienceApi, createOperatorApi } from "./api"; import { operatorMachine } from "./machines/operator"; import { audienceMachine } from "./machines/audience"; import { viewHierarchy } from "./events/hierarchy"; @@ -12,21 +12,72 @@ import { createFullscreenAdapter } from "./adapters/fullscreen"; import { protectUnsavedChanges } from "./adapters/unsaved-changes"; import { observeOperatorKeys } from "./adapters/operator-keyboard"; import { createMicrophoneAdapter } from "./adapters/microphone"; +import { roomsApi, navigate, roomUrl } from "./adapters/rooms"; +import { homeMachine } from "./machines/home"; +import { roomMachine } from "./machines/room"; +import { presentHome } from "./presenters/home"; +import { presentRoom, type RoomParameters } from "./presenters/room"; +import { HomeView } from "./views/home"; +import { RoomActionsView } from "./views/room"; import type { MicrophoneAdapter } from "./domain"; -import type { AudienceApi, FullscreenAdapter, OperatorApi, ViewEvent } from "./domain"; +import type { AudienceApi, FullscreenAdapter, OperatorApi, RoomBinding, RoomsApi, ViewEvent } from "./domain"; -export function OperatorRoot({ api = operatorApi, microphone }: { api?: OperatorApi; microphone?: MicrophoneAdapter }) { +interface RoomControls { + parameters: RoomParameters; + handle: (event: ViewEvent) => boolean; + onUnsavedChange: (unsaved: boolean) => void; +} + +export function OperatorRoot({ api = operatorApi, microphone, room, controls }: { api?: OperatorApi; microphone?: MicrophoneAdapter; room?: RoomBinding; controls?: RoomControls }) { const input = useMemo(() => microphone ?? createMicrophoneAdapter(), [microphone]); const actor = useActorRef(operatorMachine, { input: { api, microphone: input } }); - const parameters = useSelector(actor, presentOperator); + const parameters = useSelector(actor, snapshot => presentOperator(snapshot, room?.roomId)); useEffect(() => parameters.hasUnsavedChanges ? protectUnsavedChanges(window) : undefined, [parameters.hasUnsavedChanges]); + const onUnsavedChange = controls?.onUnsavedChange; + useEffect(() => { onUnsavedChange?.(parameters.hasUnsavedChanges); }, [onUnsavedChange, parameters.hasUnsavedChanges]); + const roomHandle = controls?.handle; const handle = useCallback((event: ViewEvent) => { + if (roomHandle?.(event)) return true; if (!actor.getSnapshot().can(event)) return false; actor.send(event); return true; - }, [actor]); + }, [actor, roomHandle]); useEffect(() => observeOperatorKeys(document, handle), [handle]); - return ; + return {controls ?
    : }
    ; +} + +export function HomeRoot({ api = roomsApi, go = navigate }: { api?: RoomsApi; go?: (path: string) => void }) { + const actor = useActorRef(homeMachine, { input: { api } }); + const parameters = useSelector(actor, presentHome); + useEffect(() => { if (parameters.destination) go(roomUrl(parameters.destination)); }, [go, parameters.destination]); + const handle = useCallback((event: ViewEvent) => { + if (!actor.getSnapshot().can(event)) return false; + actor.send(event); + return true; + }, [actor]); + return ; +} + +export function RoomRoot({ id, api = roomsApi, go = navigate, operator = createOperatorApi, microphone = createMicrophoneAdapter }: { + id: string; api?: RoomsApi; go?: (path: string) => void; + operator?: (room: RoomBinding) => OperatorApi; microphone?: (room: RoomBinding) => MicrophoneAdapter; +}) { + const actor = useActorRef(roomMachine, { input: { api, id } }); + const parameters = useSelector(actor, presentRoom); + const generation = parameters.room?.generation; + const binding = useMemo(() => ({ roomId: id, generation: generation ?? 0 }), [id, generation]); + const operatorAdapter = useMemo(() => operator(binding), [operator, binding]); + const microphoneAdapter = useMemo(() => microphone(binding), [microphone, binding]); + const handle = useCallback((event: ViewEvent) => { + if (!actor.getSnapshot().can(event)) return false; + actor.send(event); + return true; + }, [actor]); + const onUnsavedChange = useCallback((unsaved: boolean) => actor.send({ type: "room.unsaved.changed", unsaved }), [actor]); + useEffect(() => { if (parameters.destination) go("/"); }, [go, parameters.destination]); + if (parameters.showOperator) return ; + return
    ; } export function AudienceRoot({ api = audienceApi, fullscreen }: { api?: AudienceApi; fullscreen?: FullscreenAdapter }) { diff --git a/frontend/src/views/home.tsx b/frontend/src/views/home.tsx new file mode 100644 index 0000000..083039c --- /dev/null +++ b/frontend/src/views/home.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from "react"; +import { ArrowRight, Cable, LoaderCircle, Plus, Settings2 } from "lucide-react"; +import { Button } from "../components/ui/button"; +import { Form } from "../components/ui/form"; +import { Textarea } from "../components/ui/textarea"; +import { viewHierarchy } from "../events/hierarchy"; +import type { HomeParameters } from "../presenters/home"; + +function RoomFormView({ parameters }: { parameters: HomeParameters["form"] }) { + const name = useRef(null); + useEffect(() => { name.current?.focus(); }, [parameters.focusVersion]); + return + {emit =>
    +

    {parameters.title}

    +
    +
    + + emit({ type: "home.form.changed", field: "name", value: event.currentTarget.value })} /> + + + {parameters.showLink && <> + +