fix(pi): localize UI and isolate credential health

This commit is contained in:
SaladDay
2026-08-03 08:07:19 +00:00
parent a8246da3d6
commit a2fa86bea1
4 changed files with 614 additions and 81 deletions
+242 -77
View File
@@ -111,8 +111,7 @@ pub(crate) async fn handle_pi_native(
continue; continue;
} }
let mut saw_health_failure = false; let mut provider_health_failure = None;
let mut reached_network = false;
for candidate in attempts[index..provider_end].iter().cloned() { for candidate in attempts[index..provider_end].iter().cloned() {
if !network_budget.has_remaining() { if !network_budget.has_remaining() {
break; break;
@@ -155,16 +154,12 @@ pub(crate) async fn handle_pi_native(
} }
if let Some(pending) = pending_retryable.take() { if let Some(pending) = pending_retryable.take() {
if pending.provider_id != provider_id { if pending.provider_id != provider_id {
record_provider_result( settle_provider_health(
&state, &state,
route.catalog_epoch, route.catalog_epoch,
&pending.provider_id, &pending.provider_id,
pending.used_half_open_permit, pending.used_half_open_permit,
false, pending.provider_health.clone(),
Some(format!(
"Pi upstream returned retryable status {}",
pending.response.status()
)),
) )
.await; .await;
} else { } else {
@@ -182,7 +177,6 @@ pub(crate) async fn handle_pi_native(
.headers(outgoing_headers) .headers(outgoing_headers)
.body(body.clone()) .body(body.clone())
.send(); .send();
reached_network = true;
let response = match if timeout_seconds > 0 { let response = match if timeout_seconds > 0 {
tokio::time::timeout(Duration::from_secs(u64::from(timeout_seconds)), send) tokio::time::timeout(Duration::from_secs(u64::from(timeout_seconds)), send)
.await .await
@@ -192,25 +186,31 @@ pub(crate) async fn handle_pi_native(
} { } {
Ok(Ok(response)) => response, Ok(Ok(response)) => response,
Ok(Err(error)) => { Ok(Err(error)) => {
saw_health_failure = true; let error = if error.is_timeout() {
last_error = Some(if error.is_timeout() {
"Pi upstream request timed out".to_string() "Pi upstream request timed out".to_string()
} else { } else {
"Pi upstream request failed before response".to_string() "Pi upstream request failed before response".to_string()
}); };
provider_health_failure = Some(error.clone());
last_error = Some(error);
continue; continue;
} }
Err(()) => { Err(()) => {
saw_health_failure = true; let error = "Pi upstream response-header timeout".to_string();
last_error = Some("Pi upstream response-header timeout".to_string()); provider_health_failure = Some(error.clone());
last_error = Some(error);
continue; continue;
} }
}; };
let status = response.status(); let status = response.status();
if retryable_status(status) && network_budget.has_remaining() { if retryable_status(status) && network_budget.has_remaining() {
saw_health_failure = true; let error = format!("Pi upstream returned retryable status {status}");
last_error = Some(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; let selected_is_failover = materialized.is_failover;
pending_retryable = Some(PendingRetryableResponse { pending_retryable = Some(PendingRetryableResponse {
response, response,
@@ -218,10 +218,16 @@ pub(crate) async fn handle_pi_native(
provider_id: provider_id.clone(), provider_id: provider_id.clone(),
used_half_open_permit: permit.used_half_open_permit, used_half_open_permit: permit.used_half_open_permit,
selected_is_failover, selected_is_failover,
provider_health: ProviderHealthOutcome::from_status(
status,
provider_health_failure.as_deref(),
),
}); });
continue; continue;
} }
let selected_is_failover = materialized.is_failover; let selected_is_failover = materialized.is_failover;
let provider_health =
ProviderHealthOutcome::from_status(status, provider_health_failure.as_deref());
match prepare_response( match prepare_response(
state.clone(), state.clone(),
response, response,
@@ -236,19 +242,18 @@ pub(crate) async fn handle_pi_native(
route.app_config.non_streaming_timeout, route.app_config.non_streaming_timeout,
permit.used_half_open_permit, permit.used_half_open_permit,
selected_is_failover, selected_is_failover,
provider_health.clone(),
) )
.await .await
{ {
Ok(prepared) => { Ok(prepared) => {
if !prepared.finalization_deferred { if !prepared.finalization_deferred {
let health_success = !retryable_status(status); settle_provider_health(
record_provider_result(
&state, &state,
route.catalog_epoch, route.catalog_epoch,
&provider_id, &provider_id,
permit.used_half_open_permit, permit.used_half_open_permit,
health_success, provider_health,
(!health_success).then(|| format!("Pi upstream returned {status}")),
) )
.await; .await;
record_request_finish( record_request_finish(
@@ -263,7 +268,7 @@ pub(crate) async fn handle_pi_native(
return Ok(prepared.response); return Ok(prepared.response);
} }
Err(ProxyError::ForwardFailed(error)) | Err(ProxyError::Timeout(error)) => { Err(ProxyError::ForwardFailed(error)) | Err(ProxyError::Timeout(error)) => {
saw_health_failure = true; provider_health_failure = Some(error.clone());
last_error = Some(error); last_error = Some(error);
continue; continue;
} }
@@ -273,9 +278,7 @@ pub(crate) async fn handle_pi_native(
route.catalog_epoch, route.catalog_epoch,
&provider_id, &provider_id,
permit.used_half_open_permit, permit.used_half_open_permit,
reached_network, provider_health_failure.clone(),
saw_health_failure,
Some(error.to_string()),
) )
.await; .await;
record_request_finish(&state, false, false, Some(error.to_string())).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, route.catalog_epoch,
&provider_id, &provider_id,
permit.used_half_open_permit, permit.used_half_open_permit,
reached_network, provider_health_failure,
saw_health_failure,
last_error.clone(),
) )
.await; .await;
} }
@@ -307,6 +308,7 @@ pub(crate) async fn handle_pi_native(
let provider_id = pending.provider_id.clone(); let provider_id = pending.provider_id.clone();
let used_half_open_permit = pending.used_half_open_permit; let used_half_open_permit = pending.used_half_open_permit;
let selected_is_failover = pending.selected_is_failover; let selected_is_failover = pending.selected_is_failover;
let provider_health = pending.provider_health;
match prepare_response( match prepare_response(
state.clone(), state.clone(),
pending.response, pending.response,
@@ -321,18 +323,18 @@ pub(crate) async fn handle_pi_native(
route.app_config.non_streaming_timeout, route.app_config.non_streaming_timeout,
used_half_open_permit, used_half_open_permit,
selected_is_failover, selected_is_failover,
provider_health.clone(),
) )
.await .await
{ {
Ok(prepared) => { Ok(prepared) => {
if !prepared.finalization_deferred { if !prepared.finalization_deferred {
record_provider_result( settle_provider_health(
&state, &state,
route.catalog_epoch, route.catalog_epoch,
&provider_id, &provider_id,
used_half_open_permit, used_half_open_permit,
false, provider_health,
Some(format!("Pi upstream returned {status}")),
) )
.await; .await;
record_request_finish( record_request_finish(
@@ -374,6 +376,7 @@ struct PendingRetryableResponse {
provider_id: String, provider_id: String,
used_half_open_permit: bool, used_half_open_permit: bool,
selected_is_failover: bool, selected_is_failover: bool,
provider_health: ProviderHealthOutcome,
} }
#[derive(Debug)] #[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<String>,
}
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( async fn release_or_record_provider(
state: &ProxyState, state: &ProxyState,
catalog_epoch: u64, catalog_epoch: u64,
provider_id: &str, provider_id: &str,
used_half_open_permit: bool, used_half_open_permit: bool,
reached_network: bool, provider_health_failure: Option<String>,
saw_health_failure: bool,
error: Option<String>,
) { ) {
if reached_network && saw_health_failure { if provider_health_failure.is_some() {
record_provider_result( record_provider_result(
state, state,
catalog_epoch, catalog_epoch,
provider_id, provider_id,
used_half_open_permit, used_half_open_permit,
false, false,
error, provider_health_failure,
) )
.await; .await;
} else { } else {
@@ -714,6 +807,7 @@ struct PiStreamFinalization {
content_is_sse: bool, content_is_sse: bool,
used_half_open_permit: bool, used_half_open_permit: bool,
selected_is_failover: bool, selected_is_failover: bool,
complete_provider_health: ProviderHealthOutcome,
} }
enum PiStreamTermination { enum PiStreamTermination {
@@ -723,7 +817,7 @@ enum PiStreamTermination {
} }
struct PiStreamDisposition { struct PiStreamDisposition {
provider_success: Option<bool>, provider_health: ProviderHealthDisposition,
provider_error: Option<String>, provider_error: Option<String>,
request_success: bool, request_success: bool,
request_error: Option<String>, request_error: Option<String>,
@@ -731,29 +825,25 @@ struct PiStreamDisposition {
fn pi_stream_disposition( fn pi_stream_disposition(
status: StatusCode, status: StatusCode,
complete_provider_health: &ProviderHealthOutcome,
termination: &PiStreamTermination, termination: &PiStreamTermination,
) -> PiStreamDisposition { ) -> PiStreamDisposition {
match termination { match termination {
PiStreamTermination::Complete { .. } => { PiStreamTermination::Complete { .. } => PiStreamDisposition {
let provider_success = !retryable_status(status); provider_health: complete_provider_health.disposition,
PiStreamDisposition { provider_error: complete_provider_health.error.clone(),
provider_success: Some(provider_success), request_success: status.is_success(),
provider_error: (!provider_success) request_error: (!status.is_success()).then(|| format!("Pi upstream returned {status}")),
.then(|| format!("Pi upstream returned {status}")), },
request_success: status.is_success(),
request_error: (!status.is_success())
.then(|| format!("Pi upstream returned {status}")),
}
}
PiStreamTermination::UpstreamFailure { message } => PiStreamDisposition { PiStreamTermination::UpstreamFailure { message } => PiStreamDisposition {
provider_success: Some(false), provider_health: ProviderHealthDisposition::Unhealthy,
provider_error: Some(message.clone()), provider_error: Some(message.clone()),
request_success: false, request_success: false,
request_error: Some(message.clone()), request_error: Some(message.clone()),
}, },
PiStreamTermination::DownstreamDropped => PiStreamDisposition { PiStreamTermination::DownstreamDropped => PiStreamDisposition {
// A downstream cancellation says nothing about upstream health. // A downstream cancellation says nothing about upstream health.
provider_success: None, provider_health: ProviderHealthDisposition::Neutral,
provider_error: None, provider_error: None,
request_success: false, request_success: false,
request_error: Some( request_error: Some(
@@ -801,27 +891,19 @@ impl PiStreamFinalization {
} }
async fn apply(self, termination: PiStreamTermination) { async fn apply(self, termination: PiStreamTermination) {
let disposition = pi_stream_disposition(self.status, &termination); let disposition =
if let Some(provider_success) = disposition.provider_success { pi_stream_disposition(self.status, &self.complete_provider_health, &termination);
record_provider_result( settle_provider_health(
&self.state, &self.state,
self.catalog_epoch, self.catalog_epoch,
&self.candidate.provider_id, &self.candidate.provider_id,
self.used_half_open_permit, self.used_half_open_permit,
provider_success, ProviderHealthOutcome {
disposition.provider_error, disposition: disposition.provider_health,
) error: disposition.provider_error,
.await; },
} else { )
self.state .await;
.provider_router
.release_permit_neutral(
&self.candidate.provider_id,
"pi",
self.used_half_open_permit,
)
.await;
}
record_request_finish( record_request_finish(
&self.state, &self.state,
disposition.request_success, disposition.request_success,
@@ -921,6 +1003,7 @@ async fn prepare_response(
non_streaming_timeout_seconds: u32, non_streaming_timeout_seconds: u32,
used_half_open_permit: bool, used_half_open_permit: bool,
selected_is_failover: bool, selected_is_failover: bool,
complete_provider_health: ProviderHealthOutcome,
) -> Result<PreparedPiResponse, ProxyError> { ) -> Result<PreparedPiResponse, ProxyError> {
let status = response.status(); let status = response.status();
let headers = filtered_response_headers(response.headers()); let headers = filtered_response_headers(response.headers());
@@ -1019,6 +1102,7 @@ async fn prepare_response(
streaming_idle_timeout_seconds, streaming_idle_timeout_seconds,
used_half_open_permit, used_half_open_permit,
selected_is_failover, selected_is_failover,
complete_provider_health,
); );
let mut builder = Response::builder().status(status); let mut builder = Response::builder().status(status);
*builder *builder
@@ -1138,6 +1222,7 @@ fn logged_body_stream(
streaming_idle_timeout_seconds: u32, streaming_idle_timeout_seconds: u32,
used_half_open_permit: bool, used_half_open_permit: bool,
selected_is_failover: bool, selected_is_failover: bool,
complete_provider_health: ProviderHealthOutcome,
) -> impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static { ) -> impl futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static {
// Construct the guard before the generator is polled. Axum may drop a // Construct the guard before the generator is polled. Axum may drop a
// response body without ever polling it when the client disconnects after // response body without ever polling it when the client disconnects after
@@ -1154,6 +1239,7 @@ fn logged_body_stream(
content_is_sse, content_is_sse,
used_half_open_permit, used_half_open_permit,
selected_is_failover, selected_is_failover,
complete_provider_health,
}); });
async_stream::stream! { async_stream::stream! {
let mut captured = Vec::new(); 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] #[test]
fn sse_comments_do_not_cross_the_commit_fence() { fn sse_comments_do_not_cross_the_commit_fence() {
assert!(!contains_semantic_sse_event( assert!(!contains_semantic_sse_event(
@@ -1497,33 +1634,61 @@ mod tests {
fn streaming_health_waits_for_the_terminal_outcome() { fn streaming_health_waits_for_the_terminal_outcome() {
let complete = pi_stream_disposition( let complete = pi_stream_disposition(
StatusCode::OK, StatusCode::OK,
&ProviderHealthOutcome::from_status(StatusCode::OK, None),
&PiStreamTermination::Complete { &PiStreamTermination::Complete {
captured: Some(Vec::new()), captured: Some(Vec::new()),
}, },
); );
assert_eq!(complete.provider_success, Some(true)); assert_eq!(complete.provider_health, ProviderHealthDisposition::Healthy);
assert!(complete.request_success); assert!(complete.request_success);
let truncated = pi_stream_disposition( let truncated = pi_stream_disposition(
StatusCode::OK, StatusCode::OK,
&ProviderHealthOutcome::from_status(StatusCode::OK, None),
&PiStreamTermination::UpstreamFailure { &PiStreamTermination::UpstreamFailure {
message: "truncated".to_string(), message: "truncated".to_string(),
}, },
); );
assert_eq!(truncated.provider_success, Some(false)); assert_eq!(
truncated.provider_health,
ProviderHealthDisposition::Unhealthy
);
assert!(!truncated.request_success); assert!(!truncated.request_success);
assert_eq!(truncated.provider_error.as_deref(), Some("truncated")); assert_eq!(truncated.provider_error.as_deref(), Some("truncated"));
let client_drop = let client_drop = pi_stream_disposition(
pi_stream_disposition(StatusCode::OK, &PiStreamTermination::DownstreamDropped); StatusCode::OK,
assert_eq!(client_drop.provider_success, None); &ProviderHealthOutcome::from_status(StatusCode::OK, None),
&PiStreamTermination::DownstreamDropped,
);
assert_eq!(
client_drop.provider_health,
ProviderHealthDisposition::Neutral
);
assert!(!client_drop.request_success); assert!(!client_drop.request_success);
let non_retryable_error = pi_stream_disposition( let non_retryable_error = pi_stream_disposition(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
&ProviderHealthOutcome::from_status(StatusCode::BAD_REQUEST, None),
&PiStreamTermination::Complete { captured: 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); 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);
}
} }
} }
+144 -2
View File
@@ -431,6 +431,7 @@
"themeSystem": "システム", "themeSystem": "システム",
"importExport": "SQL インポート/エクスポート", "importExport": "SQL インポート/エクスポート",
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。", "importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。",
"piImportExportBoundary": "Pi のプロバイダーと管理設定は移行できます。ネイティブ models.json の所有権は、同じキーが空の場合にのみ再構築されます。ネイティブ指示ファイル、プロンプトファイル、Skill の配置、セッションは端末内に残り、インポートでは上書きされません。",
"exportConfig": "SQL バックアップをエクスポート", "exportConfig": "SQL バックアップをエクスポート",
"selectConfigFile": "SQL ファイルを選択", "selectConfigFile": "SQL ファイルを選択",
"noFileSelected": "ファイルが選択されていません。", "noFileSelected": "ファイルが選択されていません。",
@@ -767,6 +768,8 @@
"openclawConfigDirDescription": "OpenClaw の設定ディレクトリ(openclaw.json)を上書きします。", "openclawConfigDirDescription": "OpenClaw の設定ディレクトリ(openclaw.json)を上書きします。",
"hermesConfigDir": "Hermes 設定ディレクトリ", "hermesConfigDir": "Hermes 設定ディレクトリ",
"hermesConfigDirDescription": "Hermes の設定ディレクトリ(config.yaml)を上書きします。", "hermesConfigDirDescription": "Hermes の設定ディレクトリ(config.yaml)を上書きします。",
"piConfigDir": "Pi 設定ディレクトリ",
"piConfigDirDescription": "Pi の設定ディレクトリ(models.json、指示ファイル、プロンプト、Skills、セッション)を上書きします。",
"browsePlaceholderClaude": "例: /home/<your-username>/.claude", "browsePlaceholderClaude": "例: /home/<your-username>/.claude",
"browsePlaceholderCodex": "例: /home/<your-username>/.codex", "browsePlaceholderCodex": "例: /home/<your-username>/.codex",
"browsePlaceholderGemini": "例: /home/<your-username>/.gemini", "browsePlaceholderGemini": "例: /home/<your-username>/.gemini",
@@ -774,6 +777,7 @@
"browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode", "browsePlaceholderOpencode": "例: /home/<your-username>/.config/opencode",
"browsePlaceholderOpenclaw": "例: /home/<your-username>/.openclaw", "browsePlaceholderOpenclaw": "例: /home/<your-username>/.openclaw",
"browsePlaceholderHermes": "例: /home/<your-username>/.hermes", "browsePlaceholderHermes": "例: /home/<your-username>/.hermes",
"browsePlaceholderPi": "例: /home/<your-username>/.pi/agent",
"browseDirectory": "ディレクトリを選択", "browseDirectory": "ディレクトリを選択",
"resetDefault": "デフォルトに戻す(保存後に反映)", "resetDefault": "デフォルトに戻す(保存後に反映)",
"checkForUpdates": "アップデートを確認", "checkForUpdates": "アップデートを確認",
@@ -884,7 +888,119 @@
"grokbuild": "Grok Build", "grokbuild": "Grok Build",
"opencode": "OpenCode", "opencode": "OpenCode",
"openclaw": "OpenClaw", "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/<slug>.md の各ファイルは、Pi で /<slug> として呼び出せます。",
"newTemplate": "新しいプロンプトテンプレート",
"templateSlug": "テンプレートスラッグ(例: review)",
"templateContent": "テンプレート Markdown",
"createTemplate": "テンプレートを作成",
"templateCreated": "Pi プロンプトテンプレートを作成しました",
"templateSaved": "/{{slug}} を保存しました",
"templateDeleted": "/{{slug}} を削除しました",
"templateSaveFailed": "Pi プロンプトテンプレートを保存できませんでした",
"templateDeleteFailed": "Pi プロンプトテンプレートを削除できませんでした",
"deleteTemplateTitle": "/{{slug}} を削除しますか?",
"deleteTemplateMessage": "prompts/{{slug}}.md を完全に削除します。"
}
}, },
"grokBuild": { "grokBuild": {
"apiBackend": "API Backend", "apiBackend": "API Backend",
@@ -923,6 +1039,8 @@
"batchDeleting": "削除中...", "batchDeleting": "削除中...",
"loadingSessions": "セッションを読み込み中...", "loadingSessions": "セッションを読み込み中...",
"noSessions": "セッションが見つかりません", "noSessions": "セッションが見つかりません",
"piRelativeSessionDir": "Pi の sessionDir は Pi を起動したディレクトリからの相対パスであるため、グローバルブラウザーから安全に列挙できません。すべての Pi セッションをここで表示するには、sessionDir に絶対パスを設定してください:",
"piDiscoveryUnavailable": "Pi セッションを検査できませんでした: {{error}}",
"selectSession": "セッションを選択してください", "selectSession": "セッションを選択してください",
"noSummary": "概要なし", "noSummary": "概要なし",
"lastActive": "最終アクティブ", "lastActive": "最終アクティブ",
@@ -1503,7 +1621,8 @@
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini",
"opencode": "OpenCode", "opencode": "OpenCode",
"grokbuild": "Grok Build" "grokbuild": "Grok Build",
"pi": "Pi"
}, },
"rawInputLabel": "原始", "rawInputLabel": "原始",
"rebuildCodex": { "rebuildCodex": {
@@ -2377,6 +2496,18 @@
"importSelected": "選択をインポート ({{count}})", "importSelected": "選択をインポート ({{count}})",
"noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。", "noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。",
"unmanagedAvailable": "インポート可能なスキルがあります", "unmanagedAvailable": "インポート可能なスキルがあります",
"piStatus": {
"active": "有効",
"inactive": "無効",
"unmanagedActive": "有効(管理対象外)",
"desiredButMissing": "有効化済み(Pi で未検出)",
"foreignConflict": "管理対象外の配置先によりブロック",
"staleDeployment": "管理対象の配置が外部で変更されました",
"shadowed": "同名の別の Skill により隠されています",
"invalid": "Skill マニフェストが無効です",
"inspecting": "検査中...",
"inspectionUnavailable": "実際の状態を検査できません"
},
"foundIn": "発見場所", "foundIn": "発見場所",
"local": "ローカル", "local": "ローカル",
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除され、削除前にローカルバックアップが自動作成されます。", "uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除され、削除前にローカルバックアップが自動作成されます。",
@@ -2440,6 +2571,7 @@
"homepage": "ホームページ", "homepage": "ホームページ",
"endpoint": "API エンドポイント", "endpoint": "API エンドポイント",
"apiKey": "API Key", "apiKey": "API Key",
"api": "ネイティブ API プロトコル",
"icon": "アイコン", "icon": "アイコン",
"model": "モデル", "model": "モデル",
"haikuModel": "Haiku モデル", "haikuModel": "Haiku モデル",
@@ -2553,6 +2685,16 @@
} }
}, },
"proxy": { "proxy": {
"piGateway": {
"title": "Pi ゲートウェイ認証情報",
"description": "Pi とこのローカルゲートウェイ間でのみ使用する端末固有の認証情報をローテーションします。",
"rotate": "認証情報をローテーション",
"confirmTitle": "Pi ゲートウェイ認証情報をローテーションしますか?",
"confirmMessage": "現在のローカル認証情報は直ちに無効になります。実行中の Pi プロセスは投影済みの認証情報をメモリに保持しているため、再起動が必要です。",
"rotateSuccess": "Pi ゲートウェイ認証情報をローテーションしました",
"rotateFailed": "Pi ゲートウェイ認証情報をローテーションできませんでした",
"restartNotice": "次のリクエストを送信する前に、実行中のすべての Pi プロセスを再起動してください。"
},
"panel": { "panel": {
"serviceAddress": "サービスアドレス", "serviceAddress": "サービスアドレス",
"addressCopied": "アドレスをコピーしました", "addressCopied": "アドレスをコピーしました",
+144 -2
View File
@@ -431,6 +431,7 @@
"themeSystem": "跟隨系統", "themeSystem": "跟隨系統",
"importExport": "SQL 匯入匯出", "importExport": "SQL 匯入匯出",
"importExportHint": "匯入/匯出資料庫 SQL 備份(僅支援匯入由 CC Switch 匯出的備份),便於備份或遷移。", "importExportHint": "匯入/匯出資料庫 SQL 備份(僅支援匯入由 CC Switch 匯出的備份),便於備份或遷移。",
"piImportExportBoundary": "Pi 供應商與受管設定可移轉;僅在相同原生 key 為空時重建 models.json 所有權。原生指示檔案、提示檔案、Skill 部署與工作階段都保留在本機,匯入時絕不覆寫。",
"exportConfig": "匯出 SQL 備份", "exportConfig": "匯出 SQL 備份",
"selectConfigFile": "選擇 SQL 檔案", "selectConfigFile": "選擇 SQL 檔案",
"noFileSelected": "尚未選擇設定檔。", "noFileSelected": "尚未選擇設定檔。",
@@ -767,6 +768,8 @@
"openclawConfigDirDescription": "覆寫 OpenClaw 設定目錄 (openclaw.json)。", "openclawConfigDirDescription": "覆寫 OpenClaw 設定目錄 (openclaw.json)。",
"hermesConfigDir": "Hermes 設定目錄", "hermesConfigDir": "Hermes 設定目錄",
"hermesConfigDirDescription": "覆寫 Hermes 設定目錄 (config.yaml)。", "hermesConfigDirDescription": "覆寫 Hermes 設定目錄 (config.yaml)。",
"piConfigDir": "Pi 設定目錄",
"piConfigDirDescription": "覆寫 Pi 設定目錄(models.json、指示檔案、提示、Skills 與工作階段)。",
"browsePlaceholderClaude": "例如:/home/<您的帳號>/.claude", "browsePlaceholderClaude": "例如:/home/<您的帳號>/.claude",
"browsePlaceholderCodex": "例如:/home/<您的帳號>/.codex", "browsePlaceholderCodex": "例如:/home/<您的帳號>/.codex",
"browsePlaceholderGemini": "例如:/home/<您的帳號>/.gemini", "browsePlaceholderGemini": "例如:/home/<您的帳號>/.gemini",
@@ -774,6 +777,7 @@
"browsePlaceholderOpencode": "例如:/home/<您的帳號>/.config/opencode", "browsePlaceholderOpencode": "例如:/home/<您的帳號>/.config/opencode",
"browsePlaceholderOpenclaw": "例如:/home/<您的帳號>/.openclaw", "browsePlaceholderOpenclaw": "例如:/home/<您的帳號>/.openclaw",
"browsePlaceholderHermes": "例如:/home/<您的帳號>/.hermes", "browsePlaceholderHermes": "例如:/home/<您的帳號>/.hermes",
"browsePlaceholderPi": "例如:/home/<您的帳號>/.pi/agent",
"browseDirectory": "瀏覽目錄", "browseDirectory": "瀏覽目錄",
"resetDefault": "還原預設目錄(需儲存後生效)", "resetDefault": "還原預設目錄(需儲存後生效)",
"checkForUpdates": "檢查更新", "checkForUpdates": "檢查更新",
@@ -885,7 +889,119 @@
"grokbuild": "Grok Build", "grokbuild": "Grok Build",
"opencode": "OpenCode", "opencode": "OpenCode",
"openclaw": "OpenClaw", "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/<slug>.md 檔案都可在 Pi 中透過 /<slug> 呼叫。",
"newTemplate": "新增提示範本",
"templateSlug": "範本識別碼(例如:review",
"templateContent": "範本 Markdown",
"createTemplate": "建立範本",
"templateCreated": "Pi 提示範本已建立",
"templateSaved": "/{{slug}} 已儲存",
"templateDeleted": "/{{slug}} 已刪除",
"templateSaveFailed": "儲存 Pi 提示範本失敗",
"templateDeleteFailed": "刪除 Pi 提示範本失敗",
"deleteTemplateTitle": "刪除 /{{slug}}",
"deleteTemplateMessage": "此操作會永久刪除 prompts/{{slug}}.md。"
}
}, },
"grokBuild": { "grokBuild": {
"apiBackend": "API Backend", "apiBackend": "API Backend",
@@ -924,6 +1040,8 @@
"batchDeleting": "批次刪除中...", "batchDeleting": "批次刪除中...",
"loadingSessions": "載入工作階段中...", "loadingSessions": "載入工作階段中...",
"noSessions": "未發現工作階段", "noSessions": "未發現工作階段",
"piRelativeSessionDir": "Pi 的 sessionDir 是相對於啟動 Pi 時所在的目錄,因此全域瀏覽器無法安全列舉。請將 sessionDir 設為絕對路徑,之後即可在此瀏覽所有 Pi 工作階段:",
"piDiscoveryUnavailable": "無法檢查 Pi 工作階段:{{error}}",
"selectSession": "請選擇工作階段檢視詳情", "selectSession": "請選擇工作階段檢視詳情",
"noSummary": "暫無摘要", "noSummary": "暫無摘要",
"lastActive": "最近活躍", "lastActive": "最近活躍",
@@ -1504,7 +1622,8 @@
"codex": "Codex", "codex": "Codex",
"gemini": "Gemini", "gemini": "Gemini",
"opencode": "OpenCode", "opencode": "OpenCode",
"grokbuild": "Grok Build" "grokbuild": "Grok Build",
"pi": "Pi"
}, },
"rawInputLabel": "原始", "rawInputLabel": "原始",
"rebuildCodex": { "rebuildCodex": {
@@ -2378,6 +2497,18 @@
"importSelected": "匯入已選 ({{count}})", "importSelected": "匯入已選 ({{count}})",
"noUnmanagedFound": "未發現需要匯入的技能。所有技能已在 CC Switch 統一管理中。", "noUnmanagedFound": "未發現需要匯入的技能。所有技能已在 CC Switch 統一管理中。",
"unmanagedAvailable": "發現可匯入的技能", "unmanagedAvailable": "發現可匯入的技能",
"piStatus": {
"active": "已生效",
"inactive": "未啟用",
"unmanagedActive": "已生效但未受管",
"desiredButMissing": "已啟用但 Pi 未發現",
"foreignConflict": "遭未受管的目的地阻擋",
"staleDeployment": "受管部署已被外部修改",
"shadowed": "遭另一個同名 Skill 遮蔽",
"invalid": "Skill 資訊清單無效",
"inspecting": "檢查中...",
"inspectionUnavailable": "無法檢查實際狀態"
},
"foundIn": "發現於", "foundIn": "發現於",
"local": "本地", "local": "本地",
"uninstallConfirm": "確定要解除安裝技能 \"{{name}}\" 嗎?這將從所有應用程式中移除該技能,並在刪除前自動建立本地備份。", "uninstallConfirm": "確定要解除安裝技能 \"{{name}}\" 嗎?這將從所有應用程式中移除該技能,並在刪除前自動建立本地備份。",
@@ -2441,6 +2572,7 @@
"homepage": "官網位址", "homepage": "官網位址",
"endpoint": "API 端點", "endpoint": "API 端點",
"apiKey": "API 金鑰", "apiKey": "API 金鑰",
"api": "原生 API 通訊協定",
"icon": "圖示", "icon": "圖示",
"model": "模型", "model": "模型",
"haikuModel": "Haiku 模型", "haikuModel": "Haiku 模型",
@@ -2554,6 +2686,16 @@
} }
}, },
"proxy": { "proxy": {
"piGateway": {
"title": "Pi 閘道憑證",
"description": "輪換僅用於 Pi 與此本機閘道之間通訊的裝置端憑證。",
"rotate": "輪換憑證",
"confirmTitle": "輪換 Pi 閘道憑證?",
"confirmMessage": "目前的本機憑證會立即失效。執行中的 Pi 處理程序仍在記憶體中保留已投影的憑證,因此必須重新啟動。",
"rotateSuccess": "Pi 閘道憑證已輪換",
"rotateFailed": "Pi 閘道憑證輪換失敗",
"restartNotice": "再次傳送請求前,請重新啟動所有執行中的 Pi 處理程序。"
},
"panel": { "panel": {
"serviceAddress": "服務位址", "serviceAddress": "服務位址",
"addressCopied": "位址已複製", "addressCopied": "位址已複製",
+84
View File
@@ -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<string, unknown>;
function flattenStrings(
value: unknown,
path: string[] = [],
result = new Map<string, string>(),
): Map<string, string> {
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([]);
},
);
});