# NAI SDKからAI向け画像生成クライアントを自作する設計書 作成日: 2026-11-03。対象: Python 3.12 / Windows x64を主な実行環境とする、GUIなしの単発画像生成端末。 配布されたEXEを起動する前に、何を送信し、どこへ保存するプログラムなのか自分で確かめたい。 その場合、公開SDKから小さなクライアントを組み立て、ソースのまま使う方法がある。 必要なのは、プロンプトの受信、入力の検証、1回のAPI呼出し、画像の返却をつなぐ処理である。 一方、キャラDBや独自のランダム構文まで同じ動作にするには、SDKの外側に構文処理を実装する必要がある。 この設計書には、自分のPython環境で実行できる最小実装の全ソースと、DB・変数・選択式などを追加するための仕様を収録した。 最小実装はNAIDesktopのコードや配布済みEXEを読み込まない。キャラDB、テンプレート、画像素材、APIキーも含まない。 ## 目次 1. 構築するものと完成条件 2. SDK・依存関係・調査の基準 3. 最小実装を動かす手順 4. 全体構成と責務の分け方 5. CLI・標準入出力・JSONの契約 6. ホスト設定とAPIキー 7. SDKアダプターとモデル別の注意点 8. 単発生成・WebP保存・画像の返却 9. 生成の排他制御 10. 独自構文の字句解析と構文木 11. キャラDB・要素グループ・一時値 12. ワイルドカード 13. 選択式・変数・連動抽選 14. CHAR・システム命令・参照素材 15. プレビューと生成を一致させる方法 16. SSH・Tailscale・AI側クライアント 17. 失敗・タイムアウト・再試行 18. テスト仕様と受入条件 19. 自分で行うEXE化・ソース配布 20. 段階別の実装順序と検証記録 21. 公開資料と読み合わせ 付録A. 実行可能な最小実装の全ソース ## 1. 構築するものと完成条件 ホスト側でNAI SDKを実行する。AIが別端末にいる場合はSSHでホストのコマンドを起動し、JSONを標準入力へ渡す。 画像はWebPに変換し、ホスト上のファイルパス、JSON内のBase64、標準出力へのバイト列のいずれかで返す。 GUIも追加のWebサーバーも必要ない。 ### 最小実装と拡張後の違い |機能|付録の最小実装|拡張後の設計| |---|---|---| |SDKによる単発生成|対応|対応| |UTF-8 JSON・CLI・rpc|対応|対応| |WebP・Base64・binary|対応|対応| |同時生成の拒否|対応|対応| |ホスト側のキー設定・上限|対応|対応| |通常の直接プロンプト|対応|対応| |NAIの`1.5::tag::`|そのまま送信|そのまま送信| |V4.5 Full|対応|対応| |V5 Full|未対応|7章のアダプターを追加| |キャラDB・要素グループ|未対応|11章の仕様で追加| |独自の選択式・変数・ワイルドカード|未対応として拒否|10~13章の仕様で追加| |CHAR・精密参照・Vibe・座標|未対応として拒否|14章の仕様で追加| |usage照会|未対応|7章の読取専用操作を追加| |総当たり・バッチ・img2img・inpaint|提供しない|提供しない| 最小実装は「全機能が完成した互換クライアント」ではない。 まず通常プロンプトの1枚生成を独立して動かすための完成した土台であり、未実装の構文を黙ってNAIへ送らずエラーにする。 拡張を追加するたび、describeの対応機能、schema、マニュアル、テストも同じ段階へ更新する。 最低限の完成条件は、キーなしでvalidateが通ること、1要求の画像数が常に1であること、 生成中の別要求がAPIを呼ばず拒否されること、画像とJSONが混ざらないこと、キーが返却データへ入らないことである。 実APIの受理や実際の消費量は、オフライン検証とは別に確認する。 ## 2. SDK・依存関係・調査の基準 利用するのはcaru-iniの`novelai-sdk`である。Pythonのimport名は`novelai`。 このSDKはNovelAI運営の公式SDKではなく、第三者が公開している非公式クライアントである。 公開元は[SDKリポジトリ](https://github.com/caru-ini/novelai-sdk)と[SDKサイト](https://caru-ini.github.io/novelai-sdk/)。 この設計の固定対象は次のとおり。「常に最新版」という意味ではない。 |依存|検証した版|役割| |---|---|---| |Python|3.12|実行環境| |novelai-sdk|0.14.1|認証付きNAI API呼出し、SDKパラメータ、画像の取得| |Pillow|12.3.0|画像とWebPエンコード| |Pydantic|2.13.4|入力と設定の型検証| |PyInstaller(任意)|6.21.0|自分でEXEを作る場合のみ| SDKの依存先にはhttpx、numpy、python-dotenv、richなどもある。上の3パッケージを固定しても、 推移的な依存をすべて固定したことにはならない。再現する環境を残す場合は、解決後の全パッケージとwheelのハッシュも保存する。 SDKの通常の流れは`NovelAI`を作り、`GenerateImageParams`を作り、`client.image.generate(params)`を1回呼ぶ形である。 戻り値はPillow画像のリストになる。[SDKの導入例](https://caru-ini.github.io/novelai-sdk/docs/getting-started/)でもこの公開インターフェースが使われている。 SDKの公開資料だけで判断できない互換処理は、手元の0.14.1の型定義と実装も確認した。 V5のタグや保存済みVibeなど、実APIとの一致をこの設計書のオフライン試験だけで保証していないものは、後述のとおり区別して扱う。 ## 3. 最小実装を動かす手順 このフォルダを好きな場所に置く。ファイルは次のとおり。 ```text sdk-nai-client/ BUILD_FROM_SDK.md この設計書。末尾に最小実装の全ソース reference_client.py 付録Aと同じコード test_reference_client.py requirements.txt config.example.json request.example.json muse_nai.py 監査できるSSH側のソース SYNTAX.md 拡張後の構文リファレンス ``` 配布済みのコードも使いたくない場合は、付録Aを自分で読み、reference_client.pyを作成する。 同じSDK呼出しを行う別の実装に書き直してもよい。配布済みEXEを実行する工程はない。 ### 3.1 環境を作る WindowsでPython 3.12が導入済みの場合: ```powershell py -3.12 -m venv .venv .\.venv\Scripts\python.exe -m pip install -r requirements.txt .\.venv\Scripts\python.exe -m pip check Copy-Item config.example.json config.json ``` requirements.txtは次の3行である。 ```text novelai-sdk==0.14.1 Pillow==12.3.0 pydantic==2.13.4 ``` 仮想環境のactivateは必須ではない。コマンドで仮想環境のPythonを明示すれば、 PowerShellのスクリプト実行ポリシーを変更する必要もない。 Python・SDKのimportとWebP対応を確認する。 ```powershell .\.venv\Scripts\python.exe -c "from importlib.metadata import version; from PIL import features; print(version('novelai-sdk')); print(features.check('webp'))" .\.venv\Scripts\python.exe reference_client.py describe .\.venv\Scripts\python.exe reference_client.py schema ``` describeの`application_syntax`がfalseなのは、この最小実装では正しい。 ### 3.2 生成APIを呼ばずに動かす ```powershell .\.venv\Scripts\python.exe reference_client.py validate --request request.example.json .\.venv\Scripts\python.exe -m unittest discover -s . -p test_reference_client.py -v ``` この段階ではキーを設定しなくてよい。validateはSDKパラメータの作成までで止まり、 `NovelAI`クライアントを作らず、通信しない。 同梱テストの画像生成はFakeClientに置き換え、実際のNAIへ送信しない。 ### 3.3 利用者がキーを設定する ホスト側にsecretsフォルダを作り、利用者自身がapi-key.txtを作成してキーを1行で保存する。 コマンド例やソースに実キーを書かない。代わりに`NAI_API_KEY`環境変数を利用してもよい。 このアプリは、その値を読み取ってSDKへ明示的に渡す。 SDK自身の既定の変数名`NOVELAI_API_KEY`とは異なるので混同しない。 SDK側の認証方式は[認証の公開資料](https://caru-ini.github.io/novelai-sdk/docs/authentication/)で確認できる。 SSHセッションで環境変数を使う場合は、接続先プロセスからその変数が見える必要がある。 自分のGUIセッションで設定しただけでは、新しいSSHセッションに反映されない場合がある。 ### 3.4 自分で実API試験を行う 利用者が生成と利用枠の消費を了承した後、次を1回実行する。 ```powershell .\.venv\Scripts\python.exe reference_client.py generate --request request.example.json --response path ``` 成功時はoutputsへWebPとJSONが保存され、stdoutに成功JSONが1行出る。 この設計書を作成した環境では実キーを使う試験は実施していない。 失敗した場合も自動で再実行せず、17章の方法で状況を確かめる。 ### 3.5 解決済みの依存を残す 新しい仮想環境を構築した直後に、全依存を記録してwheelを確保できる。 ```powershell .\.venv\Scripts\python.exe -m pip freeze --all > requirements.lock.txt .\.venv\Scripts\python.exe -m pip download --only-binary=:all: -r requirements.lock.txt -d wheels Get-ChildItem wheels -File | Get-FileHash -Algorithm SHA256 | Format-Table ``` Python版・CPU・OSも記録する。別のプラットフォームで同じwheelを使えるとは限らない。 ハッシュを保存しただけではpipが照合する設定にはならない。 `--require-hashes`を使う場合は、全依存を`==`で固定し、各行へ対応する`--hash=sha256:...`を記載したrequirementsファイルを作る。 [pipのSecure installs](https://pip.pypa.io/en/stable/topics/secure-installs/)が、その形式と制約を説明している。 ## 4. 全体構成と責務の分け方 拡張版は次のモジュールへ分けると、構文の試験とネットワークの試験を独立して進められる。 ```text client/ cli.py 引数、stdin、JSON、終了コード contracts.py Request / Settings / HostConfig / Issue service.py 1要求の流れ、ロック、生成、保存 prompt/ scanner.py エスケープ・括弧・位置情報 parser.py 構文木 evaluator.py 変数・抽選・展開上限 directives.py res/ref/vibe/pos data/ characters.py JSON DB、IDの索引 wildcards.py txtの候補、循環検出 references.py ファイル名の解決 sdk_adapter.py SDKの版に依存する変換 lease.py OS排他 outputs.py WebP、SHA-256、保存 ssh_client.py 別端末で使う場合だけ ``` 依存方向はCLI → Service → Compiler / Repository / Adapter / Store。 CompilerとRepositoryからSDKクライアントやCLIへ戻らない。 SDKの版変更で触る場所をsdk_adapter.pyに閉じ込める。 ```mermaid flowchart LR A[AIのrequest.json] --> B[SSHまたはCLI] B --> C[入力の型検証] C --> D[構文解析・DB・抽選] D --> E[解決済みの計画1件] E --> F[上限とSDKパラメータ検証] F --> G[生成APIを1回呼ぶ] G --> H[WebP・メタデータ] H --> I[JSONまたは画像バイト列] ``` 実際のgenerateでは、入力サイズの制限とJSON読込の後、計画作成より前にロックを取得する。 validateはロックなしで計画を作るが、SDK通信へ進まない。 ## 5. CLI・標準入出力・JSONの契約 ### 5.1 操作 最小実装のdescribe、schema、status、validate、generate、rpcを先に完成させる。 DB拡張後にcharacters、character、groups、wildcards、references、vibesを追加する。 usageはアカウントの照会だけを行い、生成のロックを取得しない設計にする。 |操作|入力|成功時|通信| |---|---|---|---| |describe|なし|機能と対応版のJSON|なし| |schema|なし|JSON Schema|なし| |status|なし|busyと上限|なし| |validate|request|解決済みprompt・設定・seed・issues|なし| |generate|request|画像または結果JSON|生成API| |usage(拡張)|なし|利用枠などのJSON|照会API| |catalog操作(拡張)|検索語・ID・model|一覧または1件|なし| stdoutは結果のために使い、進捗のprintを混ぜない。ログが必要ならstderrまたはホスト側ログへ出す。 SDKや他ライブラリの例外をそのままJSONへ変換しない。HTTPのヘッダーなどを含む可能性がある。 ### 5.2 最小実装のrequest ```json { "prompt": "landscape, sunny sky", "negative_prompt": "blurry", "settings": {"seed": 12345, "width": 832, "height": 1216, "steps": 28} } ``` 最小実装はprompt、negative_prompt、settingsだけを受け取る。 charactersなどの未実装キーはextra=forbidで拒否する。 settingsも任意辞書をそのままSDKへ渡さず、公開したフィールドだけを独自モデルで受け取る。 ### 5.3 拡張版のrequest ```json { "prompt": "@char1, @char1.hair, 「smile|serious」", "negative_prompt": "@char1, blurry", "characters": ["実際に登録済みのID"], "temporary_values": ["white ribbon"], "random_seed": 12345, "settings": {"model": "nai-diffusion-4-5-full", "seed": 12345} } ``` charactersは1画像内のスロットで、行の配列ではない。最大6件。 temporary_valuesは最大32件。random_seedは構文の抽選、settings.seedは画像用。 本文の長さはPositive/Negative各65,536文字、JSON全体は256KiBなど、上限を決めてから読み込む。 ### 5.4 受信時の検証 1. stdinやファイルから上限+1バイトだけ読む。超過したら打ち切る。 2. UTF-8として読む。BOMを許すならutf-8-sigを使う。 3. 重複JSONキーをobject_pairs_hookで検出する。 4. NaN、Infinity、-Infinityをparse_constantで拒否する。 5. 最上位がobjectであることを確認する。 6. Pydanticのstrict=True、extra=forbid、allow_inf_nan=Falseで型検証する。 7. 診断ではfieldとmessageを返し、入力値そのものは返さない。 boolはPythonではintの一種だが、steps=trueを1として扱わない。 文字列の"832"も暗黙に整数へ変換しない。 未知のn_samples、batch、mode、api_key、output_directoryをリクエストで受け取らない。 ### 5.5 rpc リモートシェルへ渡す起動文字列を固定したい場合、`program rpc`を実行して次をstdinへ送る。 ```json {"command":"generate","request":{"prompt":"landscape"}} ``` rpcのgenerateはBase64付きJSONで返す。binaryをrpcの任意キーで選択できるようにはしない。 最小実装はcommand/requestだけを受け取り、commandごとにrequestが必要かどうかを確認する。 拡張版のcatalogならmodel/query/idなどのキーを操作別に許可する。 未対応の操作はエラーとし、文字列を関数名にしてglobals()などから呼び出さない。 ## 6. ホスト設定とAPIキー config.jsonは所有者が管理する。requestから変更できる生成設定とは分離する。 ```json { "api_key_file": "secrets/api-key.txt", "output_directory": "outputs", "max_pixels": 1048576, "max_steps": 28, "timeout_seconds": 120, "webp_quality": 85, "webp_lossless": false, "defaults": {"model":"nai-diffusion-4-5-full","quality":false} } ``` `defaults → request.settings`の順に辞書をマージしてから型検証する。 ただしmax_pixels、max_steps、キーの場所、出力先、APIの送信先は上書き対象にしない。 最小実装ではAPIの送信先を`https://image.novelai.net`に固定している。 相対パスはconfig.jsonの親を基準にresolveする。 実行時のカレントディレクトリを基準にすると、SSHから起動した場合に保存先が変わってしまう。 自作EXEで可変設定を置く場所はsys.executableの親であり、PyInstallerが内部ファイルを展開する場所ではない。 キーの優先順はNAI_API_KEY環境変数、設定済みファイルの順。 ファイルはUTF-8で読み、外側の空白と改行だけ除く。空なら終了コード4。 AIへ返すstatusはキーの値を出さず、設定の有無だけにする。 設定済みという表示は、キーが有効・契約が有効という保証とは分ける。 SDKへは`NovelAI(api_key=key, image_base=BASE_URL, timeout=...)`と明示する。 SDKが対応する別名の環境変数を暗黙に読んだ結果、意図しないキーやAPI先へ切り替わることを避けるためである。 所有者のSSH権限で動くプログラムなので、SSHアカウント自体の権限とキーのファイル権限は所有者が管理する。 ## 7. SDKアダプターとモデル別の注意点 ### 7.1 最小実装の境界 ```python with NovelAI(api_key=key, image_base=BASE_URL, timeout=120) as client: images = client.image.generate(params) ``` クライアントはcontext managerで閉じる。画像も保存・変換後にcloseする。 validateではGenerateImageParamsの作成までにし、上記のブロックへ入らない。 保存済みVibeの扱いを追加した後も、検証の途中でencode APIを呼ばない。 必ずn_samples=1をアダプター自身が設定し、戻り値の長さも1を確認する。 画像リストをforで全部保存するだけの構成にすると、SDK設定の漏れを検出できない。 ### 7.2 パラメータの対応 |独自設定|SDKへ渡す値| |---|---| |width / height|size=(width, height)| |seed|確定した整数。0も有効| |prompt / negative_prompt|展開済みテキスト| |steps / scale / sampler / noise_schedule|型検証後の値| |quality / uc_preset|公開した設定だけ| |cfg_rescale|0~1| |生成件数|アダプター内でn_samples=1に固定| `seed or random_seed`と書くとseed=0を失う。`seed if seed is not None else ...`を使う。 SDKの型で通ることと、特定のモデル・samplerの組合せを実APIが受理することは別である。 ### 7.3 uc_preset=none 最小実装ではnoneを公開していない。SDK 0.14.1の低レベルモデルでは、 noneがAPI値4になる一方でucPresetの型制限と衝突する経路があるためである。 互換性が必要なら、次の処理をアダプター1箇所へ限定して実装する。 ```text high-level paramsを作成 none以外: params.to_api_request(client) none: uc_preset=human_focusへ一時変更したコピーをto_api_requestへ渡す 得られたrequest.parameters.ucPresetを4へ設定 negative_promptは元params.processed_negative_promptへ戻す v4_negative_promptがあれば、そのbase_captionも同じ値へ戻す 最後にclient.api_client.image.generate(request)を1回だけ呼ぶ ``` これはSDKの低レベルAPIに依存する。NoneというPython値と文字列"none"は区別する。 更新時は型定義・生成JSON・テストを確認し、不要になった回避処理を漫然と残さない。 ### 7.4 V5対応を追加する 最初はSDKの高レベル対応範囲だけで動かし、Web UIと同じ自動タグを付けたい場合だけ追加処理を入れる。 既存端末ではV5のquality有効時に`, very aesthetic, amazing quality, no text`を明示的に加え、 SDKのqualityをfalseにして二重の品質タグを避けている。 variety_boostはV5では送らずfalseとする。 透明背景の指定を追加する場合、SDKのstraight_alphaとtag_hint_transparent_backgroundを混同しない。 既存端末では前者をV5の出力処理として有効にし、利用者の透明背景指定は後者へ対応させている。 tag_hint_qtやtag_hint_uc_presetを追加する経路も低レベルAPIへの依存なので、型の存在を確認する。 これらは既存端末の互換方針であり、将来のWeb UIや全モデルについての公式保証ではない。 V5用の精密参照やVibeをV4.5と同じペイロードのまま有効にしない。 検証が済むまで未対応として拒否または明示的に無効化し、describeへ反映する。 ### 7.5 アカウントの照会 拡張のusageは`client.user.get_subscription()`を用いて、anlas、active、tierを必要な形へ変換する。 SDKの戻り値にusageがない場合はnullで返す。欠けた値を0%と解釈しない。 Usage表示を作るならpercent、isNegative、timeUntilNextPercentの型と有限性を確認する。 同じ値がないモデルへ無理に変換しない。 利用枠照会は生成APIを呼ばず、生成ロックも取得しない。 ## 8. 単発生成・WebP保存・画像の返却 generateの処理順を固定する。 ```text JSON読込 → 排他ロック取得 → 入力型検証 → 構文展開 → 最終解像度・stepsの上限確認 → SDKパラメータ作成 → ホストのキー読込 → SDKクライアント作成 → 生成APIを1回だけ呼ぶ → 画像数1を確認 → WebPへエンコード → 保存または返却 → ロック解放 ``` WebPはPillow画像をBytesIOへ保存する。RGBAを無条件にRGBへ変えず、透過を保持する。 qualityやlosslessはホスト設定にし、画像バイト列からSHA-256を計算する。 Pillow側のWebP保存オプションは[画像形式の資料](https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp)で確認できる。 ```python buffer = io.BytesIO() image.save(buffer, format="WEBP", quality=85, lossless=False) blob = buffer.getvalue() digest = hashlib.sha256(blob).hexdigest() ``` ファイル名はUUIDなどの要求ごとに一意なIDにする。利用者入力のpromptやIDをそのままパスへ使わない。 `open('xb')`で新規作成し、既存画像を上書きしない。 生成後に保存に失敗してもAPIを再度呼ばない。保存の失敗と生成の失敗を分ける。 |response|stdout|ホストへの保存| |---|---|---| |path|結果JSON|WebPとJSON| |base64|結果JSONとimage_base64|WebPとJSON| |binary|WebPだけ|最小実装では保存しない| JSONにはid、image_path、metadata_path、mime_type、bytes、sha256、seed、解決済みprompt、issuesを含める。 Base64はメタデータJSONそのものへ保存せず、返却するJSONだけへ追加する。 画像の長さNに対しBase64はおよそ4/3へ増えるので、巨大画像向けにはbinaryやSFTPを選べるようにする。 ホスト側の生成記録にはプロンプトが含まれる。秘密情報であるAPIキーとは別だが、利用者の私的なデータとして管理する。 配布用ZIPへoutputsや生成記録を取り込まない。 保存の耐障害性を上げる場合は、画像と記録を同じIDの一時ファイルへ書き、flush後に最終名へrenameする。 画像だけ存在する状態と、記録まで保存済みの状態は区別する。 複数ファイルのrenameは1つの原子的処理ではないので、「2ファイルが必ず同時に現れる」とは扱わない。 ## 9. 生成の排他制御 ### 9.1 共有するロックの仕様 同じWindowsユーザーで既存の対応版NAIDesktopと併用する場合は、次のファイルを共有する。 ```text %LOCALAPPDATA%/NAIDesktop/generation-v1.lock ``` Windowsではファイル位置0から1バイトを`msvcrt.locking(fd, LK_NBLCK, 1)`で非待機ロックする。 取得できなければbusyを返し、その要求のAPIを呼ばない。 LK_LOCKは待機や再試行を行うため、即時拒否する用途ではLK_NBLCKを選ぶ。 この違いは[Pythonのmsvcrt資料](https://docs.python.org/3/library/msvcrt.html#msvcrt.locking)にも記載されている。 ```text 親フォルダを作成 append+binaryで開く。既存ファイルをtruncateしない 空なら1バイトを書いてflush seek(0) LK_NBLCKで1バイトを取得 try: 1要求の全処理 finally: ファイルをclose ``` ロックファイルは削除しない。存在することと取得中であることは別である。 PIDだけを書いて「プロセスがいるか」を見る方式では、同時起動や異常終了に対応しにくい。 ファイルハンドルのcloseとプロセス終了でOSがロックを解放する方式にする。 openやフォルダ作成の権限エラーは設定エラーであり、他の生成がbusyという意味ではない。 statusは一度取得してすぐ解放し、瞬間的な利用可否を表示するだけにする。 ### 9.2 NAIDesktop側が必要とする処理 バッチの各API呼出しだけでなく、バッチ実行関数全体を同じロックで囲む。 API間隔、再試行待ち、1枚ずつの許可待ちでも保持し、一時停止して実行関数が戻ると解放する。 GUIの更新処理がこのロックを取得しない旧版なら、CLIが勝手にその生成を検出できるわけではない。 旧版との併用は排他保証の対象外と明示する。 別PCや別Windowsユーザーとは、このファイルロックを共有しない。 その範囲まで共通化する場合は、生成専用のホスト1台へ要求を集めるか、別の共有調停サービスを設計する。 単にキーが同じという理由で別PCのロック状態を推測しない。 ## 10. 独自構文の字句解析と構文木 SDKは`「a|b」`、`@char1`、`$「scene」`を解釈する機能ではない。 これらを使うには、送信前に1本の通常プロンプトへ変換するコンパイラーが必要になる。 総当たりを持つバッチエンジンを呼び、その先頭だけ採用する実装は避ける。 組合せを列挙した時点でメモリや処理時間を消費し、単発という契約も曖昧になる。 ### 10.1 処理の段階 ```text 生入力のコメント処理 → DBスロット・一時値の解決 → ワイルドカードの候補読込 → 変数定義と選択式をASTへ変換 → 全ASTを走査し、禁止モード(!,+)を拒否 → 変数依存と循環の検証 → 1つのRandomインスタンスで抽選 → CHAR集計・システム命令抽出 → 改行・空白の正規化 → 最終上限確認 ``` ASTはAbstract Syntax Tree、つまり文字列を意味のある要素へ分けた構造である。 すべてを正規表現の置換だけで処理すると、入れ子、エスケープ、選ばれない枝、変数内の命令を扱いにくい。 正規表現はトークンの入口検出に使い、括弧の対応はスタックで処理する。 ### 10.2 ノードの例 ```python @dataclass(frozen=True) class Span: start: int end: int source: str @dataclass(frozen=True) class Text: value: str span: Span @dataclass(frozen=True) class Alternative: weight: int children: tuple @dataclass(frozen=True) class Choice: alternatives: tuple[Alternative, ...] count: int mode: str span: Span ``` ほかにVariableDefinition、VariableReference、WildcardReference、CharacterReference、 CharBoundary、Directiveを用意する。位置情報を変換後の文字列だけから計算し直さず、入力元のSpanを保持する。 DBから展開した場合は、入力の@char位置とDBファイル・要素名の両方を診断へ載せられるとよい。 ### 10.3 スキャナー 1. バックスラッシュの次の文字は、その段階の構造記号として扱わない。 2. `(`と`「`でstackへ開始位置を積む。 3. `)`と`」`で種類を照合し、対応しない閉じや未閉じをエラーにする。 4. `|`は現在の括弧階層に所属させる。 5. その括弧直下に有効な`|`がある場合だけChoiceとして解釈する。 6. `special week (umamusume)`のような通常の括弧はTextとして維持する。 7. エスケープを元へ戻すのは最後。途中で戻すと文字の`\|`が選択肢の区切りへ化ける。 `::`はNAIの記法として保持し、独自構文の区切りに使わない。 先頭の整数+単一コロンを重みとみなし、`1.5::tag::`や`artist:name`を誤って重みへ変換しない。 ### 10.4 禁止と上限 !と+は、抽選の後ではなくASTの全走査で拒否する。 選ばれない候補、重み0の候補、未参照の変数定義、DBやワイルドカード由来の候補にも適用する。 DB・ワイルドカードをText置換した後の文字列も検査するか、読込時に必ずAST化する。 実装上限の初期値を明示する。例として、構文の深さ64、読込候補総文字数131,072、 展開後の長さ131,072、ワイルドカード参照256回などを定める。 無限ループを止めるだけでなく、二重の参照で指数的に文字数が増える場合も、ノード連結や評価の途中で上限を確認する。 ## 11. キャラDB・要素グループ・一時値 ### 11.1 ファイル構造 ```text data/ model_modes/ v4_5/ characters/ 初期状態は空 character_element_groups.json 任意。なくてもよい references/ 初期状態は空 v5/ characters/ references/ wildcards/ vibes/ ``` 配布時は空にする。入力例のために既存ユーザーのDBやテンプレートをコピーしない。 所有者が追加した後に初めてcatalogへ現れる設計にする。 キャラ1件のJSON形式を次のように定める。これは説明用であり、実データとして同梱する必要はない。 ```json { "id": "example_id", "prompt": "original character, short hair", "negative": "", "elements": {"hair": "short hair", "summer": "white shirt"}, "references": [{"file":"portrait","default":true,"strength":1.0,"fidelity":0.85}] } ``` ### 11.2 索引 JSONファイルを名前順で読み、1件objectまたは配列のどちらを許可するか仕様に書く。 IDをcasefoldした辞書を作り、要求ごとに大きなDBを検索し直さない。 重複IDは新規自作版ではエラーにするのが明確である。 既存DBの先勝ち動作まで互換にしたい場合は別のモードとし、重複の警告を返す。 DBのIDはファイルパスへ直結させない。読込済みの索引から取得する。 同じ要求内ではDBのスナップショットを固定し、prompt解決と参照画像決定の途中で読込み直さない。 ### 11.3 スロットの解決 characters=[A,B]なら@char1がA、@char2がB。 Positiveの@charNはprompt、Negativeの@charNはnegative。 @charN.hairはそのキャラのelements内のhairをcasefoldで探す。 不明ID・未指定スロットはエラー。要素キーがなければ警告+空文字とする。 空文字の値が登録されている場合とキー自体がない場合は区別する。 空値は所有者の明示した候補、キーなしは候補不足である。 使用したキャラのID一覧と使用したスロットを別に保持する。配列に登録されただけの未使用IDから参照画像を自動選択しない。 ### 11.4 一時値 temporary_values=[X,Y]ならtemp1がX、temp2がY。 @char1.temp1と@char2.temp1は同じ行共通の値を返す。 対応列がない場合はエラー。空文字が明示されていれば空として使う。 tempNは予約名にし、DB要素やグループ名との衝突を禁止する。 ### 11.5 要素グループ ```json {"groups":{"outfit":["summer","winter"]}} ``` @char1.outfitはグループ構成要素のうち、そのキャラにキーが存在するものだけを候補にする。 存在するが空文字の要素は候補として残す。候補が0件ならエラー。 グループ名と通常要素名が衝突した場合は、暗黙にどちらかを採用せずエラーにする。 @char1.!outfitは単発端末では拒否する。 候補を単純な`(a|b)`文字列へ組み直す場合は、候補内のパイプが新しい外側候補へ分裂しないようにする。 ASTで候補ノードを保てば、外側の候補境界と内側の選択式を混同しにくい。 ## 12. ワイルドカード `__name__`はdata/wildcards/name.txtを参照する。名前は大文字小文字を区別しない索引で探す。 `../`や絶対パスを名前として受けず、許可文字を英数字・Unicode文字・_・-などに限定する。 ```text #! lines smile serious ``` ```text #! blocks school uniform, white shirt --- winter coat, red scarf ``` linesは1行1候補、blocksは`---`の独立行で区切る複数行候補。 宣言省略時はblocks。空候補の扱いと#コメント行の扱いをparserのテストに固定する。 候補内の選択式・変数・命令は、通常の構文として引き続き評価する。 入れ子の読込みはスタックを使う。 ```text expand(name, stack): key = casefold(name) keyがstack内にあれば、循環経路を返してエラー 候補を全件読み込んで構文解析 各候補の禁止モードと読込上限を検査 内部のwildcardをexpand(child, stack + key) 候補境界を保持したChoiceを返す ``` 結果を選択してから循環や!を検査するだけでは、選ばれなかった枝が検証されない。 候補の解析と検証を先に行い、抽選は後にする。 `__CHAR__`は別の構成人数プレースホルダーなので、wildcards/CHAR.txtより先に判定する。 ## 13. 選択式・変数・連動抽選 ### 13.1 選択式 ```text 「a|b|c」 1候補 「1:a|3:b|c」 重み1:3:1 「2~a|b|c」 重複せず2候補を抽選してカンマ結合 ``` 重みは0以上の整数だけ。省略時1。全候補0、負数、小数はエラー。 0重みの候補は実際の抽選対象から外すが、その候補内の禁止モードは除去前に検査する。 複数選択は、有効候補数以上を要求したらエラー。 重み付きの重複なし抽選を再現するには、候補を1つ選ぶたびに候補リストから除き、 残った重みで次を選ぶ。単純なrandom.sampleでは重みが反映されない。 ```python remaining = list(enumerate(alternatives)) selected = [] for _ in range(count): weights = [item.weight for _, item in remaining] picked = rng.choices(range(len(remaining)), weights=weights, k=1)[0] selected.append(remaining.pop(picked)) ``` rngは要求ごとに`random.Random(random_seed)`を1つ作る。 モジュール全体のrandom.seedを変更して別要求の結果へ影響させない。 評価順はPositiveの左から右、続いてNegativeと決め、関数内の辞書順に依存させない。 ### 13.2 変数 ```text $「scene=「classroom|beach|bedroom」」 $「scene.light=「sunlight|sunset|dim light」」 $「scene」, $「scene.light」 ``` 定義を抽出し、定義自身を送信本文から取り除く。 名前をcasefoldして重複を検出する。未定義の参照と循環参照はエラー。 Negativeでの新規定義は許可せず、Positiveの定義だけを共有する。 定義値は初回に1度だけ評価してキャッシュし、同じ変数参照が別の抽選結果にならないようにする。 連動変数scene.lightは、基準変数sceneで最初に選んだ候補番号を共有する。 sceneの有効候補が3つ、lightが2つなら`base_index % 2`で選び、候補数の不一致を警告する。 基準の候補番号は0重み候補を除いた並びで定義し、テストでも明示する。 基準変数が単一値の場合は番号0を使う。 ```text resolve(name): キャッシュがあれば返す resolving集合にあれば循環エラー 定義がなければ未定義エラー resolvingへ追加 子参照を先にresolve 基準変数なら値を評価し、値と最初の候補番号を保存 連動変数なら基準をresolveし、共有番号で候補を選択 resolvingから除き、キャッシュした値を返す ``` 未参照の定義は抽選しなくてもよい。ただし構文の正当性と禁止モードは解析時に検査する。 この違いにより、未使用変数がrngを消費して本来の抽選結果を変える問題を避けられる。 ## 14. CHAR・システム命令・参照素材 ### 14.1 CHARとSDKのCharacter ```text __CHAR__, outdoors CHAR: 1girl, short hair CHAR: 1boy, long coat ``` 行頭の独立したCHAR:で本文を分ける。最初がメイン、以後がキャラセクション。 Negative側も同じ順序で分け、足りないキャラnegativeは空とする。 SDKへはCharacter(prompt=..., negative_prompt=..., position=...)の配列で渡す。 座標未指定時のpositionはNoneにする。中心(0.5,0.5)を既定で全員へ与えない。 __CHAR__または**CHAR**は、各セクション先頭のカンマ区切りタグから人数を集計する。 先頭の1girlや1boyの数値を除いた分類を数え、上の例なら1girl,1boyになる。 命令を取り除いてから先頭タグを判定するか、CHAR構造を先に確定して両者が干渉しないようにする。 ### 14.2 命令の適用 |命令|処理| |---|---| |[res:832x1216]|最終解像度を上書き| |[ref:name strength=1 fidelity=0.85]|登録済み精密参照を選択| |[ref:none]|自動参照も含めて無効化| |[vibe:name] または複数名|保存済みVibeを選択| |[pos:(0.2,0.5),(0.8,0.5)]|順番に座標を割当| |[pos:char2=(0.8,0.5)]|指定キャラだけ座標を上書き| |[mask:...]|単発テキスト生成では未対応として拒否| Positiveの展開後を左から読み、同種の命令は最後の指定を採用する。 命令は送信文字列から除去する。未知の角括弧はNAIの構文として残す。 resの上書き後にも64の倍数、縦横の範囲、面積、ホストの上限を確認する。 posは0~1の有限値だけ。指定したchar番号が存在しない場合は警告して無視する。 1人でも明示座標を持つ場合にだけ座標モードを有効にする。 SDK 0.14.1はV4/V4.5の座標をグリッドへ合わせる動作も持つため、 生の座標と送信用の値が必ず同一とは限らない。アダプターの試験で確認する。 ### 14.3 参照画像 参照名は一覧から解決し、任意の絶対パスをリクエストへ許可しない。 候補パスをresolveした後、参照ディレクトリの配下であることとファイル存在を検証する。 同じ拡張子なし名が複数あれば曖昧としてエラー。 SDKではCharacterReference(image=path, type="character", strength=..., fidelity=...)を使える。 DB既定参照、明示ref、ref:noneの優先順は、ref:none → 明示ref → 使用中キャラの既定参照とする。 複数キャラ呼出しと精密参照を併用しない契約にする場合、送信前にエラーにする。 精密参照が有効ならVibeを外し、その理由を警告へ残す。 ### 14.4 保存済みVibeとSDKの制限 SDK 0.14.1のControlNetImage.from_fileは、手元の実装ではNotImplementedErrorである。 メソッド名があることだけを根拠に、ポーションを読めると判断しない。 ControlNetImage.encode_vibeはキャッシュがなければencode APIを呼ぶ。 保存済みエンコードだけを使う契約なら、ポーションJSONからモデルに一致するencodingを読み、 information_extractedとstrengthを検証し、既存エンコードをSDKへ渡すアダプターを用意する。 既存端末はSDKの`_vibe_data`へ保存済みencodingを設定する方式を使っている。 これはprivate属性に依存するため、版固定・属性存在確認・送信JSONのオフライン試験を必須にする。 ```text payload.encodings["v4-5full"]から候補を取得 importInfo.information_extractedに一致する候補を選択 encodingが空でない文字列であることを確認 strength / information_extractedをSDKの範囲で検証 ControlNetImage(image=b"", ...)を作成し、保存済みencodingをキャッシュへ設定 ControlNet(images=[...], strength=1.0)へまとめる ``` このprivate依存を採用したくない場合、Vibeを未対応と宣言するか、 公開SDKが保存済みエンコードを受け取れるようになった版を確認してから対応する。 毎回自動encodeする実装へ置き換えると、呼出し回数や消費の契約が変わる。 ## 15. プレビューと生成を一致させる方法 最小実装のvalidateは、その要求の解決結果を返すだけで、後続生成を予約しない。 seedを省略したままvalidateとgenerateを呼ぶと異なる画像用seedになる。 拡張後もrandom_seedとsettings.seedを固定し、同じデータ版を使うことを明示する。 再現性を上げる場合は、解決結果をPreparedという不変オブジェクトにまとめる。 ```text Prepared: original_request_hash resolved_positive / resolved_negative SDKへ渡す設定 character_ids directives random_seed / image_seed issues 使用データのSHA-256 ``` 1つのgenerateの中では、検証で得たPreparedをそのまま送信に使い、 その後に変数やワイルドカードを再評価しない。 validateと別プロセスのgenerateで厳密に一致させたい場合は、ホスト側へPreparedを保存し、 plan_idを返して後続要求で指定する新しいプロトコルを設計する。 既存端末の入力契約にはplan_idはないので、追加したときはschemaと対応バージョンも変える。 同じseedで同じピクセルになることまで、クライアントだけで保証しない。 モデルやAPI実装が変われば変化しうるため、まず同じ送信prompt・設定・seedを作れたかを比較する。 ## 16. SSH・Tailscale・AI側クライアント AI側にはAPIキーを置かず、SSH接続先と起動コマンドを設定する。 Tailscaleを使う場合も、すでにそのホストへSSH接続できることを前提にする。 このクライアント自体はTailscaleやWindowsのSSHサーバーを導入・設定しない。 自作のPythonソースをホストで起動する場合、起動コマンドには仮想環境のPythonとスクリプトの絶対パスを書く。 カレントディレクトリやactivate済み状態に依存させない。 ```text "C:\Tools\sdk-client\.venv\Scripts\python.exe" "C:\Tools\sdk-client\reference_client.py" ``` AI側の送信処理は標準ライブラリのsubprocessで作れる。 ```python result = subprocess.run( ["ssh", "-T", "-o", "BatchMode=yes", host, fixed_command + " rpc"], input=json.dumps(envelope, ensure_ascii=False).encode("utf-8"), stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180, ) ``` prompt、検索語、IDは固定コマンドへ連結せずstdinに入れる。 hostは先頭の-、改行、NULを拒否する。shell=Trueは使わない。 remote-commandは所有者が設定する起動文字列として扱い、通常の生成リクエストから変更させない。 最小実装のrpcのvalidate/generate/status/schema/describeは同梱muse_nai.pyから呼び出せる。 最小実装にないcharactersなどをクライアントが列挙していても、ホストのdescribeを優先する。 Base64を受信したら、終了コード、ok、mime_type、bytes、sha256を照合する。 Base64 decodeではvalidate=Trueを使い、RIFFとWEBPのヘッダーも確認する。 保存先はAI側の指定パスを'xb'で作成し、既存ファイルを上書きしない。 PillowをAI側へ入れたくない場合も、標準ライブラリでこの確認はできる。 Windowsシェルのパスの引用と、muse側シェルの引用は別である。 空白を含むWindowsパスの二重引用符はremote-commandの値に含める。 同梱muse_nai.pyの--remote-commandへ、前述の2つの引用済みパスを1つの値として渡せる。 ## 17. 失敗・タイムアウト・再試行 |終了コード|意味|再実行の扱い| |---|---|---| |0|成功|結果を利用| |2|入力・構文・対応外の機能など|入力修正後に利用者の依頼を保って再検証| |3|busy|生成中と伝える。自動キューに積まない| |4|キー未設定|所有者にホスト設定が必要と伝える| |5|生成API失敗|自動再送しない| |6|設定・ファイルアクセス失敗|ホスト側を確認| |7|muse側のSSHタイムアウト|同梱クライアント独自の値。自動再送しない| SDKのAuthenticationError、RateLimitError、NetworkErrorなどは内部で区別してログへ記録できる。 ただし利用者へ返す際はキーやHTTPヘッダーを含めず、固定したエラーコードへ変換する。 最小実装はAPI境界の例外をgeneration_failedへまとめる。 接続開始に失敗したのか、送信後に返答を読めなかったのかは、すべてのエラーから確実には分からない。 タイムアウト時に同じ要求を再送すると、2枚生成して2回消費する可能性がある。 SDK 0.14.1の確認した生成メソッドは1回のPOSTを行う構成だったが、将来のSDKやHTTP層の更新では再試行設定も調べる。 アプリ側には自動再送のループを追加しない。 強い運用要件がある場合、生成前にジョブ記録へrequest_idと状態を書き、 pending / sending / received / saved / unknownなどへ遷移させる。 ただしNAI側が同じrequest_idの処理を重複排除する保証がない限り、クライアントだけでexactly-onceは保証できない。 このジョブ照会も最小実装と既存端末にはなく、別のプロトコル拡張である。 ## 18. テスト仕様と受入条件 ### 18.1 ネットワークを呼ばない試験 SDKオブジェクトの型検証とFakeClientを使う。実キーを読み込む既定設定をテストへ渡さない。 テストごとに一時フォルダ、空の設定、偽キー、専用LOCALAPPDATAを用意する。 |対象|入力・操作|期待結果| |---|---|---| |最小request|promptだけ|SDK paramsを作れる、画像数1| |seed=0|settings.seed=0|0を保持| |未知キー|n_samples / api_key / arbitrary|終了コード2、値を返却しない| |型|steps=true、width="832"|拒否| |JSON|重複キー、NaN、非UTF-8、サイズ超過|拒否| |上限|1536×1536、ホスト上限超steps|API前に拒否| |未対応構文|最小実装へ@char1や選択式|unsupported_syntax| |キーなし|通常generate|4、クライアント未作成| |画像保存|FakeClientがRGBA1枚を返す|透過WebP、正しいSHA-256| |枚数違反|FakeClientが0枚/2枚|失敗として扱う| |binary|成功時|stdoutはWebPのみ| |binaryエラー|busy時|stdout空、stderr JSON| |API例外|偽のAuthorizationを含む例外|キーを出さず、1回で終了、HTTPクライアントclose| |排他|別プロセスでロック|3、APIを呼ばない| |異常終了|ロックを持つ子を停止|次のプロセスが取得可能| |保存失敗|出力先を拒否|再生成しない| 同梱テストでは上記のうち13のテストメソッドで、型・キーなし・模擬画像・close・排他・UTF-8などを確認した。 表のすべてのケースが同梱テスト1対1で実装済みという意味ではない。 0枚/2枚、保存失敗、強制終了なども、自作の完成判定に追加する。 ### 18.2 拡張コンパイラーの受入例 |入力または状況|期待結果| |---|---| |special week (umamusume)|通常文字を保持| |1.5::tag::|強調を保持| |閉じない「|位置付きエラー| |0重みと正重みの候補|0を選ばない| |全候補0、負数、小数重み|エラー| |入れ子の通常抽選|seed固定で再現| |同じ変数を2回参照|同じ値| |未定義、変数の循環|エラーと経路| |連動候補数の不一致|警告+剰余で選択| |Negative内の定義|エラー| |選ばれない枝の!や+|生成前エラー| |未使用定義の!や+|生成前エラー| |DB要素・ワイルドカード内の!|生成前エラー| |wildcardの循環|読込経路を返してエラー| |DB要素の空値と欠落|空値は候補、欠落は除外または警告| |未登録キャラID|エラー| |@char2.temp1|@char1.temp1と同じ一時値| |[res]で上限を超過|API前エラー| |複数のres|最後が有効| |未知の角括弧|通常文字として維持| |ref:noneとDB既定参照|参照を無効化| |複数キャラと精密参照|契約に従い拒否| |精密参照とVibe|精密参照を優先し警告| |V5へ非対応Vibe|送信前エラー| |validate後にDBが変化|同一データという前提の違反を検出、または予約方式で固定| ### 18.3 実API・別PCで別途確認すること オフラインでパラメータを作れても、APIの受理、契約による生成可否、実消費、生成品質は確認できない。 所有者がキーを設定し、最小の1枚を明示的に依頼して初めて実API確認とする。 別PCへEXEをコピーした起動確認、muse実機のSSH、実バッチとの競合は、それぞれ別の確認として記録する。 ## 19. 自分で行うEXE化・ソース配布 ソースのまま動かせばEXE化は不要である。 PythonがないWindowsへ配置したい場合だけ、自分でPyInstallerを導入してビルドする。 ```powershell .\.venv\Scripts\python.exe -m pip install PyInstaller==6.21.0 .\.venv\Scripts\python.exe -m PyInstaller --clean --console --onedir --name MyNAIClient --collect-all novelai reference_client.py ``` --consoleは標準入出力を使うために必要。--windowedへ変えない。 出来上がるdist/MyNAIClientへconfig.example.jsonをconfig.jsonとして配置する。 READMEやschema、secretsの設定案内も自分で追加する。APIキーをコピーしない。 PyInstallerのオプションは[公式Usage](https://www.pyinstaller.org/en/stable/usage.html)で確認できる。 ```text MyNAIClient/ MyNAIClient.exe _internal/ config.json secrets/ キーは含めない outputs/ 空 README.md ``` --onedirは、このフォルダ全体で動かす形式。 --onefileは実行用ファイルを起動時に展開する方式であり、設定・出力・キーの置き場まで消えるわけではない。 配布形式の違いと、NAIDesktop/Pythonの外部インストールを必要としないことを分けて説明する。 ビルド後は別のフォルダへコピーし、PATHから開発用Pythonを外し、PYTHONHOME/PYTHONPATH/VIRTUAL_ENVを消して試す。 空白と日本語を含む場所、異なるカレントディレクトリ、キー未設定のgenerate、busy時のbinaryを確認する。 その後、できればPython未導入の別PCでも確認する。 配布物はコピーするファイルを列挙して作る。プロジェクト全体やdataフォルダの丸ごとZIP化は避ける。 DB・テンプレ・wildcards・references・vibes・secrets・outputs・復旧ファイルを初期状態で含めない。 依存ライブラリのライセンス表示やwheelの情報は各公開元の条件に従って残す。 自分でビルドした事実と、依存ライブラリまで自分で実装・検証したことは別である。 配布済みEXEを信頼しない人にとっては、どのソースを読み、どの依存を取得し、 どのコマンドで実行ファイルを作ったかを確認できることが判断材料になる。 ビルド日時、Python版、依存一覧、ソースと成果物のSHA-256、テスト結果を残す。 同じ手順でもOSやツール版でEXEのバイト列が変わりうるため、説明なしにビット単位の再現性を約束しない。 ## 20. 段階別の実装順序と検証記録 |段階|実装するもの|完了判定| |---|---|---| |A|付録の最小実装|キーなしvalidateとオフラインテスト成功| |B|所有者の実API試験|1要求1枚、WebP、利用枠を確認| |C|SSHクライアント|UTF-8 requestと画像を往復、切断で自動再送しない| |D|空DBとcatalog|一覧が空で正常、ID解決と欠落診断| |E|ASTと選択式|全枝の禁止モード・重み・深さ制限| |F|変数とwildcard|共有抽選、循環、展開上限| |G|CHARと命令|SDK Character、最終上限、参照の優先順| |H|V5とモデル別互換|型・送信JSON・実APIをモデルごとに確認| |I|EXE化と配布|移動テスト、空データ、鍵なし、依存一覧| 段階Aのコードを、D~Hまで完成したものとしてAIへ渡さない。 describeが未対応機能をfalseまたは未掲載として返し、AIがその情報に従って通常プロンプトへ戻れるようにする。 AIへの運用指示はAI_MANUAL.mdの通常の手順を利用できるが、ホストのdescribeとschemaを優先する。 この資料作成時の検証は、最小実装の13テスト、SDKパラメータの作成、模擬画像のWebP返却、 別プロセスのbusy拒否である。実APIは呼んでいない。 既存のスタンドアロン端末そのものの移動テストとは別の検証として扱う。 ## 21. 公開資料と読み合わせ 以下は2026-11-03に参照した公開元。Web上のmainやstableは変化するため、 実装の基準には使用するSDKのタグ・版とローカルの型定義も合わせて記録する。 - [novelai-sdk リポジトリ](https://github.com/caru-ini/novelai-sdk) - [novelai-sdk 0.14.1のPyPI](https://pypi.org/project/novelai-sdk/0.14.1/) - [SDKリリース一覧](https://github.com/caru-ini/novelai-sdk/releases) - [SDK Getting Started](https://caru-ini.github.io/novelai-sdk/docs/getting-started/) - [SDK Authentication](https://caru-ini.github.io/novelai-sdk/docs/authentication/) - [SDK Character Prompts例](https://github.com/caru-ini/novelai-sdk/blob/main/examples/03_character_prompts.py) - [SDK Precise Reference](https://caru-ini.github.io/novelai-sdk/docs/examples/character-reference/) - [Python msvcrt.locking](https://docs.python.org/3/library/msvcrt.html#msvcrt.locking) - [Pillow WebP](https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp) - [pip Secure installs](https://pip.pypa.io/en/stable/topics/secure-installs/) - [PyInstaller Usage](https://www.pyinstaller.org/en/stable/usage.html) 手元のSDK 0.14.1では、NovelAIのcontext manager、GenerateImageParamsのフィールド、 高レベルgenerateから低レベルの1回POSTへ進む経路、ControlNetImage.from_fileの未実装、 encode_vibeのキャッシュとAPI呼出しをソースで読み合わせた。 これらは、ここにない別のSDKや将来の版についての保証ではない。 最初の実行時に確認すべき成果物は、画面に表示される「成功」の文字だけではない。 自分で読んだソースが作った送信prompt、seed、1枚の画像、同じ画像のSHA-256が一致していることを確かめる。 その小さな1要求を把握できてから、DBや構文を1段ずつ増やせる。 ## 付録A. 実行可能な最小実装の全ソース 次をreference_client.pyとして保存する。別ファイルのreference_client.pyと同一内容である。 上記のrequirements.txtを自分の仮想環境へ導入し、config.example.jsonをconfig.jsonへコピーして実行する。 ```python """Readable single-image SDK client. No desktop imports, GUI or bundled DB. This reference supports literal prompts on V4.5 only. The accompanying design document specifies the parser/DB/V5 extensions; they are deliberately rejected here until the implementer has added and tested them. """ from __future__ import annotations import argparse import base64 import hashlib import io import json import os from pathlib import Path import re import secrets import sys from typing import Literal import uuid from pydantic import BaseModel, ConfigDict, Field, ValidationError from novelai import NovelAI from novelai.types import GenerateImageParams MAX_BYTES = 262144 BASE_URL = "https://image.novelai.net" class AppError(Exception): def __init__(self, code: str, message: str, details=None): super().__init__(message) self.code = code self.details = details or [] class StrictModel(BaseModel): model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False) class Settings(StrictModel): model: Literal["nai-diffusion-4-5-full"] = "nai-diffusion-4-5-full" width: int = Field(default=832, ge=64, le=1600, multiple_of=64) height: int = Field(default=1216, ge=64, le=1600, multiple_of=64) steps: int = Field(default=28, ge=1, le=50) scale: float = Field(default=5.0, ge=0, le=10) sampler: Literal["k_euler", "k_euler_ancestral"] = "k_euler_ancestral" noise_schedule: Literal["karras", "exponential", "polyexponential"] = "karras" cfg_rescale: float = Field(default=0.0, ge=0, le=1) quality: bool = False uc_preset: Literal["strong", "light", "furry_focus", "human_focus"] = "light" seed: int | None = Field(default=None, ge=0, le=4294967295) class Request(StrictModel): prompt: str = Field(min_length=1, max_length=65536) negative_prompt: str = Field(default="", max_length=65536) settings: Settings = Field(default_factory=Settings) class HostConfig(StrictModel): api_key_file: str = "secrets/api-key.txt" output_directory: str = "outputs" max_pixels: int = Field(default=1048576, ge=4096, le=2560000) max_steps: int = Field(default=28, ge=1, le=50) timeout_seconds: int = Field(default=120, ge=5, le=600) webp_quality: int = Field(default=85, ge=1, le=100) webp_lossless: bool = False defaults: Settings = Field(default_factory=Settings) def app_root(): return Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else Path(__file__).resolve().parent def parse_json(raw: bytes): if len(raw) > MAX_BYTES: raise AppError("invalid_request", "JSON must be at most 256 KiB") def pairs(items): result = {} for key, value in items: if key in result: raise AppError("invalid_request", "Duplicate JSON key") result[key] = value return result def invalid_constant(value): raise AppError("invalid_request", "Non-finite JSON number") try: return json.loads(raw.decode("utf-8-sig"), object_pairs_hook=pairs, parse_constant=invalid_constant) except (UnicodeError, json.JSONDecodeError): raise AppError("invalid_request", "Expected UTF-8 JSON") from None def load_json_file(path): with path.open("rb") as stream: return parse_json(stream.read(MAX_BYTES + 1)) def lock_path(): base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / ".local/share") return base / "NAIDesktop/generation-v1.lock" class Lease: def __init__(self): self.stream = None def __enter__(self): path = lock_path() path.parent.mkdir(parents=True, exist_ok=True) stream = path.open("a+b") if stream.seek(0, os.SEEK_END) == 0: stream.write(b"\0") stream.flush() stream.seek(0) try: if os.name == "nt": import msvcrt msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) else: import fcntl fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: stream.close() raise AppError("busy", "Another generation is active") from None self.stream = stream return self def __exit__(self, *exc): self.stream.close() self.stream = None def busy(): try: with Lease(): return False except AppError as exc: if exc.code != "busy": raise return True def literal_prompt(text): text = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("//")) # Fail closed for application syntax this small reference does not parse. # The full AST implementation belongs to the extension described in the guide. if re.search(r"[「」]|\$「|__[\w-]+__|\*\*CHAR\*\*|@char[1-9]\d*|\[(?:res|ref|vibe|pos|mask)\s*:|^\s*CHAR\s*:|\([^\n)]*\|", text, re.I | re.M): raise AppError("unsupported_syntax", "This minimal reference accepts literal prompts only") return " ".join(text.split()) class Service: def __init__(self, path): self.root = path.resolve().parent self.config = HostConfig.model_validate(load_json_file(path)) def credential(self): key = os.environ.get("NAI_API_KEY", "").strip() if not key: path = (self.root / self.config.api_key_file).resolve() if path.is_file(): key = path.read_text(encoding="utf-8-sig").strip() if not key: raise AppError("api_key_missing", "Configure the key on the host") return key def prepare(self, payload): if not isinstance(payload, dict) or not isinstance(payload.get("settings", {}), dict): raise AppError("invalid_request", "Expected an object with an optional settings object") settings = self.config.defaults.model_dump() settings.update(payload.get("settings", {})) request = Request.model_validate({**payload, "settings": settings}) positive = literal_prompt(request.prompt) negative = literal_prompt(request.negative_prompt) if not positive: raise AppError("invalid_request", "The resolved positive prompt is empty") values = request.settings if values.width * values.height > self.config.max_pixels or values.steps > self.config.max_steps: raise AppError("generation_limit", "Host pixel or step limit exceeded") seed = values.seed if values.seed is not None else secrets.randbits(32) params = GenerateImageParams( prompt=positive, negative_prompt=negative or None, model=values.model, size=(values.width, values.height), steps=values.steps, scale=values.scale, sampler=values.sampler, noise_schedule=values.noise_schedule, cfg_rescale=values.cfg_rescale, quality=values.quality, uc_preset=values.uc_preset, seed=seed, n_samples=1, ) summary = {"ok": True, "prompt": positive, "negative_prompt": negative, "seed": seed, "settings": values.model_dump(exclude={"seed"}), "effective_size": list(params.size), "n_samples": 1, "issues": []} return params, summary def generate(self, payload, response="path", client_factory=NovelAI): with Lease(): params, summary = self.prepare(payload) key = self.credential() images = [] try: # Explicit key + fixed API origin; remote requests cannot supply either. with client_factory(api_key=key, image_base=BASE_URL, timeout=self.config.timeout_seconds) as client: images = client.image.generate(params) except Exception: raise AppError("generation_failed", "Generation failed; do not retry automatically") from None if len(images) != 1: for image in images: image.close() raise AppError("generation_failed", "Expected exactly one image") buffer = io.BytesIO() try: images[0].save(buffer, format="WEBP", quality=self.config.webp_quality, lossless=self.config.webp_lossless) finally: images[0].close() blob = buffer.getvalue() if response == "binary": return blob output = (self.root / self.config.output_directory).resolve() output.mkdir(parents=True, exist_ok=True) identifier = uuid.uuid4().hex image_path = output / (identifier + ".webp") metadata_path = output / (identifier + ".json") result = {**summary, "id": identifier, "image_path": str(image_path), "metadata_path": str(metadata_path), "mime_type": "image/webp", "bytes": len(blob), "sha256": hashlib.sha256(blob).hexdigest()} with image_path.open("xb") as stream: stream.write(blob) with metadata_path.open("x", encoding="utf-8") as stream: json.dump(result, stream, ensure_ascii=False, allow_nan=False, indent=2) if response == "base64": result["image_base64"] = base64.b64encode(blob).decode("ascii") return result def describe(): return {"ok": True, "name": "independent-nai-reference", "protocol": "literal-mvp-1", "commands": ["describe", "schema", "status", "validate", "generate", "rpc"], "application_syntax": False, "models": ["nai-diffusion-4-5-full"], "n_samples": 1} def emit(value, stream=None): target = stream or sys.stdout target.write(json.dumps(value, ensure_ascii=False, allow_nan=False) + "\n") target.flush() class Parser(argparse.ArgumentParser): def error(self, message): raise AppError("invalid_request", message) def main(argv=None): for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): stream.reconfigure(encoding="utf-8") binary = False try: parser = Parser(description=__doc__) parser.add_argument("--config", type=Path, default=app_root() / "config.json") parser.add_argument("command", choices=("describe", "schema", "status", "validate", "generate", "rpc")) parser.add_argument("--request", type=Path) parser.add_argument("--stdin", action="store_true") parser.add_argument("--response", choices=("path", "base64", "binary"), default="path") args = parser.parse_args(argv) binary = args.command == "generate" and args.response == "binary" if args.command == "describe": emit(describe()) return 0 if args.command == "schema": emit(Request.model_json_schema()) return 0 service = Service(args.config) command = args.command payload = None if command == "rpc": envelope = parse_json(sys.stdin.buffer.read(MAX_BYTES + 1)) if not isinstance(envelope, dict) or set(envelope) - {"command", "request"}: raise AppError("invalid_request", "Invalid RPC envelope") command = envelope.get("command") if command not in ("describe", "schema", "status", "validate", "generate"): raise AppError("invalid_request", "Unknown RPC command") payload = envelope.get("request") if command not in ("validate", "generate") and "request" in envelope: raise AppError("invalid_request", "This command does not accept request") args.response = "base64" elif command in ("validate", "generate"): if bool(args.request) == args.stdin: raise AppError("invalid_request", "Use exactly one of --request or --stdin") payload = load_json_file(args.request) if args.request else parse_json(sys.stdin.buffer.read(MAX_BYTES + 1)) if command == "describe": emit(describe()) elif command == "schema": emit(Request.model_json_schema()) elif command == "status": emit({"ok": True, "busy": busy(), "max_images_per_request": 1}) elif command == "validate": emit(service.prepare(payload)[1]) elif command == "generate": result = service.generate(payload, args.response) if binary: sys.stdout.buffer.write(result) sys.stdout.buffer.flush() else: emit(result) return 0 except AppError as exc: error = exc except ValidationError as exc: details = [{"field": ".".join(map(str, entry["loc"])), "message": entry["msg"]} for entry in exc.errors(include_input=False, include_url=False)] error = AppError("invalid_request", "Invalid field or value", details) except OSError: error = AppError("configuration_error", "Cannot access a configured file or output path") except Exception: error = AppError("internal_error", "Operation failed") emit({"ok": False, "error": {"code": error.code, "message": str(error), "issues": error.details}}, sys.stderr if binary else sys.stdout) return {"busy": 3, "api_key_missing": 4, "generation_failed": 5, "configuration_error": 6}.get(error.code, 2) if __name__ == "__main__": raise SystemExit(main()) ```