diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cb09eb0..0cc1ee3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -87,6 +87,8 @@ SSE は購読登録と初期スナップショットの取得を中断なしに [API の型生成](docs/generated/README.md)でバックエンドのモデルから OpenAPI と TypeScript 型を生成する。フロントエンドの Adapter は生成した型を使用し、API モデルを独自に再定義しない。 +ルーム名とトークの背景の上限はOpenAPIから [form-limits.ts](frontend/src/generated/form-limits.ts) へ生成する。Home MediatorとOperator MediatorがUnicodeコードポイントで保存前に検査し、上限超過時も下書きを保持する。背景の検査は共通の `backgroundValidation` を使う。Viewは入力を途中で切らず、担当するフォーム内に検証結果を表示する。APIのPydantic検証も保持する。 + ## 辞書の制限と検証 入力上限は [models.py](backend/app/models.py) の `DICTIONARY_LIMITS` に置き、Pydanticモデルと、[生成した辞書規則](frontend/src/generated/dictionary-rules.ts)で共有する。`check:api` が上限値とUnicode case foldingの一致を検査する。APIの型は従来どおりOpenAPIから生成し、画面の下書きや操作イベントとは分ける。 diff --git a/backend/tests/test_rooms.py b/backend/tests/test_rooms.py index 73f8f2d..72576ac 100644 --- a/backend/tests/test_rooms.py +++ b/backend/tests/test_rooms.py @@ -33,6 +33,17 @@ def receive(client, room, text, identifier="same"): return response.json() +def test_room_name_and_background_use_unicode_character_limits_and_keep_saved_settings(): + name, background = "𠮷" * 120, "𠮷" * 4000 + with TestClient(create_app(environment={})) as client: + room = create_room(client, name, background) + for invalid_name, invalid_background in ((name + "𠮷", background), (name, background + "𠮷")): + response = client.put(f"/api/rooms/{room['id']}", json={"name": invalid_name, "ai": {"background": invalid_background}}) + assert response.status_code == 422 + saved = client.get(f"/api/rooms/{room['id']}").json() + assert saved["name"] == name and saved["background"] == background + + 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, "作家へのインタビュー", "写真と布の展示") diff --git a/docs/ai-routing.md b/docs/ai-routing.md index 15300b7..a66cf5a 100644 --- a/docs/ai-routing.md +++ b/docs/ai-routing.md @@ -74,7 +74,7 @@ Geminiは[generateContent](https://ai.google.dev/api/generate-content)、Claude ## トークの背景と直近の字幕 -「トークの背景」にはテーマ、登壇者、作品名などを4,000文字まで入力できる。保存後の確定字幕から使い、既存の字幕を再校正しない。背景はルームのAI設定として保存し、再起動後も保持する。直近の字幕はメモリ上にあり、再起動で消える。 +「トークの背景」にはテーマ、登壇者、作品名などを4,000文字まで入力できる。「𠮷」などの補助漢字も1文字と数える。上限を超えた下書きは保持し、保存前にエラーを表示する。保存後の確定字幕から使い、既存の字幕を再校正しない。背景はルームのAI設定として保存し、再起動後も保持する。直近の字幕はメモリ上にあり、再起動で消える。 各要求では、その時点の背景、単語辞書、同じ接続の直前の確定字幕を取得する。直近の字幕は採用済みの表示文を古い順で最大6件・合計3,000文字まで渡し、文の途中では切らない。対象自身・後続字幕・未確定字幕・以前の接続の履歴は混ぜない。接続先や入力元を変えた場合は履歴の範囲をリセットし、同じ入力の送信形式だけを変えた場合は保つ。 diff --git a/docs/assets/unicode-form-limits-2026-10-25/after-publication.png b/docs/assets/unicode-form-limits-2026-10-25/after-publication.png new file mode 100644 index 0000000..48a2216 Binary files /dev/null and b/docs/assets/unicode-form-limits-2026-10-25/after-publication.png differ diff --git a/docs/assets/unicode-form-limits-2026-10-25/ai-background-corrected.png b/docs/assets/unicode-form-limits-2026-10-25/ai-background-corrected.png new file mode 100644 index 0000000..f670fff Binary files /dev/null and b/docs/assets/unicode-form-limits-2026-10-25/ai-background-corrected.png differ diff --git a/docs/assets/unicode-form-limits-2026-10-25/ai-background-error.png b/docs/assets/unicode-form-limits-2026-10-25/ai-background-error.png new file mode 100644 index 0000000..c4d2269 Binary files /dev/null and b/docs/assets/unicode-form-limits-2026-10-25/ai-background-error.png differ diff --git a/docs/assets/unicode-form-limits-2026-10-25/ai-background-saved.png b/docs/assets/unicode-form-limits-2026-10-25/ai-background-saved.png new file mode 100644 index 0000000..2511258 Binary files /dev/null and b/docs/assets/unicode-form-limits-2026-10-25/ai-background-saved.png differ diff --git a/docs/assets/unicode-form-limits-2026-10-25/before-publication.png b/docs/assets/unicode-form-limits-2026-10-25/before-publication.png new file mode 100644 index 0000000..44a2076 Binary files /dev/null and b/docs/assets/unicode-form-limits-2026-10-25/before-publication.png differ diff --git a/docs/assets/unicode-form-limits-2026-10-25/home-background-error.png b/docs/assets/unicode-form-limits-2026-10-25/home-background-error.png new file mode 100644 index 0000000..3d23694 Binary files /dev/null and b/docs/assets/unicode-form-limits-2026-10-25/home-background-error.png differ diff --git a/docs/assets/unicode-form-limits-2026-10-25/home-mobile-error.png b/docs/assets/unicode-form-limits-2026-10-25/home-mobile-error.png new file mode 100644 index 0000000..429c0a0 Binary files /dev/null and b/docs/assets/unicode-form-limits-2026-10-25/home-mobile-error.png differ diff --git a/docs/assets/unicode-form-limits-2026-10-25/home-name-error.png b/docs/assets/unicode-form-limits-2026-10-25/home-name-error.png new file mode 100644 index 0000000..b3aa44a Binary files /dev/null and b/docs/assets/unicode-form-limits-2026-10-25/home-name-error.png differ diff --git a/docs/assets/unicode-form-limits-2026-10-25/verification.json b/docs/assets/unicode-form-limits-2026-10-25/verification.json new file mode 100644 index 0000000..0777686 --- /dev/null +++ b/docs/assets/unicode-form-limits-2026-10-25/verification.json @@ -0,0 +1,48 @@ +{ + "checked_at": "2026-10-25T22:16:23.909517+09:00", + "environment": "隔離した実FastAPIと背景の実ブラウザ。架空の文字列のみ使用。音声・外部AIなし。", + "browser": { + "room_name_limit": 120, + "background_limit": 4000, + "supplementary_background_utf16_length": 8000, + "oversized_name_retained": 121, + "oversized_background_retained": 4001, + "invalid_submit_blocked": true, + "keyboard_submit_blocked": true, + "ai_off_invalid_draft_retained": true, + "corrected_background": "短い背景", + "input_focus_retained": true, + "ai_off_background_hidden_only_after_save": true, + "cancelled_form_discarded": true, + "existing_room_reconnected": true, + "mobile_viewport_width": 390, + "mobile_document_width": 390, + "console_errors": 0 + }, + "api": { + "oversized_room_statuses": [ + 422, + 422 + ], + "oversized_ai_status": 422, + "saved_settings_preserved": true, + "saved_strategy": "off", + "saved_name_characters": 120, + "public_captions_before_save": 0, + "public_captions_after_save": 1, + "published_text": "展示の新しい見方を、一緒に探してみましょう。", + "human_edited": true + }, + "checks": { + "command": "bash scripts/check.sh", + "node": "22.23.4", + "frontend_passed": 145, + "backend_passed": 196, + "pyright_errors": 0 + }, + "audit": { + "separate_session": true, + "p0_p1_findings": 0 + }, + "audio_started": false +} diff --git a/docs/exec-plans/active/2026-10-25-unicode-form-limits.md b/docs/exec-plans/active/2026-10-25-unicode-form-limits.md deleted file mode 100644 index 5f636ae..0000000 --- a/docs/exec-plans/active/2026-10-25-unicode-form-limits.md +++ /dev/null @@ -1,39 +0,0 @@ -# ルーム名と背景の文字数をAPIへそろえる - -## Goal - -ルーム名とトークの背景でも、辞書と同じくUnicodeコードポイントで上限を判定する。 - -## Context - -#21のNode更新はPR #34でマージ済み。#19の辞書の制限値はバックエンドから生成する。調査ではhome.tsxのルーム名maxLength=120、背景maxLength=4000、ai-settings.tsxの背景maxLength=4000にも同じUTF-16による切り詰めが残っている。APIのPydanticはUnicode文字数で判定する。 - -## Scope and Non-Goals - -上記3入力の文字数、上限超過時の下書き保持と保存拒否、制限値の生成を対象にする。ルーム切替、API形式、認識Adapter、AIの要求内容、公開方針は変えない。 - -## Constraints From Decisions - -- 既存の未コミット6項目と別作業ツリーを保持する。 -- Viewは描画と通知のみ。入力検証はRoot/Mediatorなど既存の担当層へ置く。 -- 生成値はPydanticのOpenAPIから作り、手で生成物を編集しない。 -- 物理マイクと実音声サービスは使用しない。 - -## Remaining Work - -1. 3入力の所有者と保存前の検査を確認し、補助漢字の切り詰めを再現する。 -2. OpenAPIの上限値を生成し、入力の保存判定をUnicodeへそろえる。 -3. 実APIとブラウザで上限以内の保存と上限超過時の保持を確認する。 -4. 全チェックと別セッション監査、PR・CI・マージを完了する。 - -## Now And Next - -homeのRootとOperator Mediatorの保存処理を読み、既存の辞書の修正と同じ考え方で回帰テストを追加する。 - -## Blockers And Human Tasks - -なし。 - -## Verification - -ルーム名120文字、背景4000文字の補助漢字を入力でき、APIが受け付ける。超過時は明確なエラーを示し、下書きと保存済み設定を保持する。API型と既存のルーム保護を維持する。 diff --git a/docs/generated/README.md b/docs/generated/README.md index dbba51a..d44750a 100644 --- a/docs/generated/README.md +++ b/docs/generated/README.md @@ -8,6 +8,7 @@ API モデルを変更するときに参照する。生成の流れは次のと | OpenAPI の生成 | [scripts/export_openapi.py](../../scripts/export_openapi.py) → [openapi.json](openapi.json) | | TypeScript の生成 | `openapi-typescript` → [frontend/src/generated/api.d.ts](../../frontend/src/generated/api.d.ts) | | 辞書の入力制限と文字変換表 | [scripts/export_openapi.py](../../scripts/export_openapi.py) → [dictionary-rules.ts](../../frontend/src/generated/dictionary-rules.ts)。Pydanticモデルと同じ上限値、サーバーのPythonと同じUnicode case foldingを使う | +| ルーム名・背景の入力制限 | OpenAPI → [form-limits.ts](../../frontend/src/generated/form-limits.ts)。画面の保存前検査でAPIと同じ文字数を使う | 入力、操作画面用字幕、観客用字幕、セッション、表示切り替えの型をフロントエンドへ渡す。公開モデルの境界は[システム構成](../../ARCHITECTURE.md#api-と公開データの境界)を参照する。 diff --git a/docs/index.md b/docs/index.md index 76c5a6c..ee4e2c1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ | UDトークへ訂正を戻す接続を調べる | [編集通信の解析](research/udtalk-editor-protocol.md)・[公開資料](research/udtalk-editor-protocol-sources.md) | 純正編集アプリの要求形式、オフライン実行結果、復旧後の実反映と未確認事項 | | AIを選び、速度と費用を比較する | [AIの選択と振り分け](ai-routing.md) | GPT-6 Luna・GPT-6.1 Sol・Claude・Gemini、文脈と料金の根拠、振り分けと実接続の確認 | | 固有名詞・専門用語を登録する | [単語辞書](dictionary.md) | 表記・読み・誤認識例、認識と校正への反映、保存先 | -| API モデルを変更する | [API の型生成](generated/README.md) | 生成元、再生成、型の一致確認 | +| API モデルを変更する | [API の型生成](generated/README.md) | 生成元、再生成、型と入力制限の一致確認 | | 新しい機能の要件を検討する | [v0.2 の設計案](design-v0.2.md) | 将来要件、未確定事項、受け入れ条件の案 | | マイクから文字起こしする | [マイク入力](microphone-input.md) | OpenAI・Appleの準備、開始・停止、試験上限、確認範囲 | | クラウド音声認識を比較する | [クラウド候補の調査](research/2026-10-10-cloud-transcription.md) | Google・Azure・AmiVoice・AWS などの日本語対応、途中結果、料金、データの扱い | diff --git a/docs/rooms.md b/docs/rooms.md index bd75a5a..6e9da2f 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -8,6 +8,8 @@ 2. ルーム名と配信先を選びます。「UDトークへ反映」では編集用の参加リンク、「独立配信」では字幕の入力元を指定します。必要ならトークの背景を記入します。 3. 「登録して接続」を押します。登録済みのルームは一覧の「接続」から開けます。設定アイコンから名前・接続先・背景を変更できます。 +ルーム名は120文字、トークの背景は4,000文字まで入力できます。「𠮷」などの補助漢字も1文字と数えます。上限を超えた場合は入力内容を残し、保存前にエラーを表示します。 + 字幕入力の接続は同時に一つです。別のルームへ接続すると、前のルームの入力と処理中のAI校正を終了します。ルームを切り替えても、それまで受け取った字幕はルームごとに保持します。ホームへ戻る操作でも、現在の入力を終了します。 形式が不正なリンクと閲覧専用リンクは、接続を切り替える前に拒否します。保存済みの編集用リンクは画面へ返しません。接続先を変えるときだけ、新しいリンクを入力します。参加キーの有効性はUDトークの応答で判断します。 diff --git a/frontend/src/ai.ts b/frontend/src/ai.ts index 428f1bc..7e61912 100644 --- a/frontend/src/ai.ts +++ b/frontend/src/ai.ts @@ -1,3 +1,4 @@ +import { formLimits } from "./generated/form-limits"; import type { AiRouting, AiSettings } from "./domain"; export const defaultAiSettings: AiSettings = { @@ -22,3 +23,8 @@ export function editableAiSettings(ai: AiRouting): AiSettings { export function knownAiModel(value: string): value is AiSettings["preferred_model"] { return defaultAiSettings.candidates.some(model => model === value); } + +export function backgroundValidation(value: string): string { + return Array.from(value).length > formLimits.background + ? `トークの背景は${formLimits.background.toLocaleString("ja-JP")}文字以内で入力してください。` : ""; +} diff --git a/frontend/src/generated/form-limits.ts b/frontend/src/generated/form-limits.ts new file mode 100644 index 0000000..edec763 --- /dev/null +++ b/frontend/src/generated/form-limits.ts @@ -0,0 +1,2 @@ +// Generated from OpenAPI by scripts/export_openapi.py; do not edit. +export const formLimits = {"roomName":120,"background":4000} as const; diff --git a/frontend/src/machines/home.ts b/frontend/src/machines/home.ts index c932386..ce5bf49 100644 --- a/frontend/src/machines/home.ts +++ b/frontend/src/machines/home.ts @@ -1,5 +1,6 @@ +import { formLimits } from "../generated/form-limits"; import { assign, fromPromise, setup, type SnapshotFrom } from "xstate"; -import { defaultAiSettings } from "../ai"; +import { backgroundValidation, defaultAiSettings } from "../ai"; import { OperationError, type AiSettings, type HomeEvent, type RoomCatalog, type RoomDetails, type RoomsApi, type ViewEvent } from "../domain"; interface Form { @@ -28,6 +29,11 @@ const emptyForm = (): Form => ({ name: "", mode: "client", inputSource: "udtalk" 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); +const roomFormValidation = (form: Form) => Array.from(form.name.trim()).length > formLimits.roomName + ? `ルーム名は${formLimits.roomName}文字以内で入力してください。` : backgroundValidation(form.ai.background); +const canSubmitRoom = (context: Context) => context.formOpen && Boolean(context.form.name.trim()) + && !roomFormValidation(context.form) && (context.form.mode === "host" || Boolean(context.form.editorUrl.trim()) || context.form.storedLink); + export const homeMachine = setup({ types: {} as { context: Context; events: ViewEvent; input: { api: RoomsApi } }, actors: { @@ -40,9 +46,9 @@ export const homeMachine = setup({ }, 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), + validForm: ({ context }) => canSubmitRoom(context), 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), + && canSubmitRoom(context), }, actions: { newForm: assign(({ context }) => ({ form: emptyForm(), formOpen: true, editingRoomId: null, message: "", focusVersion: context.focusVersion + 1 })), @@ -54,7 +60,7 @@ export const homeMachine = setup({ 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: "" }; + return { form, message: roomFormValidation(form) }; }), requestRoom: assign(({ event }) => "roomId" in event ? { requestedRoomId: event.roomId, message: "" } : {}), }, diff --git a/frontend/src/machines/operator.ts b/frontend/src/machines/operator.ts index b5b347c..6392a70 100644 --- a/frontend/src/machines/operator.ts +++ b/frontend/src/machines/operator.ts @@ -3,7 +3,7 @@ import { microphoneActor } from "./microphone"; import type { MicrophoneAdapter, MicrophoneDevice, TranscriptionStatus } from "../domain"; import { CaptionConflict, OperationError, disclosureIds, type DisclosureId, type ConnectionSettings, type ConnectionStatus, type AiRouting, type AiSettings, type Caption, type DisplaySettings, type Draft, type OperatorApi, type ViewEvent, type PublicationMode, type SaveRequest, type StatusTooltipGeometry } from "../domain"; import { changeDisplayField, defaultDisplay, selectDisplayPalette } from "../display"; -import { editableAiSettings, defaultAi, knownAiModel } from "../ai"; +import { backgroundValidation, editableAiSettings, defaultAi, knownAiModel } from "../ai"; import { defaultConnection } from "../connection"; import { dictionaryLimits, dictionaryUpdate, dictionaryValidation, draftDictionary, emptyDictionary, type DictionaryDraft } from "../dictionary"; import type { DictionarySnapshot, DictionaryUpdate } from "../domain"; @@ -62,6 +62,8 @@ interface OperatorContext { displayDraft: DisplaySettings | null; ai: AiRouting; aiDraft: AiSettings | null; + aiError: string; + aiBackgroundExpanded: boolean; dictionary: DictionarySnapshot; dictionaryDraft: DictionaryDraft | null; dictionaryError: string; @@ -161,7 +163,10 @@ const operations = { "display.palette.selected": { guard: "hostMode", actions: "editDisplay" }, "ai.background.changed": { actions: "editAi" }, "ai.model.selected": { guard: "validAiModel", actions: "editAi" }, - "ai.save.requested": { target: "settingAi", actions: "clearError" }, + "ai.save.requested": [ + { guard: "validAiSettings", target: "settingAi", actions: "clearError" }, + { actions: "invalidAi" }, + ], "dictionary.word.added": { guard: "canAddWord", actions: "addWord" }, "dictionary.word.changed": { guard: "knownWord", actions: "editWord" }, "dictionary.word.enabled": { guard: "knownWord", actions: "editWord" }, @@ -202,6 +207,7 @@ export const operatorMachine = setup({ connection: fromPromise(async ({ input, signal }: { input: { api: OperatorApi; settings: ConnectionSettings }; signal: AbortSignal }) => input.api.setConnection(input.settings, signal)), }, guards: { + validAiSettings: ({ context }) => !backgroundValidation((context.aiDraft ?? context.ai.settings).background), canAddWord: ({ context }) => (context.dictionaryDraft ?? draftDictionary(context.dictionary)).entries.length < dictionaryLimits.maxEntries, knownWord: ({ context, event }) => (event.type === "dictionary.word.changed" || event.type === "dictionary.word.enabled" || event.type === "dictionary.word.removed") && Number.isInteger(event.index) && event.index >= 0 && event.index < (context.dictionaryDraft ?? draftDictionary(context.dictionary)).entries.length, @@ -270,6 +276,7 @@ export const operatorMachine = setup({ && context.pendingSave?.captionId !== event.captionId, }, actions: { + invalidAi: assign(({ context }) => ({ aiError: backgroundValidation((context.aiDraft ?? context.ai.settings).background) })), discardDictionary: assign({ dictionaryDraft: null, dictionaryError: "", dictionaryNotice: "保存済みの辞書に戻しました。" }), clearDictionaryMessage: assign({ dictionaryError: "", dictionaryNotice: "" }), addWord: assign(({ context }) => { @@ -340,10 +347,10 @@ export const operatorMachine = setup({ }), editAi: assign(({ context, event }) => { const settings = context.aiDraft ?? editableAiSettings(context.ai); - if (event.type === "ai.background.changed") return { aiDraft: { ...settings, background: event.value } }; + if (event.type === "ai.background.changed") return { aiError: "", aiBackgroundExpanded: context.aiBackgroundExpanded || Boolean(backgroundValidation(event.value)), aiDraft: { ...settings, background: event.value } }; if (event.type === "ai.model.selected") { - if (event.modelId === "off") return { aiDraft: { ...settings, strategy: "off" as const } }; - if (knownAiModel(event.modelId)) return { aiDraft: { ...settings, strategy: "manual" as const, + if (event.modelId === "off") return { aiError: "", aiDraft: { ...settings, strategy: "off" as const } }; + if (knownAiModel(event.modelId)) return { aiError: "", aiDraft: { ...settings, strategy: "manual" as const, preferred_model: event.modelId, candidates: [event.modelId] } }; } return {}; @@ -413,7 +420,7 @@ export const operatorMachine = setup({ openedDisclosures: new Set(["display-speaker", "display-appearance"]), requestedMode: { auto_publish: true }, display: defaultDisplay, displayDraft: null, - ai: defaultAi, aiDraft: null, + ai: defaultAi, aiDraft: null, aiError: "", aiBackgroundExpanded: false, dictionary: emptyDictionary, dictionaryDraft: null, dictionaryError: "", dictionaryNotice: "", connection: defaultConnection, receiverUrl: "", connectionDraft: null, }), @@ -608,8 +615,8 @@ export const operatorMachine = setup({ settingAi: { invoke: { src: "ai", input: ({ context }) => ({ api: context.api, settings: context.aiDraft ?? context.ai.settings }), - onDone: { target: "idle", actions: assign(({ event }) => ({ ai: event.output, aiDraft: null })) }, - onError: { target: "idle", actions: assign({ error: "AI設定を保存できませんでした。設定内容は保持しています。", errorSource: "command" }) }, + onDone: { target: "idle", actions: assign(({ event }) => ({ ai: event.output, aiDraft: null, aiError: "", aiBackgroundExpanded: false })) }, + onError: { target: "idle", actions: assign({ aiError: "AI設定を保存できませんでした。設定内容は保持しています。" }) }, }, }, settingDictionary: { diff --git a/frontend/src/presenters/home.ts b/frontend/src/presenters/home.ts index 33d8243..079ff66 100644 --- a/frontend/src/presenters/home.ts +++ b/frontend/src/presenters/home.ts @@ -8,8 +8,9 @@ export function presentHome(snapshot: HomeSnapshot) { 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, + message: context.formOpen ? "" : context.message, destination: context.destination, formOpen: context.formOpen, form: { + message: context.message, name: context.form.name, background: context.form.ai.background, mode: context.form.mode, inputSource: context.form.inputSource, editorUrl: context.form.editorUrl, title: context.editingRoomId ? "ルームの接続設定" : "新しいルームに接続", diff --git a/frontend/src/presenters/operator.ts b/frontend/src/presenters/operator.ts index 74033a4..8de6167 100644 --- a/frontend/src/presenters/operator.ts +++ b/frontend/src/presenters/operator.ts @@ -87,6 +87,7 @@ export interface ToolbarParameters { } export interface AiParameters { + error: string; background: string; helpDisclosure: DisclosureParameters; comparisonDisclosure: DisclosureParameters; @@ -400,11 +401,12 @@ export function presentOperator(snapshot: OperatorSnapshot, roomId?: string): Op : "UDトークからの入力ではAI校正で参照します。自前の音声入力では認識にも使います。", }, ai: { + error: context.aiError, background: aiDraft.background, helpDisclosure: presentDisclosure("ai-help", context.openedDisclosures), comparisonDisclosure: presentDisclosure("ai-comparison", context.openedDisclosures), connectionWarning: context.ai.settings.strategy !== "off" && !context.ai.connected, - showModelSettings: aiDraft.strategy !== "off", + showModelSettings: aiDraft.strategy !== "off" || context.aiBackgroundExpanded, connectionDescription: context.ai.settings.strategy === "off" ? "AIはオフです。原文を受け取り、手動で訂正できます。" : context.ai.connected ? "確定した字幕を選択したAIへ送って訂正します。API利用料がかかります。" : "選択したAIのAPIキーが未設定です。サーバーにキーを設定して再起動してください。", diff --git a/frontend/src/rooms.test.tsx b/frontend/src/rooms.test.tsx index d44a842..5885098 100644 --- a/frontend/src/rooms.test.tsx +++ b/frontend/src/rooms.test.tsx @@ -66,6 +66,37 @@ it("registers a room from the form once and connects before navigating", async ( expect(api.connect).toHaveBeenCalledWith(room.id, expect.any(AbortSignal)); }); +it("saves supplementary Unicode at the room limits and retains oversized form input", async () => { + const api = rooms(); + render(); + const user = userEvent.setup(); + await screen.findByText(room.name); + await user.click(screen.getByRole("button", { name: "新しいルームに接続" })); + await user.selectOptions(screen.getByLabelText("配信先"), "host"); + const name = screen.getByRole("textbox", { name: "ルーム名" }); + const background = screen.getByRole("textbox", { name: "トークの背景" }); + const nameText = "𠮷".repeat(120); + const backgroundText = "𠮷".repeat(4000); + await user.click(name); await user.paste(nameText); + expect(Array.from(name.value)).toHaveLength(120); + await user.click(background); await user.paste(backgroundText); + expect(Array.from(background.value)).toHaveLength(4000); + fireEvent.change(name, { target: { value: nameText + "𠮷" } }); + expect(screen.getByRole("alert").textContent).toContain("120文字以内"); + fireEvent.submit(name.closest("form")!); + expect(api.save).not.toHaveBeenCalled(); + expect(Array.from(name.value)).toHaveLength(121); + fireEvent.change(name, { target: { value: nameText } }); + fireEvent.change(background, { target: { value: backgroundText + "𠮷" } }); + await user.keyboard("{Control>}{Enter}{/Control}"); + expect(screen.getByRole("alert").textContent).toContain("4,000文字以内"); + expect(api.save).not.toHaveBeenCalled(); + expect(Array.from(background.value)).toHaveLength(4001); + fireEvent.change(background, { target: { value: backgroundText } }); + await user.click(screen.getByRole("button", { name: "登録して接続" })); + await waitFor(() => expect(api.save).toHaveBeenCalledWith(expect.objectContaining({ name: nameText, ai: expect.objectContaining({ background: backgroundText }) }), null, 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("編集用の参加リンクを確認してください。")); diff --git a/frontend/src/roots.test.tsx b/frontend/src/roots.test.tsx index de345d5..e1c315b 100644 --- a/frontend/src/roots.test.tsx +++ b/frontend/src/roots.test.tsx @@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testi import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { AudienceRoot, OperatorRoot } from "./roots"; -import type { AudienceEvent, Caption, OperatorApi, Snapshot } from "./domain"; +import type { AiRouting, AudienceEvent, Caption, OperatorApi, Snapshot } from "./domain"; import { defaultDisplay } from "./display"; import { defaultConnection } from "./connection"; import { viewHierarchy } from "./events/hierarchy"; @@ -636,6 +636,7 @@ it("routes fullscreen controls through the audience Mediator and releases both s }); const ai: AiParameters = { + error: "", background: "", helpDisclosure: { id: "ai-help", open: false }, comparisonDisclosure: { id: "ai-comparison", open: false }, connectionWarning: true, showModelSettings: true, @@ -768,6 +769,44 @@ it("selects a model directly and saves background after toggling AI off", async await waitFor(() => expect(api.setAi).toHaveBeenCalledWith(expect.objectContaining({ strategy: "manual", preferred_model: "claude-haiku-5-5", candidates: ["claude-haiku-5-5"], background: "写真と布の展示" }), expect.any(AbortSignal))); }); +it("saves supplementary Unicode background and retains an oversized AI-off draft", async () => { + let routing: AiRouting = { ...defaultAi, settings: { ...defaultAi.settings, strategy: "manual", preferred_model: "gemini-3.5-flash-lite" }, selected_model: "gemini-3.5-flash-lite", + models: ai.models.map(model => ({ id: model.id, label: model.label, provider: model.provider === "Gemini" ? "gemini" : "claude", + input_usd_per_million: 1, output_usd_per_million: 1, pricing_url: model.pricingUrl, pricing_checked_on: model.checkedOn, configured: false, latency_samples: 0 })), + }; + const api: OperatorApi = { + load: vi.fn().mockImplementation(() => Promise.resolve({ visible: true, captions: [], ai: routing })), + save: vi.fn(), setVisible: vi.fn(), sendTest: vi.fn(), setPublicationMode: vi.fn(), setDisplay: vi.fn(), setConnection: vi.fn(), setDictionary: vi.fn(), + setAi: vi.fn().mockImplementation(settings => { + routing = { ...routing, settings, selected_model: settings.strategy === "off" ? null : settings.preferred_model }; + return Promise.resolve(routing); + }), + }; + render(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "AI設定" })); + const background = screen.getByRole("textbox", { name: "トークの背景" }); + const text = "𠮷".repeat(4000); + await user.click(background); await user.paste(text); + expect(Array.from(background.value)).toHaveLength(4000); + await user.click(screen.getByRole("button", { name: "AI設定を保存" })); + await waitFor(() => expect(api.setAi).toHaveBeenCalledWith(expect.objectContaining({ background: text }), expect.any(AbortSignal))); + fireEvent.change(background, { target: { value: text + "𠮷" } }); + await user.selectOptions(screen.getByRole("combobox", { name: "モデル" }), "off"); + expect(screen.getByRole("textbox", { name: "トークの背景" })).toBe(background); + await user.click(screen.getByRole("button", { name: "AI設定を保存" })); + expect(api.setAi).toHaveBeenCalledTimes(1); + expect(Array.from(background.value)).toHaveLength(4001); + expect(screen.getByRole("alert").textContent).toContain("4,000文字以内"); + await user.clear(background); + expect(screen.getByRole("textbox", { name: "トークの背景" })).toBe(background); + await user.type(background, "短い背景"); + expect(document.activeElement).toBe(background); + await user.click(screen.getByRole("button", { name: "AI設定を保存" })); + await waitFor(() => expect(api.setAi).toHaveBeenLastCalledWith(expect.objectContaining({ background: "短い背景", strategy: "off" }), expect.any(AbortSignal))); + await waitFor(() => expect(screen.queryByRole("textbox", { name: "トークの背景" })).toBeNull()); +}); + const toolbar: ToolbarParameters & { preparationHint: string } = { audienceHref: "/view/demo", helpDisclosure: { id: "publication-help", open: false }, preparationDisclosure: { id: "preparation", open: false }, diff --git a/frontend/src/views/home.tsx b/frontend/src/views/home.tsx index 083039c..a0febc4 100644 --- a/frontend/src/views/home.tsx +++ b/frontend/src/views/home.tsx @@ -15,7 +15,7 @@ function RoomFormView({ parameters }: { parameters: HomeParameters["form"] }) {
- emit({ type: "home.form.changed", field: "name", value: event.currentTarget.value })} /> } -