mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 10:14:56 +08:00
feat(logging): log provider switches and routing / aggregation mode changes (#7882)
* feat(logging): log provider switches and routing / aggregation mode changes Switching providers and entering or leaving the routing / aggregation modes left nothing in cc-switch.log, so "I clicked switch and nothing happened" reports could not be told apart from a rule refusal, a user cancel or a pending roll-forward. - [SWITCH]: every ProviderService::switch logs where it went (direct from -> to, or proxy route from -> to) and how it ended: done, done with warnings, refused by the official-provider rule (INFO), or failed (ERROR). - [MODE]: enter / attach, exit / detach, route changes and the direct-mode route memory log the mode transition (direct / routing / aggregation), the route or direct provider, and whether the client config was rewritten; failures log at ERROR. - Frontend: the mode dialogs log when they open and what the user picked (cancel, switch directly anyway, start routing, confirm with a pick), and the frontend-only switch gates log too. Fixes #7881 * fix(logging): drop quoted TOML source lines from switch / mode failure logs TOML parse diagnostics quote the offending config line, which can be the one holding a credential. Strip the quoted source lines before the new backend failure logs write them to cc-switch.log; line/column and the reason are kept.
This commit is contained in:
1 parent
c615c3eeba
commit
beff557e3c
6 files changed
+302
-22
No files matched your search
+37
-1
@@ -235,6 +235,22 @@ pub(crate) fn redact_url_origin_for_log(url_str: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 给日志用的错误文本:去掉 TOML 解析诊断里引用的源码行(`1 | key = "..."` 和它上下的
|
||||
/// `|`、`^` 标注行)。那一行是用户配置原文,出错的可能正是密钥那一行;行列号和原因留着。
|
||||
pub(crate) fn error_for_log(error: &str) -> String {
|
||||
error
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!line
|
||||
.trim_start()
|
||||
.trim_start_matches(|c: char| c.is_ascii_digit())
|
||||
.trim_start()
|
||||
.starts_with('|')
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn runtime_log_level_allows(level: log::Level, max_level: log::LevelFilter) -> bool {
|
||||
max_level.to_level().is_some_and(|maximum| level <= maximum)
|
||||
}
|
||||
@@ -2263,10 +2279,30 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
classify_exit_request, redact_url_for_log, redact_url_for_log_with_secrets,
|
||||
classify_exit_request, error_for_log, redact_url_for_log, redact_url_for_log_with_secrets,
|
||||
redact_url_origin_for_log, runtime_log_level_allows, ExitRequestAction,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn log_error_drops_toml_source_lines_but_keeps_position() {
|
||||
let secret = "sk-review-only-secret";
|
||||
let toml_edit_error = format!("experimental_bearer_token = \"{secret}\" !\n")
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
let toml_error = toml::from_str::<toml::Table>(&format!("token = \"{secret}\" !\n"))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
for error in [toml_edit_error, toml_error] {
|
||||
assert!(error.contains(secret), "前提:诊断里带源码行");
|
||||
let logged = error_for_log(&format!("无法解析:{error} (cannot parse: {error})"));
|
||||
assert!(!logged.contains(secret), "{logged}");
|
||||
assert!(logged.contains("line 1"), "{logged}");
|
||||
}
|
||||
// 普通错误原样保留。
|
||||
assert_eq!(error_for_log("供应商 a 不存在"), "供应商 a 不存在");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_url_redaction_strips_credentials_and_query_keeps_path() {
|
||||
// userinfo 与整个 query 剥离,path 保留用于诊断 base_url 配错。
|
||||
|
||||
@@ -247,6 +247,24 @@ fn settled_stack(app: &AppType) -> Result<StackState, String> {
|
||||
super::state::stack(&DeviceStore::for_device(), app.as_str()).map_err(err)
|
||||
}
|
||||
|
||||
/// 日志里的模式名。
|
||||
fn mode_label(proxy: bool, stack: bool) -> &'static str {
|
||||
match (proxy, stack) {
|
||||
(false, _) => "直连",
|
||||
(true, false) => "路由",
|
||||
(true, true) => "聚合",
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志里写没写客户端文件。
|
||||
fn rewrite_label(rewritten: bool) -> &'static str {
|
||||
if rewritten {
|
||||
"已重写"
|
||||
} else {
|
||||
"没变,未重写"
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布 Stack 模型的成员(见 [`stack::published_members`])。
|
||||
fn published_members(
|
||||
state: &AppState,
|
||||
@@ -263,7 +281,7 @@ fn commit_state(state: &AppState, app: &AppType, target: &PendingTarget) -> Resu
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
/// 写代理契约。`target` 的 `contract` 由这里填好。契约没变时不碰客户端文件;接上
|
||||
/// 写代理契约。`target` 的 `contract` 由这里填好。契约没变时不碰客户端文件(返回假);接上
|
||||
/// (启动时)一律重写:顺带核对路由供应商还能用(比如托管账号还在),并修正 CC Switch
|
||||
/// 没运行期间客户端文件里的漂移。
|
||||
///
|
||||
@@ -277,7 +295,7 @@ async fn write_proxy(
|
||||
live_now: &LiveNow,
|
||||
mut target: ModeState,
|
||||
next_stack: Option<StackState>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<bool, String> {
|
||||
let (proxy_url, codex_base_url) = state.proxy_service.build_proxy_urls().await?;
|
||||
// 改用 CC Switch 的目录时契约可能没变(指针只在 live 里),也要重写。
|
||||
let force = op_name == op::ATTACH || op_name == op::CATALOG;
|
||||
@@ -291,7 +309,7 @@ async fn write_proxy(
|
||||
stack: next_stack.clone(),
|
||||
..PendingTarget::default()
|
||||
};
|
||||
match app {
|
||||
let unchanged = match app {
|
||||
AppType::Claude => {
|
||||
let members = published_members(state, app, &stack, route)?;
|
||||
let published = stack::claude_published(&members);
|
||||
@@ -308,6 +326,7 @@ async fn write_proxy(
|
||||
pending(target),
|
||||
)
|
||||
.map_err(err)?;
|
||||
unchanged
|
||||
}
|
||||
AppType::Gemini => {
|
||||
let projection = GeminiProjection::proxy_contract(
|
||||
@@ -325,6 +344,7 @@ async fn write_proxy(
|
||||
pending(target),
|
||||
)
|
||||
.map_err(err)?;
|
||||
unchanged
|
||||
}
|
||||
AppType::Codex => {
|
||||
let owner = live_now.codex_owner();
|
||||
@@ -348,6 +368,7 @@ async fn write_proxy(
|
||||
} else {
|
||||
codex_direct::run(&state.db, op_name, planned, &prepared, pending).map_err(err)?;
|
||||
}
|
||||
unchanged
|
||||
}
|
||||
AppType::GrokBuild => {
|
||||
let base_url = format!("{}/grokbuild/v1", proxy_url.trim_end_matches('/'));
|
||||
@@ -368,10 +389,11 @@ async fn write_proxy(
|
||||
pending(target),
|
||||
)
|
||||
.map_err(err)?;
|
||||
unchanged
|
||||
}
|
||||
_ => return Err(format!("{} 不支持本地路由", app.as_str())),
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
Ok(!unchanged)
|
||||
}
|
||||
|
||||
/// 写回直连投影(直连指针的供应商)。
|
||||
@@ -564,6 +586,25 @@ pub async fn enter_with_route(
|
||||
app: &AppType,
|
||||
stack_mode: bool,
|
||||
route: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let result = try_enter_with_route(state, app, stack_mode, route).await;
|
||||
if let Err(error) = &result {
|
||||
log::error!(
|
||||
"[MODE] {} 进入{}模式失败(路由目标 {}): {}",
|
||||
app.as_str(),
|
||||
mode_label(true, stack_mode),
|
||||
route.unwrap_or("沿用上次"),
|
||||
crate::error_for_log(error)
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn try_enter_with_route(
|
||||
state: &AppState,
|
||||
app: &AppType,
|
||||
stack_mode: bool,
|
||||
route: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
require_proxy_app(app)?;
|
||||
if stack_mode && !stack::supports_stack(app) {
|
||||
@@ -630,8 +671,11 @@ async fn enter_locked(
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
// 只给日志用,读不到不拦着进入。
|
||||
let was_stack = settled_stack(app).is_ok_and(|stack| stack.enabled);
|
||||
let stack_on = stack_mode.unwrap_or(was_stack);
|
||||
let live_now = LiveNow::of(state, app, &mode)?;
|
||||
write_proxy(
|
||||
let rewritten = write_proxy(
|
||||
state,
|
||||
app,
|
||||
op_name,
|
||||
@@ -646,6 +690,14 @@ async fn enter_locked(
|
||||
next_stack,
|
||||
)
|
||||
.await?;
|
||||
log::info!(
|
||||
"[MODE] {} {op_name}:{} → {},路由 {},客户端配置{}",
|
||||
app.as_str(),
|
||||
mode_label(mode.is_proxy(), was_stack),
|
||||
mode_label(true, stack_on),
|
||||
route.id,
|
||||
rewrite_label(rewritten)
|
||||
);
|
||||
state.proxy_service.set_active_target(app, &route).await;
|
||||
warn_if_official_route(state, app, &route).await;
|
||||
Ok(())
|
||||
@@ -711,13 +763,48 @@ fn exit_locked(state: &AppState, app: &AppType, keep_mode: bool) -> Result<(), S
|
||||
return Ok(());
|
||||
}
|
||||
if !keep_mode && !mode.is_proxy() && !mode.attached {
|
||||
log::debug!("[MODE] {} 已经是直连,不用退出", app.as_str());
|
||||
return Ok(());
|
||||
}
|
||||
let live_now = LiveNow::of(state, app, &mode)?;
|
||||
let op_name = if keep_mode { op::DETACH } else { op::EXIT };
|
||||
let was_stack = settled_stack(app).is_ok_and(|stack| stack.enabled);
|
||||
let result = write_direct_for_exit(state, app, op_name, keep_mode, &mode);
|
||||
match &result {
|
||||
Ok(()) => log::info!(
|
||||
"[MODE] {} {op_name}:{} → {},客户端配置写回直连供应商 {}",
|
||||
app.as_str(),
|
||||
mode_label(mode.is_proxy(), was_stack),
|
||||
if keep_mode {
|
||||
"客户端指回直连,模式保留"
|
||||
} else {
|
||||
"直连"
|
||||
},
|
||||
direct_provider(state, app)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map_or_else(|| "(无)".to_string(), |provider| provider.id)
|
||||
),
|
||||
Err(error) => log::error!(
|
||||
"[MODE] {} {op_name} 失败: {}",
|
||||
app.as_str(),
|
||||
crate::error_for_log(error)
|
||||
),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn write_direct_for_exit(
|
||||
state: &AppState,
|
||||
app: &AppType,
|
||||
op_name: &str,
|
||||
keep_mode: bool,
|
||||
mode: &ModeState,
|
||||
) -> Result<(), String> {
|
||||
let live_now = LiveNow::of(state, app, mode)?;
|
||||
write_direct(
|
||||
state,
|
||||
app,
|
||||
if keep_mode { op::DETACH } else { op::EXIT },
|
||||
op_name,
|
||||
&live_now,
|
||||
ModeState {
|
||||
mode: Some(if keep_mode && mode.is_proxy() {
|
||||
@@ -726,7 +813,7 @@ fn exit_locked(state: &AppState, app: &AppType, keep_mode: bool) -> Result<(), S
|
||||
Mode::Direct
|
||||
}),
|
||||
attached: false,
|
||||
proxy_route: mode.proxy_route,
|
||||
proxy_route: mode.proxy_route.clone(),
|
||||
contract: None,
|
||||
},
|
||||
)
|
||||
@@ -836,7 +923,7 @@ pub async fn switch_route_locked(
|
||||
add_default(app, &mut next, target);
|
||||
}
|
||||
let next_stack = (next != current).then_some(next);
|
||||
if !mode.attached {
|
||||
let rewritten = if !mode.attached {
|
||||
commit_state(
|
||||
state,
|
||||
app,
|
||||
@@ -846,6 +933,7 @@ pub async fn switch_route_locked(
|
||||
..PendingTarget::default()
|
||||
},
|
||||
)?;
|
||||
false
|
||||
} else {
|
||||
let live_now = LiveNow::of(state, app, &mode)?;
|
||||
write_proxy(
|
||||
@@ -857,7 +945,19 @@ pub async fn switch_route_locked(
|
||||
new_state,
|
||||
next_stack,
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
};
|
||||
// 编辑路由那家之后也走这里重算契约:路由没换、文件也没动的不记。
|
||||
if rewritten || !mode.routes_to(&target.id) {
|
||||
log::info!(
|
||||
"[MODE] {} {}:{}模式路由 {} → {},客户端配置{}",
|
||||
app.as_str(),
|
||||
op::ROUTE,
|
||||
mode_label(true, current.enabled),
|
||||
mode.proxy_route.as_deref().unwrap_or("(无)"),
|
||||
target.id,
|
||||
rewrite_label(rewritten)
|
||||
);
|
||||
}
|
||||
state.proxy_service.set_active_target(app, target).await;
|
||||
Ok(())
|
||||
@@ -917,6 +1017,7 @@ pub async fn set_route(state: &AppState, app: &AppType, provider_id: &str) -> Re
|
||||
if mode.proxy_route.as_deref() == Some(provider_id) {
|
||||
return Ok(());
|
||||
}
|
||||
let previous = mode.proxy_route.clone();
|
||||
commit_state(
|
||||
state,
|
||||
app,
|
||||
@@ -927,7 +1028,14 @@ pub async fn set_route(state: &AppState, app: &AppType, provider_id: &str) -> Re
|
||||
}),
|
||||
..PendingTarget::default()
|
||||
},
|
||||
)
|
||||
)?;
|
||||
log::info!(
|
||||
"[MODE] {} 直连模式下记下路由 {} → {},下次进入路由 / 聚合模式时用",
|
||||
app.as_str(),
|
||||
previous.as_deref().unwrap_or("(无)"),
|
||||
provider_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 代理模式下不能切到不支持代理的官方供应商(Codex 官方账号走客户端自己的登录,除外)。
|
||||
@@ -1069,7 +1177,9 @@ async fn set_stack_member_locked(
|
||||
match attached_route(state, app)? {
|
||||
Some((mode, route)) => {
|
||||
let live_now = LiveNow::of(state, app, &mode)?;
|
||||
write_proxy(state, app, op::STACK, &route, &live_now, mode, Some(next)).await
|
||||
write_proxy(state, app, op::STACK, &route, &live_now, mode, Some(next))
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
None => commit_state(
|
||||
state,
|
||||
@@ -6231,6 +6341,34 @@ model_provider = "c"
|
||||
);
|
||||
}
|
||||
|
||||
/// 行里的 TOML 坏在密钥那一行:解析诊断会带上这行原文,失败日志不能把它写进日志文件。
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn failure_logs_drop_the_config_line_a_broken_row_quotes() {
|
||||
let _home = Home::new();
|
||||
seed_codex("", None);
|
||||
let secret = "sk-review-only-secret";
|
||||
let mut rows = codex_stack_rows().to_vec();
|
||||
let mut broken = codex_native("broken", "https://b.example/v1", "", None);
|
||||
broken.settings_config["config"] =
|
||||
json!(format!("experimental_bearer_token = \"{secret}\" !\n"));
|
||||
rows.push(broken);
|
||||
let state = state_with(AppType::Codex, &rows, "a").await;
|
||||
|
||||
let switch_error = ProviderService::switch(&state, AppType::Codex, "broken")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
let enter_error = enter_with_route(&state, &AppType::Codex, false, Some("broken"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
for error in [switch_error, enter_error] {
|
||||
assert!(error.contains(secret), "前提:错误里带配置原文 {error}");
|
||||
let logged = crate::error_for_log(&error);
|
||||
assert!(!logged.contains(secret), "{logged}");
|
||||
assert!(logged.contains("line 1"), "{logged}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn codex_official_accounts_cannot_be_stacked() {
|
||||
|
||||
@@ -6093,6 +6093,37 @@ impl ProviderService {
|
||||
/// - 直连模式:切换式应用只替换客户端文件里的关键字段(不回填),文件和指针在同一个
|
||||
/// 操作里提交;累加式应用按各自的规则写入。
|
||||
pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {
|
||||
let app = app_type.as_str().to_string();
|
||||
let result = Self::switch_inner(state, app_type, id);
|
||||
match &result {
|
||||
Ok(result) if result.warnings.is_empty() => {
|
||||
log::info!("[SWITCH] {app} 切到 {id} 完成");
|
||||
}
|
||||
Ok(result) => {
|
||||
log::warn!(
|
||||
"[SWITCH] {app} 切到 {id} 完成,有警告: {:?}",
|
||||
result.warnings
|
||||
);
|
||||
}
|
||||
// 规则拒绝:什么都没改。
|
||||
Err(error @ AppError::Localized { key, .. })
|
||||
if *key == "switch.official_blocked_by_proxy" =>
|
||||
{
|
||||
log::info!("[SWITCH] {app} 拒绝切到 {id}: {error}");
|
||||
}
|
||||
Err(error) => log::error!(
|
||||
"[SWITCH] {app} 切到 {id} 失败: {}",
|
||||
crate::error_for_log(&error.to_string())
|
||||
),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn switch_inner(
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
id: &str,
|
||||
) -> Result<SwitchResult, AppError> {
|
||||
if app_type == AppType::Pi {
|
||||
return pi::enable(state, id);
|
||||
}
|
||||
@@ -6136,7 +6167,15 @@ impl ProviderService {
|
||||
"Cannot switch to an official provider in routing mode. Using a proxy with official APIs may cause account bans.",
|
||||
));
|
||||
}
|
||||
log::info!("路由模式:{} 的代理路由切到 {}", app_type.as_str(), id);
|
||||
log::info!(
|
||||
"[SWITCH] {} 代理模式:路由 {} → {}",
|
||||
app_type.as_str(),
|
||||
crate::mode::current::mode_state(&app_type)
|
||||
.proxy_route
|
||||
.as_deref()
|
||||
.unwrap_or("(无)"),
|
||||
id
|
||||
);
|
||||
futures::executor::block_on(crate::mode::controller::switch_route_locked(
|
||||
state, &app_type, _provider,
|
||||
))
|
||||
@@ -6160,6 +6199,23 @@ impl ProviderService {
|
||||
.get(id)
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
|
||||
|
||||
if app_type.is_additive_mode() {
|
||||
log::info!("[SWITCH] {} 写入 {id}(累加式应用)", app_type.as_str());
|
||||
} else {
|
||||
let previous = crate::mode::current::provider_for(
|
||||
&state.db,
|
||||
&app_type,
|
||||
crate::mode::current::Purpose::Direct,
|
||||
)
|
||||
.ok()
|
||||
.flatten();
|
||||
log::info!(
|
||||
"[SWITCH] {} 直连:{} → {id},写客户端配置",
|
||||
app_type.as_str(),
|
||||
previous.as_deref().unwrap_or("(无)")
|
||||
);
|
||||
}
|
||||
|
||||
if matches!(app_type, AppType::Claude) {
|
||||
return Self::switch_claude_direct(state, provider, providers);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { APP_DISPLAY_NAME } from "@/components/shell/AppGlyph";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { logFrontendInfo } from "@/lib/frontendLogger";
|
||||
import { isOfficialAccount } from "@/utils/providerCapabilities";
|
||||
|
||||
/** 路由模式改写的客户端文件(确认框里写明,用默认位置)。 */
|
||||
@@ -34,6 +35,20 @@ export type ModeDialogState =
|
||||
| { kind: "enter"; target: Exclude<AppMode, "direct"> }
|
||||
| { kind: "needsRoute"; providerId: string; reason: string };
|
||||
|
||||
const MODE_LABEL: Record<Exclude<AppMode, "direct">, string> = {
|
||||
route: "路由",
|
||||
stack: "聚合",
|
||||
};
|
||||
|
||||
/** 框里的选择写进日志:取消、仍然直连切换、进入模式在日志里分得清。 */
|
||||
function logDialog(app: AppId, state: ModeDialogState, choice: string) {
|
||||
const subject =
|
||||
state.kind === "enter"
|
||||
? `进入${MODE_LABEL[state.target]}模式确认框`
|
||||
: `「${state.providerId} 需要路由」确认框`;
|
||||
logFrontendInfo(`[MODE] ${app} ${subject}:${choice}`);
|
||||
}
|
||||
|
||||
interface ModeDialogProps {
|
||||
app: AppId;
|
||||
state: ModeDialogState | null;
|
||||
@@ -57,18 +72,28 @@ interface ModeDialogProps {
|
||||
* 卡片上不再有「从这家开始」的按钮)。
|
||||
*/
|
||||
export function ModeDialog(props: ModeDialogProps) {
|
||||
const { state, onClose } = props;
|
||||
const { app, state, onClose } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (state) logDialog(app, state, "弹出");
|
||||
}, [app, state]);
|
||||
|
||||
const cancel = () => {
|
||||
if (state) logDialog(app, state, "取消");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={state !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog open={state !== null} onOpenChange={(open) => !open && cancel()}>
|
||||
{state && (
|
||||
<DialogContent
|
||||
zIndex="alert"
|
||||
className="max-w-[480px] gap-0 rounded-dialog border-border bg-surface p-6 shadow-v7-lg sm:rounded-dialog"
|
||||
>
|
||||
{state.kind === "enter" ? (
|
||||
<EnterBody {...props} state={state} />
|
||||
<EnterBody {...props} state={state} onCancel={cancel} />
|
||||
) : (
|
||||
<NeedsRouteBody {...props} state={state} />
|
||||
<NeedsRouteBody {...props} state={state} onCancel={cancel} />
|
||||
)}
|
||||
</DialogContent>
|
||||
)}
|
||||
@@ -85,9 +110,11 @@ function EnterBody({
|
||||
defaultPick,
|
||||
stackMembers,
|
||||
onClose,
|
||||
onCancel,
|
||||
onEnter,
|
||||
}: ModeDialogProps & {
|
||||
state: Extract<ModeDialogState, { kind: "enter" }>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const appName = APP_DISPLAY_NAME[app];
|
||||
@@ -145,6 +172,7 @@ function EnterBody({
|
||||
|
||||
const confirm = async () => {
|
||||
if (!pick || busy) return;
|
||||
logDialog(app, state, `确认,选 ${pick}`);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -240,7 +268,7 @@ function EnterBody({
|
||||
size="regular"
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onClick={onClose}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
@@ -265,10 +293,12 @@ function NeedsRouteBody({
|
||||
state,
|
||||
providers,
|
||||
onClose,
|
||||
onCancel,
|
||||
onEnter,
|
||||
onSwitchDirect,
|
||||
}: ModeDialogProps & {
|
||||
state: Extract<ModeDialogState, { kind: "needsRoute" }>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -277,6 +307,7 @@ function NeedsRouteBody({
|
||||
const name = provider?.name ?? state.providerId;
|
||||
|
||||
const route = async () => {
|
||||
logDialog(app, state, "开始路由并使用");
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -317,7 +348,7 @@ function NeedsRouteBody({
|
||||
size="regular"
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onClick={onClose}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
@@ -326,6 +357,7 @@ function NeedsRouteBody({
|
||||
size="regular"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
logDialog(app, state, "仍然直连切换");
|
||||
onClose();
|
||||
onSwitchDirect(state.providerId);
|
||||
}}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities";
|
||||
import { getRoutingReason } from "@/utils/routingReason";
|
||||
import { logFrontendInfo } from "@/lib/frontendLogger";
|
||||
|
||||
/**
|
||||
* Hook for managing provider actions (add, update, delete, switch)
|
||||
@@ -178,6 +179,9 @@ export function useProviderActions(
|
||||
: getRoutingReason(activeApp, provider, t);
|
||||
|
||||
if (proxyRequiredReason) {
|
||||
logFrontendInfo(
|
||||
`[SWITCH] ${activeApp} 直连切到 ${provider.id},提示需要路由:${proxyRequiredReason}`,
|
||||
);
|
||||
toast.warning(
|
||||
t("notifications.proxyRequiredForSwitch", {
|
||||
reason: proxyRequiredReason,
|
||||
@@ -198,6 +202,9 @@ export function useProviderActions(
|
||||
provider.category === "official" &&
|
||||
!officialSupportsTakeover
|
||||
) {
|
||||
logFrontendInfo(
|
||||
`[SWITCH] ${activeApp} 拒绝切到 ${provider.id}:路由模式下不能切到官方供应商`,
|
||||
);
|
||||
toast.error(
|
||||
t("notifications.officialBlockedByProxy", {
|
||||
defaultValue:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { error as writeErrorLog } from "@tauri-apps/plugin-log";
|
||||
import {
|
||||
error as writeErrorLog,
|
||||
info as writeInfoLog,
|
||||
} from "@tauri-apps/plugin-log";
|
||||
|
||||
const MAX_LOG_MESSAGE_LENGTH = 12_000;
|
||||
const MAX_RAW_LOG_INPUT_LENGTH = 16_000;
|
||||
@@ -322,6 +325,14 @@ export function reportFrontendError(
|
||||
void writeErrorLog(message, { file: "frontend" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** 用户动作(确认框里的选择等)写一行 INFO,和后端的切换日志对得上。 */
|
||||
export function logFrontendInfo(message: string): void {
|
||||
const redacted = redactFrontendLogText(
|
||||
truncateForProcessing(`[frontend] ${message}`, MAX_LOG_MESSAGE_LENGTH),
|
||||
);
|
||||
void writeInfoLog(redacted, { file: "frontend" }).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function installGlobalErrorHandlers(
|
||||
target: Window = window,
|
||||
): () => void {
|
||||
|
||||
Reference in new issue
Block a user