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 0000000..ae0d5dd Binary files /dev/null and b/docs/assets/manual/00-home.jpg differ diff --git a/docs/assets/manual/00-reset.jpg b/docs/assets/manual/00-reset.jpg new file mode 100644 index 0000000..18f8206 Binary files /dev/null and b/docs/assets/manual/00-reset.jpg differ diff --git a/docs/assets/manual/00-room-settings.jpg b/docs/assets/manual/00-room-settings.jpg new file mode 100644 index 0000000..15308ff Binary files /dev/null and b/docs/assets/manual/00-room-settings.jpg differ diff --git a/docs/current-behavior.md b/docs/current-behavior.md index 6dffb0e..ec84dad 100644 --- a/docs/current-behavior.md +++ b/docs/current-behavior.md @@ -1,12 +1,12 @@ # 現行 PoC の仕様 -実装確認日:2026-10-24 +実装確認日:2026-10-25 現在のコードとテストに対応する仕様。将来の機能・受け入れ条件は [v0.2 の設計案](design-v0.2.md)を参照する。起動方法は [README](../README.md)、責務の配置は[システム構成](../ARCHITECTURE.md)に記載する。 ## 提供範囲 -単一の `demo` セッションでUDトークまたはマイク認識の字幕を受け取り、AIと手動で訂正する。操作画面の接続設定で「独立配信」と「UDトークへ反映(試験接続)」を選ぶ。AIは初期値オフで、キーを設定した提供元へ次の確定字幕を送る。入力と同時、または後から届く修正結果を受け取るAPIも提供する。 +ホームからルームを選び、UDトークまたはマイク認識の字幕を受け取り、AIと手動で訂正する。同時に入力するのは一つのルームだけで、字幕はルームごとに分けて保持する。登録・再接続・字幕リセットは[ルームの接続と字幕リセット](rooms.md)を参照する。操作画面の接続設定で「独立配信」と「UDトークへ反映(試験接続)」を選ぶ。AIは初期値オフで、キーを設定した提供元へ次の確定字幕を送る。入力と同時、または後から届く修正結果を受け取るAPIも提供する。 独立配信ではZoom形式の送信を受けて、このアプリの観客画面へ配信する。観客画面は初期設定で公開字幕を追加しながら末尾へスクロールする。過去の公開字幕を読み返せるほか、表示設定で直近1〜10件だけの表示へ切り替えられる。従来のWeb閲覧入力も環境変数による任意の経路として残す。設定・根拠・実機確認の範囲は[UDトークの入力接続](udtalk-input.md)を参照する。 @@ -14,9 +14,9 @@ UDトークへ反映するモードでは編集用URLで参加し、確定字幕 形式が不正なリンクと閲覧専用リンクは、動作中の接続を止める前に拒否する。参加キーの有効性はUDトークの応答で判断する。共有情報の `sharingLimit` を端末時計と比較して接続を拒否しない。モード変更時は前の接続とAI処理を終了し、受信先の資格情報を更新する。接続の切り替え開始から完了までと、接続の終了中に届いた字幕では、新たなAI処理を予約せず原文を保持する。停止したWeb入力は接続状態を無効・受信数0として返す。履歴とブラウザの下書きは残すが、編集参加で受け取った字幕は独立配信へ切り替えても自動公開しない。現在の編集接続で受信した対象以外はUDトークへ送らない。画面の「受信中」は通信の応答を示し、音声認識や純正画面への表示を保証する状態ではない。 -独立配信の「送られる字幕」は初期値を「本文のみ」とする。「発話者名と本文」を選んだ場合だけUDトークの名前付き形式を分離する。本文中に続く区切りは保持する。この設定だけの変更では送信先・受信番号・既存字幕を保持し、適用後の新しい字幕から分離する。設定値は編集参加へ切り替えても接続状態で取得でき、独立配信へ戻る際も保持する。サーバー再起動で初期値へ戻る。書式の根拠と未確認事項は[発話者名の受信](udtalk-input.md#発話者名の受信)に記載する。 +独立配信の「送られる字幕」は初期値を「本文のみ」とする。「発話者名と本文」を選んだ場合だけUDトークの名前付き形式を分離する。本文中に続く区切りは保持する。この設定だけの変更では送信先・受信番号・既存字幕を保持し、適用後の新しい字幕から分離する。設定値は編集参加へ切り替えても接続状態で取得でき、独立配信へ戻る際も保持する。ルームの設定として保存し、サーバー再起動後も保持する。書式の根拠と未確認事項は[発話者名の受信](udtalk-input.md#発話者名の受信)に記載する。 -状態はサーバーのメモリ上に置く。認証と永続化はなく、ローカルの単一プロセスで使う。 +字幕はサーバーのメモリ上に置き、ルームの設定はファイルに保存する。アプリ全体の認証はなく、ローカルの単一プロセスで使う。 ## 操作と編集 @@ -72,7 +72,7 @@ SSE の切断中は直前の表示を保持し、再接続後のスナップシ 「AI設定」でGPT-6 Luna・GPT-6.1 Sol・Claude Haiku 5.5・Gemini 3.5 Flash-Liteから使うモデルを一つ選ぶ。「AIなし」で停止する。初期値はAIなし。保存した設定を次の確定入力から使い、振り分け先と依頼IDを字幕に記録する。キー設定済みなら提供元へ校正を依頼し、独立配信では公開方針に従って採用する。編集参加ではUDトークが訂正要求を受け付けた後に採用する。キー未設定・処理待ち・通信失敗を字幕の状態と訂正欄に表示し、失敗時は原文を保持する。 -AIへの同時要求は2件、待機を含む字幕数は最大64件。同じ発話の更新時とモード変更時は古い処理を終了する。料金比較・時間超過・結果の照合は[AIの選択と振り分け](ai-routing.md)を参照する。「トークの背景」と直前の確定字幕を文脈として渡す。背景は最大4,000文字、過去字幕は同じ接続の最大6件・合計3,000文字。背景内の命令には従わず、本文だけを校正する。AI設定と応答時間の記録は再起動で消える。 +AIへの同時要求は2件、待機を含む字幕数は最大64件。同じ発話の更新時とモード変更時は古い処理を終了する。料金比較・時間超過・結果の照合は[AIの選択と振り分け](ai-routing.md)を参照する。「トークの背景」と直前の確定字幕を文脈として渡す。背景は最大4,000文字、過去字幕は同じ接続の最大6件・合計3,000文字。背景内の命令には従わず、本文だけを校正する。AI設定はルームごとに保存する。応答時間の記録は再起動で消える。 ## 単語辞書 @@ -80,7 +80,7 @@ AIへの同時要求は2件、待機を含む字幕数は最大64件。同じ発 ## 別画面の表示設定 -操作画面の「表示設定」で背景色・文字色、端末標準のゴシック/明朝/等幅、文字サイズ24〜96px、太さ、行間、余白、縦位置、文字揃え、直近の発話数、画面サイズ/16:9を変更する。「表示設定を反映」でサーバーへ保存し、SSEで観客画面へ送る。ページを再読み込みしてもサーバーが動作している間は設定を保持し、再起動で初期値に戻る。 +操作画面の「表示設定」で背景色・文字色、端末標準のゴシック/明朝/等幅、文字サイズ24〜96px、太さ、行間、余白、縦位置、文字揃え、直近の発話数、画面サイズ/16:9を変更する。「表示設定を反映」でサーバーへ保存し、SSEで観客画面へ送る。設定はルームごとに保存し、ページの再読み込みとサーバーの再起動後も保持する。 発話者名は「名前の表示」で非表示・本文の前・本文の上を選べる。初期値は本文の上で、名前がない発話には名前の欄を出さない。本文の上では本文の62.5%の文字サイズを使う。名前の非表示では公開APIとSSEでも名前をnullにし、操作画面の入力名は保持する。 @@ -88,7 +88,7 @@ AIへの同時要求は2件、待機を含む字幕数は最大64件。同じ発 説明欄と設定グループはクリック、Enter、Spaceで開閉する。欄ごとの開閉状態は定期取得とパネルの切り替えでも保持し、画面の再読み込みで初期状態へ戻す。設定から字幕を選択する場合と「編集中の字幕へ」の場合は、起点の設定ボタンではなく訂正欄へフォーカスを移す。 -スクロール方式では公開字幕を履歴へ追加し、画面に収まらない古い字幕は上へ流れる。上へスクロールして過去の字幕を読んでいる間は、新着や訂正が届いても位置を戻さない。「最新へ」で末尾へ戻って追従を再開し、キーボードの操作先も字幕へ戻す。手動で末尾へ戻っても追従を再開する。表示方式の変更と非表示への切り替えでは読み返し状態を解除する。字幕や設定はサーバーのメモリ上にあり、再起動で消える。 +スクロール方式では公開字幕を履歴へ追加し、画面に収まらない古い字幕は上へ流れる。上へスクロールして過去の字幕を読んでいる間は、新着や訂正が届いても位置を戻さない。「最新へ」で末尾へ戻って追従を再開し、キーボードの操作先も字幕へ戻す。手動で末尾へ戻っても追従を再開する。表示方式の変更と非表示への切り替えでは読み返し状態を解除する。字幕はサーバーのメモリ上にあり、再起動で消える。表示設定はルームごとに保存する。 直近の字幕では設定した件数を超えると古い字幕から外す。追従中は新しい発話・本文の訂正・文字サイズや行間の変更・リサイズで末尾へ移る。同じ文章の発話が続いても最新へ追従する。プレビューは新着に追従し、内容が変わらない定期取得では手動スクロールを戻さない。 @@ -139,6 +139,7 @@ curl -X POST http://127.0.0.1:24681/api/source/events \ | 振る舞い | 検証先 | |---|---| +| ルームの追加・再接続・保存、字幕の分離とリセット、古いタブ・再送の拒否 | [ルームAPI](../backend/tests/test_rooms.py)、[ホームとルーム画面](../frontend/src/rooms.test.tsx) | | 下書き保持、保存失敗・競合、重複操作、古い取得の中断 | [operator.test.ts](../frontend/src/machines/operator.test.ts) | | 操作可否の描画への反映、空欄の検証 | [Presenter のテスト](../frontend/src/presenters/operator.test.ts) | | 共通部品を含むコンポーネント階層、Passive View の依存先・状態・通知の責務 | [構成の検査](../frontend/src/architecture.test.ts)、[階層のテスト](../frontend/src/events/hierarchy.test.tsx) | @@ -161,7 +162,7 @@ curl -X POST http://127.0.0.1:24681/api/source/events \ ## 未実装・未検証 - UDトークの対応端末・経路と実機で未確認の範囲は[入力接続](udtalk-input.md#検証と限界)を参照する。2026-10-24に合成音声の受信、手動訂正の純正アプリ・純正Web字幕への反映、別参加者のロックによる保存拒否と解除後の保存、GPT-5.4 Miniからの自動書き戻しを実接続で確認した。GPTの実接続とApple認識からGPT-5.4 Miniへの自動校正は[AIの確認記録](ai-routing.md#実接続での確認)を参照する。各AIの品質・速度の比較と請求確定額は未確認。 -- 認証、字幕状態の永続化、複数セッション・複数ワーカー、監査履歴・保存済みの修正の取消、取り下げた字幕の再公開。 +- 認証、字幕状態の永続化、複数ルームへの同時入力・複数ワーカー、監査履歴・保存済みの修正の取消、取り下げた字幕の再公開。 - 手修正 API の冪等キー、ブラウザとAPI間の応答が失われた保存の再照合、純正表示に到達したことの自動確認。UDトーク側で成否不明になった訂正は、後続の受信で自分の送信ID・本文・編集回数が一致する通知を照合して取り込む。 - 会場リハーサル、60分を超える運転・負荷試験、自前音声入力の長時間運転、会場での物理マイク確認。UDトーク経由の60分・120回は[合成音声で確認済み](udtalk-input.md#翻訳と連続運転の確認)。OpenAIの実接続は[合成音声で確認済み](microphone-input.md#確認済みの範囲)。 - 日本語入力ソフトを使った IME の実機試験。テストでのキーイベント確認だけでは完了としない。 diff --git a/docs/development.md b/docs/development.md index 2f131c5..685bf45 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # 開発手順 -設定確認日:2026-10-11 +設定確認日:2026-10-25 リポジトリのルートで以下のコマンドを実行する。Codex Cloud と SSH 接続の固有手順は[リモート開発](remote-development.md)を参照する。 @@ -24,7 +24,7 @@ bash scripts/dev.sh [setup.sh](../scripts/setup.sh) は Node.js の要件を確認し、`backend/uv.lock` と `frontend/package-lock.json` から依存関係を導入して画面をビルドする。対応MacではmacOS 26 SDKでApple認識のSwift補助プログラムもビルドする。SDKがない環境やLinuxではAppleの準備を省略する。依存関係が変わった場合も再実行する。 -[dev.sh](../scripts/dev.sh) は FastAPI を `127.0.0.1:24681` で起動し、`frontend/dist/` を配信する。操作画面は `/`、観客画面は `/view/demo`。バックエンドの変更は自動で読み込む。画面を変更した場合は `npm --prefix frontend run build` で再ビルドする。 +[dev.sh](../scripts/dev.sh) は FastAPI を `127.0.0.1:24681` で起動し、`frontend/dist/` を配信する。ホームは `/`、接続後の操作画面は `/rooms/{room_id}`。観客画面は操作画面のモニターアイコンから開く。バックエンドの変更は自動で読み込む。画面を変更した場合は `npm --prefix frontend run build` で再ビルドする。 フロントエンドを変更しながら確認する場合は、バックエンドを起動したまま別のターミナルで実行する。 @@ -32,7 +32,7 @@ bash scripts/dev.sh npm --prefix frontend run dev ``` -操作画面は 、観客画面は 。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 && <> + +