diff --git a/src-tauri/src/proxy/pi_handler.rs b/src-tauri/src/proxy/pi_handler.rs index 35f9e8a65..143f8d099 100644 --- a/src-tauri/src/proxy/pi_handler.rs +++ b/src-tauri/src/proxy/pi_handler.rs @@ -111,8 +111,7 @@ pub(crate) async fn handle_pi_native( continue; } - let mut saw_health_failure = false; - let mut reached_network = false; + let mut provider_health_failure = None; for candidate in attempts[index..provider_end].iter().cloned() { if !network_budget.has_remaining() { break; @@ -155,16 +154,12 @@ pub(crate) async fn handle_pi_native( } if let Some(pending) = pending_retryable.take() { if pending.provider_id != provider_id { - record_provider_result( + settle_provider_health( &state, route.catalog_epoch, &pending.provider_id, pending.used_half_open_permit, - false, - Some(format!( - "Pi upstream returned retryable status {}", - pending.response.status() - )), + pending.provider_health.clone(), ) .await; } else { @@ -182,7 +177,6 @@ pub(crate) async fn handle_pi_native( .headers(outgoing_headers) .body(body.clone()) .send(); - reached_network = true; let response = match if timeout_seconds > 0 { tokio::time::timeout(Duration::from_secs(u64::from(timeout_seconds)), send) .await @@ -192,25 +186,31 @@ pub(crate) async fn handle_pi_native( } { Ok(Ok(response)) => response, Ok(Err(error)) => { - saw_health_failure = true; - last_error = Some(if error.is_timeout() { + let error = if error.is_timeout() { "Pi upstream request timed out".to_string() } else { "Pi upstream request failed before response".to_string() - }); + }; + provider_health_failure = Some(error.clone()); + last_error = Some(error); continue; } Err(()) => { - saw_health_failure = true; - last_error = Some("Pi upstream response-header timeout".to_string()); + let error = "Pi upstream response-header timeout".to_string(); + provider_health_failure = Some(error.clone()); + last_error = Some(error); continue; } }; let status = response.status(); if retryable_status(status) && network_budget.has_remaining() { - saw_health_failure = true; - last_error = Some(format!("Pi upstream returned retryable status {status}")); + let error = format!("Pi upstream returned retryable status {status}"); + let status_health = provider_health_disposition(status); + if status_health == ProviderHealthDisposition::Unhealthy { + provider_health_failure = Some(error.clone()); + } + last_error = Some(error); let selected_is_failover = materialized.is_failover; pending_retryable = Some(PendingRetryableResponse { response, @@ -218,10 +218,16 @@ pub(crate) async fn handle_pi_native( provider_id: provider_id.clone(), used_half_open_permit: permit.used_half_open_permit, selected_is_failover, + provider_health: ProviderHealthOutcome::from_status( + status, + provider_health_failure.as_deref(), + ), }); continue; } let selected_is_failover = materialized.is_failover; + let provider_health = + ProviderHealthOutcome::from_status(status, provider_health_failure.as_deref()); match prepare_response( state.clone(), response, @@ -236,19 +242,18 @@ pub(crate) async fn handle_pi_native( route.app_config.non_streaming_timeout, permit.used_half_open_permit, selected_is_failover, + provider_health.clone(), ) .await { Ok(prepared) => { if !prepared.finalization_deferred { - let health_success = !retryable_status(status); - record_provider_result( + settle_provider_health( &state, route.catalog_epoch, &provider_id, permit.used_half_open_permit, - health_success, - (!health_success).then(|| format!("Pi upstream returned {status}")), + provider_health, ) .await; record_request_finish( @@ -263,7 +268,7 @@ pub(crate) async fn handle_pi_native( return Ok(prepared.response); } Err(ProxyError::ForwardFailed(error)) | Err(ProxyError::Timeout(error)) => { - saw_health_failure = true; + provider_health_failure = Some(error.clone()); last_error = Some(error); continue; } @@ -273,9 +278,7 @@ pub(crate) async fn handle_pi_native( route.catalog_epoch, &provider_id, permit.used_half_open_permit, - reached_network, - saw_health_failure, - Some(error.to_string()), + provider_health_failure.clone(), ) .await; record_request_finish(&state, false, false, Some(error.to_string())).await; @@ -293,9 +296,7 @@ pub(crate) async fn handle_pi_native( route.catalog_epoch, &provider_id, permit.used_half_open_permit, - reached_network, - saw_health_failure, - last_error.clone(), + provider_health_failure, ) .await; } @@ -307,6 +308,7 @@ pub(crate) async fn handle_pi_native( let provider_id = pending.provider_id.clone(); let used_half_open_permit = pending.used_half_open_permit; let selected_is_failover = pending.selected_is_failover; + let provider_health = pending.provider_health; match prepare_response( state.clone(), pending.response, @@ -321,18 +323,18 @@ pub(crate) async fn handle_pi_native( route.app_config.non_streaming_timeout, used_half_open_permit, selected_is_failover, + provider_health.clone(), ) .await { Ok(prepared) => { if !prepared.finalization_deferred { - record_provider_result( + settle_provider_health( &state, route.catalog_epoch, &provider_id, used_half_open_permit, - false, - Some(format!("Pi upstream returned {status}")), + provider_health, ) .await; record_request_finish( @@ -374,6 +376,7 @@ struct PendingRetryableResponse { provider_id: String, used_half_open_permit: bool, selected_is_failover: bool, + provider_health: ProviderHealthOutcome, } #[derive(Debug)] @@ -497,24 +500,114 @@ async fn record_provider_result( } } -#[allow(clippy::too_many_arguments)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProviderHealthDisposition { + Healthy, + Unhealthy, + Neutral, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProviderHealthOutcome { + disposition: ProviderHealthDisposition, + error: Option, +} + +impl ProviderHealthOutcome { + fn from_status(status: StatusCode, prior_failure: Option<&str>) -> Self { + match provider_health_disposition(status) { + ProviderHealthDisposition::Healthy => Self { + disposition: ProviderHealthDisposition::Healthy, + error: None, + }, + ProviderHealthDisposition::Unhealthy => Self { + disposition: ProviderHealthDisposition::Unhealthy, + error: Some(format!("Pi upstream returned {status}")), + }, + ProviderHealthDisposition::Neutral => prior_failure.map_or_else( + || Self { + disposition: ProviderHealthDisposition::Neutral, + error: None, + }, + |error| Self { + // A credential rejection is neutral by itself, but it + // cannot erase a real failure from an earlier endpoint + // covered by the same provider-level permit. + disposition: ProviderHealthDisposition::Unhealthy, + error: Some(error.to_string()), + }, + ), + } + } +} + +fn provider_health_disposition(status: StatusCode) -> ProviderHealthDisposition { + if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + // Authentication/authorization belongs to the candidate credential, + // not endpoint availability. Failover may still try another + // credential, but this provider's health and circuit stay unchanged. + ProviderHealthDisposition::Neutral + } else if retryable_status(status) { + ProviderHealthDisposition::Unhealthy + } else { + ProviderHealthDisposition::Healthy + } +} + +async fn settle_provider_health( + state: &ProxyState, + catalog_epoch: u64, + provider_id: &str, + used_half_open_permit: bool, + outcome: ProviderHealthOutcome, +) { + match outcome.disposition { + ProviderHealthDisposition::Healthy => { + record_provider_result( + state, + catalog_epoch, + provider_id, + used_half_open_permit, + true, + None, + ) + .await; + } + ProviderHealthDisposition::Unhealthy => { + record_provider_result( + state, + catalog_epoch, + provider_id, + used_half_open_permit, + false, + outcome.error, + ) + .await; + } + ProviderHealthDisposition::Neutral => { + state + .provider_router + .release_permit_neutral(provider_id, "pi", used_half_open_permit) + .await; + } + } +} + async fn release_or_record_provider( state: &ProxyState, catalog_epoch: u64, provider_id: &str, used_half_open_permit: bool, - reached_network: bool, - saw_health_failure: bool, - error: Option, + provider_health_failure: Option, ) { - if reached_network && saw_health_failure { + if provider_health_failure.is_some() { record_provider_result( state, catalog_epoch, provider_id, used_half_open_permit, false, - error, + provider_health_failure, ) .await; } else { @@ -714,6 +807,7 @@ struct PiStreamFinalization { content_is_sse: bool, used_half_open_permit: bool, selected_is_failover: bool, + complete_provider_health: ProviderHealthOutcome, } enum PiStreamTermination { @@ -723,7 +817,7 @@ enum PiStreamTermination { } struct PiStreamDisposition { - provider_success: Option, + provider_health: ProviderHealthDisposition, provider_error: Option, request_success: bool, request_error: Option, @@ -731,29 +825,25 @@ struct PiStreamDisposition { fn pi_stream_disposition( status: StatusCode, + complete_provider_health: &ProviderHealthOutcome, termination: &PiStreamTermination, ) -> PiStreamDisposition { match termination { - PiStreamTermination::Complete { .. } => { - let provider_success = !retryable_status(status); - PiStreamDisposition { - provider_success: Some(provider_success), - provider_error: (!provider_success) - .then(|| format!("Pi upstream returned {status}")), - request_success: status.is_success(), - request_error: (!status.is_success()) - .then(|| format!("Pi upstream returned {status}")), - } - } + PiStreamTermination::Complete { .. } => PiStreamDisposition { + provider_health: complete_provider_health.disposition, + provider_error: complete_provider_health.error.clone(), + request_success: status.is_success(), + request_error: (!status.is_success()).then(|| format!("Pi upstream returned {status}")), + }, PiStreamTermination::UpstreamFailure { message } => PiStreamDisposition { - provider_success: Some(false), + provider_health: ProviderHealthDisposition::Unhealthy, provider_error: Some(message.clone()), request_success: false, request_error: Some(message.clone()), }, PiStreamTermination::DownstreamDropped => PiStreamDisposition { // A downstream cancellation says nothing about upstream health. - provider_success: None, + provider_health: ProviderHealthDisposition::Neutral, provider_error: None, request_success: false, request_error: Some( @@ -801,27 +891,19 @@ impl PiStreamFinalization { } async fn apply(self, termination: PiStreamTermination) { - let disposition = pi_stream_disposition(self.status, &termination); - if let Some(provider_success) = disposition.provider_success { - record_provider_result( - &self.state, - self.catalog_epoch, - &self.candidate.provider_id, - self.used_half_open_permit, - provider_success, - disposition.provider_error, - ) - .await; - } else { - self.state - .provider_router - .release_permit_neutral( - &self.candidate.provider_id, - "pi", - self.used_half_open_permit, - ) - .await; - } + let disposition = + pi_stream_disposition(self.status, &self.complete_provider_health, &termination); + settle_provider_health( + &self.state, + self.catalog_epoch, + &self.candidate.provider_id, + self.used_half_open_permit, + ProviderHealthOutcome { + disposition: disposition.provider_health, + error: disposition.provider_error, + }, + ) + .await; record_request_finish( &self.state, disposition.request_success, @@ -921,6 +1003,7 @@ async fn prepare_response( non_streaming_timeout_seconds: u32, used_half_open_permit: bool, selected_is_failover: bool, + complete_provider_health: ProviderHealthOutcome, ) -> Result { let status = response.status(); let headers = filtered_response_headers(response.headers()); @@ -1019,6 +1102,7 @@ async fn prepare_response( streaming_idle_timeout_seconds, used_half_open_permit, selected_is_failover, + complete_provider_health, ); let mut builder = Response::builder().status(status); *builder @@ -1138,6 +1222,7 @@ fn logged_body_stream( streaming_idle_timeout_seconds: u32, used_half_open_permit: bool, selected_is_failover: bool, + complete_provider_health: ProviderHealthOutcome, ) -> impl futures::Stream> + Send + 'static { // Construct the guard before the generator is polled. Axum may drop a // response body without ever polling it when the client disconnects after @@ -1154,6 +1239,7 @@ fn logged_body_stream( content_is_sse, used_half_open_permit, selected_is_failover, + complete_provider_health, }); async_stream::stream! { let mut captured = Vec::new(); @@ -1358,6 +1444,57 @@ mod tests { } } + #[tokio::test] + async fn credential_rejections_remain_retryable_but_health_neutral() { + let app = axum::Router::new() + .route( + "/unauthorized", + axum::routing::get(|| async { StatusCode::UNAUTHORIZED }), + ) + .route( + "/forbidden", + axum::routing::get(|| async { StatusCode::FORBIDDEN }), + ); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind local Pi capture endpoint"); + let address = listener.local_addr().expect("local capture address"); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve local Pi capture endpoint"); + }); + + for (path, expected) in [ + ("unauthorized", StatusCode::UNAUTHORIZED), + ("forbidden", StatusCode::FORBIDDEN), + ] { + let response = reqwest::get(format!("http://{address}/{path}")) + .await + .expect("request local Pi capture endpoint"); + assert_eq!(response.status(), expected); + assert!(retryable_status(response.status())); + assert_eq!( + ProviderHealthOutcome::from_status(response.status(), None), + ProviderHealthOutcome { + disposition: ProviderHealthDisposition::Neutral, + error: None, + } + ); + } + assert_eq!( + ProviderHealthOutcome::from_status( + StatusCode::UNAUTHORIZED, + Some("earlier endpoint failed"), + ), + ProviderHealthOutcome { + disposition: ProviderHealthDisposition::Unhealthy, + error: Some("earlier endpoint failed".to_string()), + } + ); + server.abort(); + } + #[test] fn sse_comments_do_not_cross_the_commit_fence() { assert!(!contains_semantic_sse_event( @@ -1497,33 +1634,61 @@ mod tests { fn streaming_health_waits_for_the_terminal_outcome() { let complete = pi_stream_disposition( StatusCode::OK, + &ProviderHealthOutcome::from_status(StatusCode::OK, None), &PiStreamTermination::Complete { captured: Some(Vec::new()), }, ); - assert_eq!(complete.provider_success, Some(true)); + assert_eq!(complete.provider_health, ProviderHealthDisposition::Healthy); assert!(complete.request_success); let truncated = pi_stream_disposition( StatusCode::OK, + &ProviderHealthOutcome::from_status(StatusCode::OK, None), &PiStreamTermination::UpstreamFailure { message: "truncated".to_string(), }, ); - assert_eq!(truncated.provider_success, Some(false)); + assert_eq!( + truncated.provider_health, + ProviderHealthDisposition::Unhealthy + ); assert!(!truncated.request_success); assert_eq!(truncated.provider_error.as_deref(), Some("truncated")); - let client_drop = - pi_stream_disposition(StatusCode::OK, &PiStreamTermination::DownstreamDropped); - assert_eq!(client_drop.provider_success, None); + let client_drop = pi_stream_disposition( + StatusCode::OK, + &ProviderHealthOutcome::from_status(StatusCode::OK, None), + &PiStreamTermination::DownstreamDropped, + ); + assert_eq!( + client_drop.provider_health, + ProviderHealthDisposition::Neutral + ); assert!(!client_drop.request_success); let non_retryable_error = pi_stream_disposition( StatusCode::BAD_REQUEST, + &ProviderHealthOutcome::from_status(StatusCode::BAD_REQUEST, None), &PiStreamTermination::Complete { captured: None }, ); - assert_eq!(non_retryable_error.provider_success, Some(true)); + assert_eq!( + non_retryable_error.provider_health, + ProviderHealthDisposition::Healthy + ); assert!(!non_retryable_error.request_success); + + for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { + let credential_rejection = pi_stream_disposition( + status, + &ProviderHealthOutcome::from_status(status, None), + &PiStreamTermination::Complete { captured: None }, + ); + assert_eq!( + credential_rejection.provider_health, + ProviderHealthDisposition::Neutral + ); + assert!(!credential_rejection.request_success); + } } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cf723cb00..7848b5667 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -431,6 +431,7 @@ "themeSystem": "システム", "importExport": "SQL インポート/エクスポート", "importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。", + "piImportExportBoundary": "Pi のプロバイダーと管理設定は移行できます。ネイティブ models.json の所有権は、同じキーが空の場合にのみ再構築されます。ネイティブ指示ファイル、プロンプトファイル、Skill の配置、セッションは端末内に残り、インポートでは上書きされません。", "exportConfig": "SQL バックアップをエクスポート", "selectConfigFile": "SQL ファイルを選択", "noFileSelected": "ファイルが選択されていません。", @@ -767,6 +768,8 @@ "openclawConfigDirDescription": "OpenClaw の設定ディレクトリ(openclaw.json)を上書きします。", "hermesConfigDir": "Hermes 設定ディレクトリ", "hermesConfigDirDescription": "Hermes の設定ディレクトリ(config.yaml)を上書きします。", + "piConfigDir": "Pi 設定ディレクトリ", + "piConfigDirDescription": "Pi の設定ディレクトリ(models.json、指示ファイル、プロンプト、Skills、セッション)を上書きします。", "browsePlaceholderClaude": "例: /home//.claude", "browsePlaceholderCodex": "例: /home//.codex", "browsePlaceholderGemini": "例: /home//.gemini", @@ -774,6 +777,7 @@ "browsePlaceholderOpencode": "例: /home//.config/opencode", "browsePlaceholderOpenclaw": "例: /home//.openclaw", "browsePlaceholderHermes": "例: /home//.hermes", + "browsePlaceholderPi": "例: /home//.pi/agent", "browseDirectory": "ディレクトリを選択", "resetDefault": "デフォルトに戻す(保存後に反映)", "checkForUpdates": "アップデートを確認", @@ -884,7 +888,119 @@ "grokbuild": "Grok Build", "opencode": "OpenCode", "openclaw": "OpenClaw", - "hermes": "Hermes" + "hermes": "Hermes", + "pi": "Pi" + }, + "pi": { + "form": { + "providerKey": "プロバイダーキー", + "providerKeyHint": "Pi が models.json で使用する固定キーです。作成後は変更できません。", + "providerKeyRequired": "プロバイダーキーを入力してください", + "displayName": "表示名", + "nameRequired": "表示名を入力してください", + "providerApi": "プロバイダー API", + "providerBaseUrl": "プロバイダーのベース URL", + "manageEndpoints": "フェイルオーバーエンドポイントを管理", + "credential": "認証情報", + "credentialHint": "Pi はリテラル、$ENV 参照、または !command 式を受け付けます。遅延値は Pi が解決した後にのみ検証されます。", + "website": "Web サイト", + "authHeader": "認証情報を Authorization として送信", + "authHeaderHint": "対応する API ファミリーでは Pi の authHeader 動作を使用します。Anthropic OAuth 認証情報は、検証済みのゲートウェイ転送方式で処理されます。", + "headers": "カスタムヘッダー(JSON)", + "headersStringValues": "カスタムヘッダーの値はすべて文字列で指定してください", + "jsonObjectRequired": "{{label}} は JSON オブジェクトで指定してください", + "nonFiniteNumber": "{{label}} に有限でない数値が含まれています", + "absoluteHttpUrlRequired": "{{label}} は絶対 HTTP または HTTPS URL で指定してください", + "models": "モデル", + "modelsHint": "各モデルはプロバイダーの API とベース URL を継承できます。モデルオブジェクト全体が models.json に保持されます。", + "addModel": "モデルを追加", + "modelNumber": "モデル {{index}}", + "removeModel": "モデルを削除", + "modelId": "モデル ID", + "modelIdRequired": "モデル {{index}} に ID が必要です", + "modelName": "表示名", + "modelApi": "モデル API", + "modelBaseUrl": "モデルのベース URL", + "modelBaseUrlFor": "モデル {{id}} のベース URL", + "inherit": "プロバイダーから継承", + "inheritanceHint": "上書きのないモデルは、このプロバイダー値を継承します。", + "effectiveApiRequired": "モデル {{id}} には、直接またはプロバイダーから継承した API が必要です", + "effectiveBaseUrlRequired": "モデル {{id}} には、直接またはプロバイダーから継承したベース URL が必要です", + "duplicateModel": "モデル ID {{id}} が重複しています", + "modelRequired": "モデルを 1 つ以上追加してください", + "modelAdditionalConfig": "モデル {{id}} の追加設定(JSON)", + "additionalConfig": "プロバイダーの追加設定(JSON)", + "additionalConfigHint": "未認識の Pi フィールドは保持されます。上に表示された管理対象フィールドが優先されます。" + }, + "native": { + "title": "Pi ネイティブモデルカタログ", + "description": "認証済み分類器を通じて実際の Pi models.json を検査します。明示的な操作時のみインポートし、Pi のネイティブファイルを正とします。", + "empty": "Pi models.json にプロバイダーがありません。", + "defaultProvider": "デフォルトプロバイダー", + "defaultModel": "デフォルトモデル", + "setDefault": "デフォルトに設定", + "currentDefault": "現在のデフォルト", + "defaultSaved": "Pi のデフォルトモデルを更新しました", + "defaultSaveFailed": "Pi のデフォルトモデルを更新できませんでした", + "unmanagedDefault": "Pi は現在、管理対象外のプロバイダー {{provider}} / {{model}} を選択しています。インポートするか、管理対象のデフォルトを選択してください。", + "imported": "Pi プロバイダーをインポートしました", + "importFailed": "Pi プロバイダーをインポートできませんでした", + "managed": "管理対象", + "management": { + "importable": "インポート可能", + "managed": "管理対象", + "unsupported": "未対応" + }, + "gateway": { + "proxyable": "ゲートウェイ利用可能", + "direct_only": "直接接続のみ", + "unknown": "不明" + } + }, + "prompts": { + "nativeTitle": "Pi ネイティブプロンプトリソース", + "nativeDescription": "ファイルの存在が有効状態を表します。SYSTEM.md は Pi のシステムプロンプトを置換し、APPEND_SYSTEM.md は追記します。/template-name は prompts ディレクトリのファイルを展開します。", + "agentsLibrary": "AGENTS.md ライブラリ", + "agentsLibraryDescription": "有効なライブラリエントリは AGENTS.md に反映されます。外部変更が検出された場合は、切り替える前にインポートする必要があります。", + "libraryDriftTitle": "AGENTS.md とライブラリが一致しません", + "libraryDriftNative": "Pi が使用中のネイティブ AGENTS.md は、ライブラリに保存された選択内容と異なります。調整すると、実際の内容をそのままインポートして選択します。", + "libraryDriftMissing": "AGENTS.md がないため、ライブラリに有効な選択が残っていても Pi はグローバルプロンプトを使用していません。", + "reconcileLibrary": "調整", + "libraryReconciled": "AGENTS.md ライブラリを Pi のネイティブ状態と調整しました", + "libraryReconcileFailed": "AGENTS.md ライブラリを調整できませんでした", + "systemOverride": "システムプロンプトの置換", + "systemOverrideDescription": "SYSTEM.md が存在する間、Pi の組み込みシステムプロンプトを完全に置き換えます。", + "systemAppend": "システムプロンプトの追記", + "systemAppendDescription": "APPEND_SYSTEM.md が存在する間、Pi のシステムプロンプトに追記されます。", + "active": "有効", + "inactive": "無効", + "activateOverride": "置換を有効化", + "activateOverrideTitle": "{{filename}} を有効にしますか?", + "activateOverrideMessage": "{{filename}} を作成すると、Pi の組み込みシステムプロンプトが完全に置き換わります。意図した操作であることを確認してください。", + "deactivate": "無効化", + "deactivateTitle": "{{filename}} を無効にしますか?", + "deactivateMessage": "{{filename}} を削除します。Pi への適用は直ちに停止します。", + "instructionPlaceholder": "Pi が使用する Markdown 内容", + "blankInstruction": "空白以外の内容を入力するか、ファイルを無効化して削除してください。", + "loadFirst": "変更する前に現在の Pi ファイルを読み込んでください", + "fileSaved": "{{filename}} を保存しました", + "fileDeactivated": "{{filename}} を無効化しました", + "saveFailed": "Pi 指示ファイルを保存できませんでした", + "deleteFailed": "Pi 指示ファイルを無効化できませんでした", + "templates": "プロンプトテンプレート", + "templatesDescription": "prompts/.md の各ファイルは、Pi で / として呼び出せます。", + "newTemplate": "新しいプロンプトテンプレート", + "templateSlug": "テンプレートスラッグ(例: review)", + "templateContent": "テンプレート Markdown", + "createTemplate": "テンプレートを作成", + "templateCreated": "Pi プロンプトテンプレートを作成しました", + "templateSaved": "/{{slug}} を保存しました", + "templateDeleted": "/{{slug}} を削除しました", + "templateSaveFailed": "Pi プロンプトテンプレートを保存できませんでした", + "templateDeleteFailed": "Pi プロンプトテンプレートを削除できませんでした", + "deleteTemplateTitle": "/{{slug}} を削除しますか?", + "deleteTemplateMessage": "prompts/{{slug}}.md を完全に削除します。" + } }, "grokBuild": { "apiBackend": "API Backend", @@ -923,6 +1039,8 @@ "batchDeleting": "削除中...", "loadingSessions": "セッションを読み込み中...", "noSessions": "セッションが見つかりません", + "piRelativeSessionDir": "Pi の sessionDir は Pi を起動したディレクトリからの相対パスであるため、グローバルブラウザーから安全に列挙できません。すべての Pi セッションをここで表示するには、sessionDir に絶対パスを設定してください:", + "piDiscoveryUnavailable": "Pi セッションを検査できませんでした: {{error}}", "selectSession": "セッションを選択してください", "noSummary": "概要なし", "lastActive": "最終アクティブ", @@ -1503,7 +1621,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "grokbuild": "Grok Build" + "grokbuild": "Grok Build", + "pi": "Pi" }, "rawInputLabel": "原始", "rebuildCodex": { @@ -2377,6 +2496,18 @@ "importSelected": "選択をインポート ({{count}})", "noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。", "unmanagedAvailable": "インポート可能なスキルがあります", + "piStatus": { + "active": "有効", + "inactive": "無効", + "unmanagedActive": "有効(管理対象外)", + "desiredButMissing": "有効化済み(Pi で未検出)", + "foreignConflict": "管理対象外の配置先によりブロック", + "staleDeployment": "管理対象の配置が外部で変更されました", + "shadowed": "同名の別の Skill により隠されています", + "invalid": "Skill マニフェストが無効です", + "inspecting": "検査中...", + "inspectionUnavailable": "実際の状態を検査できません" + }, "foundIn": "発見場所", "local": "ローカル", "uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除され、削除前にローカルバックアップが自動作成されます。", @@ -2440,6 +2571,7 @@ "homepage": "ホームページ", "endpoint": "API エンドポイント", "apiKey": "API Key", + "api": "ネイティブ API プロトコル", "icon": "アイコン", "model": "モデル", "haikuModel": "Haiku モデル", @@ -2553,6 +2685,16 @@ } }, "proxy": { + "piGateway": { + "title": "Pi ゲートウェイ認証情報", + "description": "Pi とこのローカルゲートウェイ間でのみ使用する端末固有の認証情報をローテーションします。", + "rotate": "認証情報をローテーション", + "confirmTitle": "Pi ゲートウェイ認証情報をローテーションしますか?", + "confirmMessage": "現在のローカル認証情報は直ちに無効になります。実行中の Pi プロセスは投影済みの認証情報をメモリに保持しているため、再起動が必要です。", + "rotateSuccess": "Pi ゲートウェイ認証情報をローテーションしました", + "rotateFailed": "Pi ゲートウェイ認証情報をローテーションできませんでした", + "restartNotice": "次のリクエストを送信する前に、実行中のすべての Pi プロセスを再起動してください。" + }, "panel": { "serviceAddress": "サービスアドレス", "addressCopied": "アドレスをコピーしました", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 8bd7a461e..274230082 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -431,6 +431,7 @@ "themeSystem": "跟隨系統", "importExport": "SQL 匯入匯出", "importExportHint": "匯入/匯出資料庫 SQL 備份(僅支援匯入由 CC Switch 匯出的備份),便於備份或遷移。", + "piImportExportBoundary": "Pi 供應商與受管設定可移轉;僅在相同原生 key 為空時重建 models.json 所有權。原生指示檔案、提示檔案、Skill 部署與工作階段都保留在本機,匯入時絕不覆寫。", "exportConfig": "匯出 SQL 備份", "selectConfigFile": "選擇 SQL 檔案", "noFileSelected": "尚未選擇設定檔。", @@ -767,6 +768,8 @@ "openclawConfigDirDescription": "覆寫 OpenClaw 設定目錄 (openclaw.json)。", "hermesConfigDir": "Hermes 設定目錄", "hermesConfigDirDescription": "覆寫 Hermes 設定目錄 (config.yaml)。", + "piConfigDir": "Pi 設定目錄", + "piConfigDirDescription": "覆寫 Pi 設定目錄(models.json、指示檔案、提示、Skills 與工作階段)。", "browsePlaceholderClaude": "例如:/home/<您的帳號>/.claude", "browsePlaceholderCodex": "例如:/home/<您的帳號>/.codex", "browsePlaceholderGemini": "例如:/home/<您的帳號>/.gemini", @@ -774,6 +777,7 @@ "browsePlaceholderOpencode": "例如:/home/<您的帳號>/.config/opencode", "browsePlaceholderOpenclaw": "例如:/home/<您的帳號>/.openclaw", "browsePlaceholderHermes": "例如:/home/<您的帳號>/.hermes", + "browsePlaceholderPi": "例如:/home/<您的帳號>/.pi/agent", "browseDirectory": "瀏覽目錄", "resetDefault": "還原預設目錄(需儲存後生效)", "checkForUpdates": "檢查更新", @@ -885,7 +889,119 @@ "grokbuild": "Grok Build", "opencode": "OpenCode", "openclaw": "OpenClaw", - "hermes": "Hermes" + "hermes": "Hermes", + "pi": "Pi" + }, + "pi": { + "form": { + "providerKey": "供應商識別碼", + "providerKeyHint": "Pi 在 models.json 中使用的固定識別碼,建立後不可修改。", + "providerKeyRequired": "供應商識別碼為必填", + "displayName": "顯示名稱", + "nameRequired": "顯示名稱為必填", + "providerApi": "供應商 API", + "providerBaseUrl": "供應商基礎 URL", + "manageEndpoints": "管理故障轉移端點", + "credential": "憑證", + "credentialHint": "Pi 支援字面值、$ENV 參照或 !command 運算式;延遲值僅在 Pi 解析後驗證。", + "website": "網站", + "authHeader": "透過 Authorization 傳送憑證", + "authHeaderHint": "對支援的 API 類別使用 Pi 的 authHeader 行為。Anthropic OAuth 憑證由閘道依已驗證的傳輸方式處理。", + "headers": "自訂標頭(JSON)", + "headersStringValues": "每個自訂標頭值都必須是字串", + "jsonObjectRequired": "{{label}} 必須是 JSON 物件", + "nonFiniteNumber": "{{label}} 包含非有限數值", + "absoluteHttpUrlRequired": "{{label}} 必須是絕對 HTTP 或 HTTPS URL", + "models": "模型", + "modelsHint": "每個模型可繼承供應商的 API 與基礎 URL;完整模型物件會保留在 models.json 中。", + "addModel": "新增模型", + "modelNumber": "模型 {{index}}", + "removeModel": "移除模型", + "modelId": "模型 ID", + "modelIdRequired": "模型 {{index}} 需要 ID", + "modelName": "顯示名稱", + "modelApi": "模型 API", + "modelBaseUrl": "模型基礎 URL", + "modelBaseUrlFor": "模型 {{id}} 的基礎 URL", + "inherit": "繼承供應商設定", + "inheritanceHint": "沒有覆寫值的模型會繼承此供應商設定。", + "effectiveApiRequired": "模型 {{id}} 必須直接設定或從供應商繼承 API", + "effectiveBaseUrlRequired": "模型 {{id}} 必須直接設定或從供應商繼承基礎 URL", + "duplicateModel": "模型 ID {{id}} 重複", + "modelRequired": "請至少新增一個模型", + "modelAdditionalConfig": "模型 {{id}} 的附加設定(JSON)", + "additionalConfig": "供應商附加設定(JSON)", + "additionalConfigHint": "無法識別的 Pi 欄位會原樣保留;以上受控欄位優先。" + }, + "native": { + "title": "Pi 原生模型目錄", + "description": "透過已認證的分類器檢查實際 Pi models.json。僅在明確操作時匯入,並始終以 Pi 原生檔案為準。", + "empty": "Pi models.json 中沒有供應商。", + "defaultProvider": "預設供應商", + "defaultModel": "預設模型", + "setDefault": "設為預設", + "currentDefault": "目前預設", + "defaultSaved": "Pi 預設模型已更新", + "defaultSaveFailed": "更新 Pi 預設模型失敗", + "unmanagedDefault": "Pi 目前選擇的是未受管供應商 {{provider}} / {{model}}。請匯入或選擇受管的預設項目。", + "imported": "Pi 供應商已匯入", + "importFailed": "匯入 Pi 供應商失敗", + "managed": "受管", + "management": { + "importable": "可匯入", + "managed": "受管", + "unsupported": "不支援" + }, + "gateway": { + "proxyable": "閘道已就緒", + "direct_only": "僅限直接連線", + "unknown": "未知" + } + }, + "prompts": { + "nativeTitle": "Pi 原生提示資源", + "nativeDescription": "檔案存在即代表啟用。SYSTEM.md 會取代 Pi 的系統提示,APPEND_SYSTEM.md 會附加內容,而 /template-name 會展開 prompts 目錄中的檔案。", + "agentsLibrary": "AGENTS.md 提示庫", + "agentsLibraryDescription": "已啟用的提示庫項目會投影至 AGENTS.md。偵測到外部修改時,必須先匯入才能切換。", + "libraryDriftTitle": "AGENTS.md 與提示庫不一致", + "libraryDriftNative": "Pi 正在使用的原生 AGENTS.md 內容不是提示庫中儲存的選取項目。協調後會匯入並選取完全相同的即時內容。", + "libraryDriftMissing": "AGENTS.md 不存在,因此即使提示庫仍有啟用的選取項目,Pi 目前也未使用全域提示。", + "reconcileLibrary": "協調", + "libraryReconciled": "AGENTS.md 提示庫已與 Pi 原生狀態協調", + "libraryReconcileFailed": "協調 AGENTS.md 提示庫失敗", + "systemOverride": "系統提示取代", + "systemOverrideDescription": "SYSTEM.md 存在期間會完全取代 Pi 內建的系統提示。", + "systemAppend": "系統提示附加", + "systemAppendDescription": "APPEND_SYSTEM.md 存在期間會附加至 Pi 的系統提示。", + "active": "已啟用", + "inactive": "未啟用", + "activateOverride": "啟用取代", + "activateOverrideTitle": "啟用 {{filename}}?", + "activateOverrideMessage": "建立 {{filename}} 會完全取代 Pi 內建的系統提示。請確認這是您預期的操作。", + "deactivate": "停用", + "deactivateTitle": "停用 {{filename}}?", + "deactivateMessage": "此操作會刪除 {{filename}},Pi 將立即停止套用。", + "instructionPlaceholder": "供 Pi 使用的 Markdown 內容", + "blankInstruction": "請輸入非空白內容;如要移除檔案,請將其停用。", + "loadFirst": "變更前請先載入目前的 Pi 檔案", + "fileSaved": "{{filename}} 已儲存", + "fileDeactivated": "{{filename}} 已停用", + "saveFailed": "儲存 Pi 指示檔案失敗", + "deleteFailed": "停用 Pi 指示檔案失敗", + "templates": "提示範本", + "templatesDescription": "每個 prompts/.md 檔案都可在 Pi 中透過 / 呼叫。", + "newTemplate": "新增提示範本", + "templateSlug": "範本識別碼(例如:review)", + "templateContent": "範本 Markdown", + "createTemplate": "建立範本", + "templateCreated": "Pi 提示範本已建立", + "templateSaved": "/{{slug}} 已儲存", + "templateDeleted": "/{{slug}} 已刪除", + "templateSaveFailed": "儲存 Pi 提示範本失敗", + "templateDeleteFailed": "刪除 Pi 提示範本失敗", + "deleteTemplateTitle": "刪除 /{{slug}}?", + "deleteTemplateMessage": "此操作會永久刪除 prompts/{{slug}}.md。" + } }, "grokBuild": { "apiBackend": "API Backend", @@ -924,6 +1040,8 @@ "batchDeleting": "批次刪除中...", "loadingSessions": "載入工作階段中...", "noSessions": "未發現工作階段", + "piRelativeSessionDir": "Pi 的 sessionDir 是相對於啟動 Pi 時所在的目錄,因此全域瀏覽器無法安全列舉。請將 sessionDir 設為絕對路徑,之後即可在此瀏覽所有 Pi 工作階段:", + "piDiscoveryUnavailable": "無法檢查 Pi 工作階段:{{error}}", "selectSession": "請選擇工作階段檢視詳情", "noSummary": "暫無摘要", "lastActive": "最近活躍", @@ -1504,7 +1622,8 @@ "codex": "Codex", "gemini": "Gemini", "opencode": "OpenCode", - "grokbuild": "Grok Build" + "grokbuild": "Grok Build", + "pi": "Pi" }, "rawInputLabel": "原始", "rebuildCodex": { @@ -2378,6 +2497,18 @@ "importSelected": "匯入已選 ({{count}})", "noUnmanagedFound": "未發現需要匯入的技能。所有技能已在 CC Switch 統一管理中。", "unmanagedAvailable": "發現可匯入的技能", + "piStatus": { + "active": "已生效", + "inactive": "未啟用", + "unmanagedActive": "已生效但未受管", + "desiredButMissing": "已啟用但 Pi 未發現", + "foreignConflict": "遭未受管的目的地阻擋", + "staleDeployment": "受管部署已被外部修改", + "shadowed": "遭另一個同名 Skill 遮蔽", + "invalid": "Skill 資訊清單無效", + "inspecting": "檢查中...", + "inspectionUnavailable": "無法檢查實際狀態" + }, "foundIn": "發現於", "local": "本地", "uninstallConfirm": "確定要解除安裝技能 \"{{name}}\" 嗎?這將從所有應用程式中移除該技能,並在刪除前自動建立本地備份。", @@ -2441,6 +2572,7 @@ "homepage": "官網位址", "endpoint": "API 端點", "apiKey": "API 金鑰", + "api": "原生 API 通訊協定", "icon": "圖示", "model": "模型", "haikuModel": "Haiku 模型", @@ -2554,6 +2686,16 @@ } }, "proxy": { + "piGateway": { + "title": "Pi 閘道憑證", + "description": "輪換僅用於 Pi 與此本機閘道之間通訊的裝置端憑證。", + "rotate": "輪換憑證", + "confirmTitle": "輪換 Pi 閘道憑證?", + "confirmMessage": "目前的本機憑證會立即失效。執行中的 Pi 處理程序仍在記憶體中保留已投影的憑證,因此必須重新啟動。", + "rotateSuccess": "Pi 閘道憑證已輪換", + "rotateFailed": "Pi 閘道憑證輪換失敗", + "restartNotice": "再次傳送請求前,請重新啟動所有執行中的 Pi 處理程序。" + }, "panel": { "serviceAddress": "服務位址", "addressCopied": "位址已複製", diff --git a/tests/config/localeCoverage.test.ts b/tests/config/localeCoverage.test.ts new file mode 100644 index 000000000..032a5b5fe --- /dev/null +++ b/tests/config/localeCoverage.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import en from "@/i18n/locales/en.json"; +import ja from "@/i18n/locales/ja.json"; +import zhTW from "@/i18n/locales/zh-TW.json"; +import zh from "@/i18n/locales/zh.json"; + +type TranslationTree = Record; + +function flattenStrings( + value: unknown, + path: string[] = [], + result = new Map(), +): Map { + if (typeof value === "string") { + result.set(path.join("."), value); + } else if (typeof value === "object" && value !== null) { + for (const [key, child] of Object.entries(value)) { + flattenStrings(child, [...path, key], result); + } + } + return result; +} + +function interpolationVariables(value: string): string[] { + return Array.from( + value.matchAll(/\{\{\s*([^}]+?)\s*\}\}/g), + ([, name]) => name, + ).sort(); +} + +const reference = flattenStrings(en); +const piKeysOutsideNamespace = new Set([ + "apps.pi", + "deeplink.api", + "sessionManager.piDiscoveryUnavailable", + "sessionManager.piRelativeSessionDir", + "settings.browsePlaceholderPi", + "settings.piConfigDir", + "settings.piConfigDirDescription", + "settings.piImportExportBoundary", + "usage.appFilter.pi", +]); +const piReference = new Map( + [...reference].filter( + ([key]) => + key.startsWith("pi.") || + key.startsWith("proxy.piGateway.") || + key.startsWith("skills.piStatus.") || + piKeysOutsideNamespace.has(key), + ), +); +const locales = [ + ["zh", zh], + ["ja", ja], + ["zh-TW", zhTW], +] as const; + +describe("locale coverage", () => { + it.each(locales)("covers every Pi translation key in %s", (_name, tree) => { + const translations = flattenStrings(tree as TranslationTree); + const missing = [...piReference.keys()].filter( + (key) => !translations.has(key), + ); + + expect(missing).toEqual([]); + }); + + it.each(locales)( + "preserves every Pi interpolation variable in %s", + (_name, tree) => { + const translations = flattenStrings(tree as TranslationTree); + const mismatched = [...piReference].flatMap(([key, expected]) => { + const actual = translations.get(key); + return actual !== undefined && + interpolationVariables(actual).join("\0") !== + interpolationVariables(expected).join("\0") + ? [key] + : []; + }); + + expect(mismatched).toEqual([]); + }, + ); +});