From 3b89dfac407b774718823fe1f40875b36945aada Mon Sep 17 00:00:00 2001 From: milome Date: Fri, 17 Apr 2026 03:52:36 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix(config):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=85=8D=E5=AF=B9=E7=8A=B6=E6=80=81=E8=AF=86=E5=88=AB=E4=B8=8E?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E9=85=8D=E5=AF=B9=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增状态驱动的已配对网关识别与列表,并补齐 paired_device 的 bootstrap 配对流程与错误提示。 --- src-tauri/src/gateway/auth.rs | 51 ++++- src-tauri/src/gateway/commands.rs | 114 +++++++++++ src-tauri/src/gateway/errors.rs | 32 +++ src-tauri/src/gateway/store.rs | 68 +++++++ src-tauri/src/gateway/types.rs | 38 +++- src-tauri/src/lib.rs | 1 + .../components/setup/OpenClawConfigModule.tsx | 188 +++++++++++++++++- src/app/components/setup/SetupWizard.tsx | 110 ++++++++-- src/app/contexts/I18nContext.tsx | 86 +++++++- src/app/contexts/OpenClawContext.tsx | 34 ++++ .../contexts/openClawConnectionPolicy.test.ts | 14 ++ src/app/contexts/openClawConnectionPolicy.ts | 4 +- src/app/contexts/openClawStorage.test.ts | 12 +- src/app/contexts/openClawStorage.ts | 8 +- 14 files changed, 717 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/gateway/auth.rs b/src-tauri/src/gateway/auth.rs index 8fc82d2..163938c 100644 --- a/src-tauri/src/gateway/auth.rs +++ b/src-tauri/src/gateway/auth.rs @@ -42,6 +42,19 @@ pub fn select_connect_auth( .map(ToOwned::to_owned), _ => None, }; + let stored_token = stored_device_token + .map(|entry| entry.token.trim()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let bootstrap_gateway_token = match config.auth_mode { + GatewayAuthMode::PairedDevice if stored_token.is_none() && !retry_with_stored_device_token => config + .auth_secret + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + _ => None, + }; let auth_password = match config.auth_mode { GatewayAuthMode::Password => config .auth_secret @@ -51,23 +64,22 @@ pub fn select_connect_auth( .map(ToOwned::to_owned), _ => None, }; - let stored_token = stored_device_token - .map(|entry| entry.token.trim()) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned); let should_use_stored_device_token = retry_with_stored_device_token || (matches!(config.auth_mode, GatewayAuthMode::PairedDevice) && explicit_gateway_token.is_none() + && bootstrap_gateway_token.is_none() && auth_password.is_none()); let resolved_device_token = if should_use_stored_device_token { stored_token.clone() } else { None }; + let auth_bootstrap_token = bootstrap_gateway_token.clone(); let auth_token = explicit_gateway_token .clone() - .or_else(|| resolved_device_token.clone()); + .or_else(|| resolved_device_token.clone()) + .or_else(|| auth_bootstrap_token.clone()); let auth_device_token = if retry_with_stored_device_token { stored_token.clone() } else { @@ -76,7 +88,7 @@ pub fn select_connect_auth( let auth = if auth_token.is_some() || auth_password.is_some() || auth_device_token.is_some() { Some(ConnectAuth { token: auth_token.clone(), - bootstrap_token: None, + bootstrap_token: auth_bootstrap_token, device_token: auth_device_token, password: auth_password, }) @@ -165,10 +177,37 @@ mod tests { let selected = select_connect_auth(&GatewayConnectConfig::default(), Some(&stored_entry()), false); let auth = selected.auth.expect("device auth payload"); assert_eq!(auth.token.as_deref(), Some("stored-device-token")); + assert_eq!(auth.bootstrap_token, None); assert_eq!(auth.device_token.as_deref(), None); assert_eq!(selected.resolved_device_token.as_deref(), Some("stored-device-token")); } + #[test] + fn paired_device_auth_uses_bootstrap_token_when_not_yet_paired() { + let mut config = GatewayConnectConfig::default(); + config.auth_secret = Some("shared-token".to_string()); + + let selected = select_connect_auth(&config, None, false); + let auth = selected.auth.expect("bootstrap auth payload"); + assert_eq!(auth.token.as_deref(), Some("shared-token")); + assert_eq!(auth.bootstrap_token.as_deref(), Some("shared-token")); + assert_eq!(auth.device_token, None); + assert_eq!(selected.signature_token.as_deref(), Some("shared-token")); + assert!(selected.resolved_device_token.is_none()); + } + + #[test] + fn paired_device_auth_prefers_cached_device_token_over_bootstrap_secret() { + let mut config = GatewayConnectConfig::default(); + config.auth_secret = Some("shared-token".to_string()); + + let selected = select_connect_auth(&config, Some(&stored_entry()), false); + let auth = selected.auth.expect("paired auth payload"); + assert_eq!(auth.token.as_deref(), Some("stored-device-token")); + assert_eq!(auth.bootstrap_token, None); + assert_eq!(selected.resolved_device_token.as_deref(), Some("stored-device-token")); + } + #[test] fn token_auth_retry_attaches_device_token_without_dropping_shared_token() { let mut config = GatewayConnectConfig::default(); diff --git a/src-tauri/src/gateway/commands.rs b/src-tauri/src/gateway/commands.rs index e4415a7..502b501 100644 --- a/src-tauri/src/gateway/commands.rs +++ b/src-tauri/src/gateway/commands.rs @@ -7,10 +7,12 @@ use url::Url; use crate::gateway::{ connector, + device_identity::GatewayDeviceIdentity, discovery, endpoint::GatewayEndpoint, errors::GatewayErrorSummary, state::GatewayAppState, + store::{list_device_auth_tokens, load_exact_device_auth_token, normalize_role, normalize_scopes}, types::{ GatewayAdvancedConnectionConfig, GatewayAgentFileGetResult, GatewayAgentIdentityResult, GatewayAgentMemoryIndexResult, @@ -20,6 +22,7 @@ use crate::gateway::{ GatewayAgentMemoryRuntimeStatusResult, GatewayAgentMemorySearchResult, GatewayAgentMemoryStatusResult, GatewayAgentMemoryTimelineAccessResult, GatewayAgentMemoryTimelineResult, + GatewayPairedEndpoint, GatewayPairingStatusKind, GatewayPairingStatusResult, GatewayAgentSettingsResult, GatewayAgentSettingsUpdateInput, GatewayAgentsListResult, GatewayConfigSetResult, GatewayConnectConfig, GatewayStatusSnapshot, }, @@ -85,6 +88,94 @@ pub async fn gateway_saved_endpoints() -> Result, Gate .map_err(|error| GatewayErrorSummary::from_error(&error)) } +#[tauri::command] +pub async fn gateway_pairing_status_lookup( + config: GatewayConnectConfig, +) -> Result { + let endpoint = GatewayEndpoint::from_config(&config) + .map_err(|error| GatewayErrorSummary::from_error(&error))?; + let paths = resolve_store_paths(); + let identity = GatewayDeviceIdentity::load_or_create(&paths) + .map_err(|error| GatewayErrorSummary::from_error(&error))?; + let normalized_role = normalize_role(&config.role); + let normalized_scopes = normalize_scopes(&config.scopes); + let saved_endpoints = load_saved_endpoints(&paths) + .map_err(|error| GatewayErrorSummary::from_error(&error))?; + let exact_token = load_exact_device_auth_token( + &paths, + &identity.device_id, + &endpoint.origin_key, + &normalized_role, + &normalized_scopes, + ) + .map_err(|error| GatewayErrorSummary::from_error(&error))?; + let tokens = list_device_auth_tokens(&paths, &identity.device_id, &normalized_role) + .map_err(|error| GatewayErrorSummary::from_error(&error))?; + + let mut known_paired_endpoints = tokens + .into_iter() + .map(|entry| { + let saved = saved_endpoints + .iter() + .find(|endpoint_item| endpoint_item.origin_key == entry.gateway_origin); + GatewayPairedEndpoint { + origin_key: entry.gateway_origin.clone(), + label: saved + .map(|endpoint_item| endpoint_item.label.clone()) + .unwrap_or_else(|| build_gateway_label_from_origin(entry.gateway_origin.as_str())), + ws_url: entry.gateway_origin.clone(), + http_url: http_url_from_origin(entry.gateway_origin.as_str()), + role: entry.role.clone(), + scopes: entry.scopes.clone(), + updated_at_ms: entry.updated_at_ms, + was_user_selected: saved.map(|endpoint_item| endpoint_item.was_user_selected).unwrap_or(false), + last_success_at_ms: saved.and_then(|endpoint_item| endpoint_item.last_success_at_ms), + exact_match: entry.gateway_origin == endpoint.origin_key, + } + }) + .collect::>(); + known_paired_endpoints.sort_by(|left, right| { + right + .exact_match + .cmp(&left.exact_match) + .then_with(|| right.was_user_selected.cmp(&left.was_user_selected)) + .then_with(|| right.last_success_at_ms.cmp(&left.last_success_at_ms)) + .then_with(|| right.updated_at_ms.cmp(&left.updated_at_ms)) + .then_with(|| left.label.cmp(&right.label)) + }); + known_paired_endpoints.dedup_by(|left, right| left.origin_key == right.origin_key); + + let saved_endpoint = saved_endpoints + .iter() + .find(|endpoint_item| endpoint_item.origin_key == endpoint.origin_key) + .cloned(); + let matched_endpoint = known_paired_endpoints + .iter() + .find(|endpoint_item| endpoint_item.exact_match) + .cloned(); + let paired_ready = exact_token.is_some(); + + Ok(GatewayPairingStatusResult { + origin_key: endpoint.origin_key.clone(), + label: saved_endpoint + .as_ref() + .map(|endpoint_item| endpoint_item.label.clone()) + .unwrap_or_else(|| build_gateway_label_from_origin(endpoint.origin_key.as_str())), + ws_url: endpoint.ws_url.clone(), + http_url: http_url_from_origin(endpoint.origin_key.as_str()), + status: if paired_ready { + GatewayPairingStatusKind::PairedReady + } else { + GatewayPairingStatusKind::BootstrapRequired + }, + paired_ready, + bootstrap_required: !paired_ready, + saved_endpoint, + matched_endpoint, + known_paired_endpoints, + }) +} + #[tauri::command] pub async fn gateway_select_endpoint( candidate: GatewayDiscoveredCandidate, @@ -617,3 +708,26 @@ fn export_markdown_root() -> Result { Ok(std::env::current_dir()?.join("exports")) } + +fn http_url_from_origin(origin: &str) -> Option { + if let Some(stripped) = origin.strip_prefix("ws://") { + return Some(format!("http://{stripped}")); + } + if let Some(stripped) = origin.strip_prefix("wss://") { + return Some(format!("https://{stripped}")); + } + None +} + +fn build_gateway_label_from_origin(origin: &str) -> String { + Url::parse(origin) + .ok() + .map(|url| { + if matches!(url.host_str(), Some("127.0.0.1" | "localhost")) { + "OpenClaw Local".to_string() + } else { + format!("OpenClaw {}", url.host_str().unwrap_or(origin)) + } + }) + .unwrap_or_else(|| origin.to_string()) +} diff --git a/src-tauri/src/gateway/errors.rs b/src-tauri/src/gateway/errors.rs index 9a04a30..a049c0d 100644 --- a/src-tauri/src/gateway/errors.rs +++ b/src-tauri/src/gateway/errors.rs @@ -12,6 +12,8 @@ use crate::gateway::protocol::{ CONNECT_ERROR_PAIRING_REQUIRED, }; +const CONNECT_ERROR_AUTH_TOKEN_REQUIRED: &str = "AUTH_TOKEN_REQUIRED"; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct GatewayErrorSummary { @@ -283,6 +285,11 @@ fn normalize_connect_rejection_code(code: Option<&str>, message: &str) -> Option if normalized_message.contains("gateway token mismatch") { return Some(CONNECT_ERROR_AUTH_TOKEN_MISMATCH.to_string()); } + if normalized_message.contains("gateway token missing") + || normalized_message.contains("token missing") + { + return Some(CONNECT_ERROR_AUTH_TOKEN_REQUIRED.to_string()); + } if normalized_message.contains("gateway password mismatch") { return Some(CONNECT_ERROR_AUTH_PASSWORD_MISMATCH.to_string()); } @@ -328,6 +335,9 @@ fn connect_rejection_message(code: Option<&str>, message: &str, pairing_reason: Some("not-paired") => "当前设备尚未完成配对,需要先在服务端批准当前设备。".to_string(), _ => "当前连接需要先完成设备配对批准。".to_string(), }, + Some(CONNECT_ERROR_AUTH_TOKEN_REQUIRED) => { + "当前连接缺少 Gateway Token,无法完成首次配对或重配。".to_string() + } _ => message.to_string(), } } @@ -361,6 +371,9 @@ fn connect_rejection_hint( Some(CONNECT_ERROR_AUTH_TOKEN_MISMATCH) if can_retry_with_device_token => Some( "共享 token 未通过校验,但服务端允许改用已签发的 device token。".to_string(), ), + Some(CONNECT_ERROR_AUTH_TOKEN_REQUIRED) => Some( + "当前是首次配对或重配,请在“已配对设备”模式下填写 Gateway Token 作为首次配对凭据。配对成功后会自动切换为 device token。".to_string(), + ), Some(CONNECT_ERROR_AUTH_TOKEN_MISMATCH) => { Some("请检查 Gateway token 是否与 OpenClaw 当前配置一致。".to_string()) } @@ -491,6 +504,25 @@ mod tests { Some("请检查 Gateway token 是否与 OpenClaw 当前配置一致。") ); } + + #[test] + fn token_missing_is_inferred_from_message_when_code_is_missing() { + let error = GatewayError::ConnectRejected { + code: None, + message: "unauthorized: gateway token missing (provide gateway auth token)".to_string(), + retryable: false, + can_retry_with_device_token: false, + recommended_next_step: Some("update_auth_configuration".to_string()), + pairing_reason: None, + }; + let summary = GatewayErrorSummary::from_error(&error); + assert_eq!(summary.category, "auth"); + assert_eq!(summary.code.as_deref(), Some(CONNECT_ERROR_AUTH_TOKEN_REQUIRED)); + assert_eq!( + summary.hint.as_deref(), + Some("当前是首次配对或重配,请在“已配对设备”模式下填写 Gateway Token 作为首次配对凭据。配对成功后会自动切换为 device token。") + ); + } } diff --git a/src-tauri/src/gateway/store.rs b/src-tauri/src/gateway/store.rs index 473070b..93f61d3 100644 --- a/src-tauri/src/gateway/store.rs +++ b/src-tauri/src/gateway/store.rs @@ -153,6 +153,48 @@ pub fn load_device_auth_token( Ok(legacy_fallback) } +pub fn load_exact_device_auth_token( + paths: &GatewayStorePaths, + device_id: &str, + gateway_origin: &str, + role: &str, + scopes: &[String], +) -> Result, GatewayError> { + let Some(store) = read_json::(&paths.device_auth_file)? else { + return Ok(None); + }; + if store.device_id != device_id { + return Ok(None); + } + + let normalized_origin = normalize_gateway_origin(gateway_origin); + let normalized_role = normalize_role(role); + let normalized_scopes = normalize_scopes(scopes); + let binding_key = device_auth_binding_key(&normalized_origin, &normalized_role, &normalized_scopes); + Ok(store.tokens.get(&binding_key).cloned()) +} + +pub fn list_device_auth_tokens( + paths: &GatewayStorePaths, + device_id: &str, + role: &str, +) -> Result, GatewayError> { + let Some(store) = read_json::(&paths.device_auth_file)? else { + return Ok(Vec::new()); + }; + if store.device_id != device_id { + return Ok(Vec::new()); + } + + let normalized_role = normalize_role(role); + Ok(store + .tokens + .values() + .filter(|entry| entry.role == normalized_role) + .cloned() + .collect()) +} + pub fn store_device_auth_token( paths: &GatewayStorePaths, device_id: &str, @@ -541,6 +583,32 @@ mod tests { let _ = fs::remove_dir_all(paths.root); } + #[test] + fn exact_device_auth_lookup_does_not_fallback_to_other_origins() { + let paths = temp_paths(); + store_device_auth_token( + &paths, + "device-a", + "ws://192.168.1.112:18789", + "operator", + "token-lan", + &["operator.admin".to_string()], + ) + .expect("store lan token"); + + let loaded = load_exact_device_auth_token( + &paths, + "device-a", + "ws://127.0.0.1:18789", + "operator", + &["operator.admin".to_string()], + ) + .expect("load exact token"); + + assert!(loaded.is_none()); + let _ = fs::remove_dir_all(paths.root); + } + #[test] fn saved_endpoint_round_trips_and_marks_selected() { let paths = temp_paths(); diff --git a/src-tauri/src/gateway/types.rs b/src-tauri/src/gateway/types.rs index c4ef548..9ed52f6 100644 --- a/src-tauri/src/gateway/types.rs +++ b/src-tauri/src/gateway/types.rs @@ -61,6 +61,43 @@ pub struct GatewaySavedEndpoint { pub last_success_at_ms: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GatewayPairingStatusKind { + PairedReady, + BootstrapRequired, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GatewayPairedEndpoint { + pub origin_key: String, + pub label: String, + pub ws_url: String, + pub http_url: Option, + pub role: String, + pub scopes: Vec, + pub updated_at_ms: i64, + pub was_user_selected: bool, + pub last_success_at_ms: Option, + pub exact_match: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GatewayPairingStatusResult { + pub origin_key: String, + pub label: String, + pub ws_url: String, + pub http_url: Option, + pub status: GatewayPairingStatusKind, + pub paired_ready: bool, + pub bootstrap_required: bool, + pub saved_endpoint: Option, + pub matched_endpoint: Option, + pub known_paired_endpoints: Vec, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum GatewayAuthMode { @@ -884,4 +921,3 @@ mod tests { ); } } - diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 724b64e..222592d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ pub fn run() { gateway::commands::gateway_discover, gateway::commands::gateway_disconnect, gateway::commands::gateway_saved_endpoints, + gateway::commands::gateway_pairing_status_lookup, gateway::commands::gateway_select_endpoint, gateway::commands::gateway_remove_saved_endpoint, gateway::commands::gateway_agents_list, diff --git a/src/app/components/setup/OpenClawConfigModule.tsx b/src/app/components/setup/OpenClawConfigModule.tsx index 1f4c12d..c87d9b0 100644 --- a/src/app/components/setup/OpenClawConfigModule.tsx +++ b/src/app/components/setup/OpenClawConfigModule.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; -import { useOpenClaw, type AuthMode, type GatewayAdvancedConnectionConfig } from '../../contexts/OpenClawContext'; +import { gatewayPairingStatusLookup, useOpenClaw, type AuthMode, type GatewayAdvancedConnectionConfig, type GatewayPairingStatusResult, type GatewayPairedEndpoint } from '../../contexts/OpenClawContext'; import { CheckCircle2, Server, Shield, Globe, TerminalSquare, RefreshCw, XCircle, AlertCircle, RotateCcw, Trash2, Wifi } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { useI18n } from '../../contexts/I18nContext'; +import { isLoopbackGatewayUrl } from '../../contexts/openClawConnectionPolicy'; import { buildAvailableOpenClawConfigSections, resolveSelectedOpenClawConfigSection, @@ -47,15 +48,67 @@ export function OpenClawConfigModule() { const [advancedHeartbeatMs, setAdvancedHeartbeatMs] = useState(String(advancedConnectionConfig.heartbeatMs)); const [advancedProxyUrl, setAdvancedProxyUrl] = useState(advancedConnectionConfig.proxyUrl ?? ''); const [saveFeedback, setSaveFeedback] = useState<{ kind: 'success' | 'error'; message: string } | null>(null); - const authSecretRequired = authMode !== 'paired_device' && authSecret.trim().length === 0; - const authSecretRequiredMessage = authMode === 'token' ? t('setup.auth.requiredToken') : t('setup.auth.requiredPassword'); + const [pairingStatus, setPairingStatus] = useState(null); + const [pairingStatusLoading, setPairingStatusLoading] = useState(false); + const [authModeTouched, setAuthModeTouched] = useState(false); + const pairedReady = pairingStatus?.pairedReady ?? false; + const pairedDeviceBootstrapVisible = + authMode === 'paired_device' && + !pairedReady && + !pairingStatusLoading && + pairingStatus !== null && + Boolean(url.trim()); + const pairedDeviceBootstrapHintVisible = pairedDeviceBootstrapVisible && isLoopbackGatewayUrl(url); + const authSecretRequired = + ((authMode === 'token' || authMode === 'password') || pairedDeviceBootstrapVisible) && + authSecret.trim().length === 0; + const authSecretRequiredMessage = authMode === 'password' ? t('setup.auth.requiredPassword') : t('setup.auth.requiredToken'); useEffect(() => { setUrl(gatewayUrl); setAuthMode(savedAuthMode); setAuthSecret(savedAuthSecret); + setAuthModeTouched(false); }, [gatewayUrl, savedAuthMode, savedAuthSecret]); + useEffect(() => { + if (!url.trim()) { + setPairingStatus(null); + setPairingStatusLoading(false); + return; + } + + let cancelled = false; + setPairingStatus(null); + setPairingStatusLoading(true); + const timer = window.setTimeout(async () => { + try { + const next = await gatewayPairingStatusLookup(url); + if (cancelled) { + return; + } + setPairingStatus(next); + if (!authModeTouched && next.pairedReady) { + setAuthMode('paired_device'); + setAuthSecret(''); + } + } catch { + if (!cancelled) { + setPairingStatus(null); + } + } finally { + if (!cancelled) { + setPairingStatusLoading(false); + } + } + }, 220); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [authModeTouched, url]); + useEffect(() => { setAdvancedTimeoutMs(String(advancedConnectionConfig.timeoutMs)); setAdvancedHeartbeatMs(String(advancedConnectionConfig.heartbeatMs)); @@ -151,6 +204,15 @@ export function OpenClawConfigModule() { return new Date(value).toLocaleString(); }; + const formatPairedTimestamp = (value: number) => new Date(value).toLocaleString(); + + const handleUsePairedEndpoint = (endpoint: GatewayPairedEndpoint) => { + setUrl(endpoint.httpUrl ?? endpoint.wsUrl.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://')); + setAuthMode('paired_device'); + setAuthSecret(''); + setAuthModeTouched(false); + }; + const candidateConfidenceLabel = (confidence: (typeof discoveredGateways)[number]['confidence']) => { if (confidence === 'high') { return t('config.discovery.confidenceHigh'); @@ -353,7 +415,10 @@ export function OpenClawConfigModule() { setUrl(e.target.value)} + onChange={(e) => { + setUrl(e.target.value); + setAuthModeTouched(false); + }} placeholder="http://127.0.0.1:18789" className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 dark:bg-slate-950 border rounded-xl text-sm outline-none transition-all dark:text-slate-100 ${ !url @@ -365,6 +430,100 @@ export function OpenClawConfigModule() { {!url &&

{t('config.connection.urlRequired')}

} + {url && (pairingStatusLoading || pairingStatus) ? ( +
+
+
+
+ {t('setup.pairing.statusTitle')} +
+ {pairingStatusLoading ? ( +
{t('setup.pairing.detecting')}
+ ) : pairingStatus?.pairedReady ? ( + <> +
{t('setup.pairing.readyTitle')}
+
{t('setup.pairing.readyDesc')}
+ + ) : pairingStatus ? ( + <> +
{t('setup.pairing.bootstrapTitle')}
+
{t('setup.pairing.bootstrapDesc')}
+ + ) : null} +
+ {pairingStatus?.pairedReady ? ( + + ) : pairingStatus ? ( + + ) : null} +
+ +
+
+ {t('setup.pairing.knownTitle')} +
+ {pairingStatus?.knownPairedEndpoints?.length ? ( +
+ {pairingStatus.knownPairedEndpoints.map((endpoint) => ( +
+
+
+
+ {endpoint.label} + {endpoint.exactMatch ? ( + {t('setup.pairing.currentBadge')} + ) : null} + {endpoint.wasUserSelected ? ( + {t('setup.pairing.savedBadge')} + ) : null} +
+
{endpoint.httpUrl ?? endpoint.wsUrl}
+
+ {t('setup.pairing.lastSuccess', endpoint.lastSuccessAtMs ? formatTimestamp(endpoint.lastSuccessAtMs) : formatPairedTimestamp(endpoint.updatedAtMs))} +
+
+ +
+
+ ))} +
+ ) : ( +
+ {t('setup.pairing.knownEmpty')} +
+ )} +
+
+ ) : null} +
- {authMode !== 'paired_device' && ( + {(authMode !== 'paired_device' || pairedDeviceBootstrapVisible) && ( + {pairedDeviceBootstrapVisible ? ( + + ) : null}
setAuthSecret(e.target.value)} - placeholder={authMode === 'token' ? t('setup.ph.token') : t('setup.ph.pwd')} + placeholder={authMode === 'password' ? t('setup.ph.pwd') : t('setup.ph.token')} className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 dark:bg-slate-950 border rounded-xl text-sm outline-none transition-all dark:text-slate-100 ${ authSecretRequired ? 'border-red-300 dark:border-red-500/50 focus:ring-2 focus:ring-red-500/50 focus:border-red-500' @@ -412,6 +579,9 @@ export function OpenClawConfigModule() { {authSecretRequiredMessage}

)} + {pairedDeviceBootstrapHintVisible ? ( +

{t('setup.auth.pairedDeviceBootstrapHint')}

+ ) : null} )} @@ -638,7 +808,3 @@ export function OpenClawConfigModule() {
); } - - - - diff --git a/src/app/components/setup/SetupWizard.tsx b/src/app/components/setup/SetupWizard.tsx index 67f4a6e..8657a3d 100644 --- a/src/app/components/setup/SetupWizard.tsx +++ b/src/app/components/setup/SetupWizard.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; -import { useOpenClaw, type AuthMode } from '../../contexts/OpenClawContext'; +import { gatewayPairingStatusLookup, useOpenClaw, type AuthMode, type GatewayPairingStatusResult } from '../../contexts/OpenClawContext'; import { CheckCircle2, XCircle, RefreshCw, Server, Shield, Globe, TerminalSquare, LayoutGrid, Cpu, Check, AlertCircle, ChevronRight, Moon, Sun } from 'lucide-react'; import { useI18n, LANGUAGES } from '../../contexts/I18nContext'; import { useTheme } from 'next-themes'; +import { isLoopbackGatewayUrl } from '../../contexts/openClawConnectionPolicy'; import appLogo from '../../../assets/270226c058e3f12ad7bb9e96e3b029bc0e2c0461.png'; const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:18789'; @@ -38,12 +39,25 @@ export function SetupWizard() { const [isSaving, setIsSaving] = useState(false); const [testResult, setTestResult] = useState<'none' | 'success' | 'fail'>('none'); const [isDetecting, setIsDetecting] = useState(false); + const [pairingStatus, setPairingStatus] = useState(null); + const [pairingStatusLoading, setPairingStatusLoading] = useState(false); + const [authModeTouched, setAuthModeTouched] = useState(false); const shouldShowWizard = isSetupWizardOpen || (!isConfigured && !hasSkippedSetup); const isPairingRequired = lastError?.code === 'PAIRING_REQUIRED'; const isTokenMismatch = lastError?.code === 'AUTH_TOKEN_MISMATCH'; - const authSecretRequired = authMode !== 'paired_device' && authSecret.trim().length === 0; - const authSecretRequiredMessage = authMode === 'token' ? t('setup.auth.requiredToken') : t('setup.auth.requiredPassword'); + const pairedReady = pairingStatus?.pairedReady ?? false; + const pairedDeviceBootstrapVisible = + authMode === 'paired_device' && + !pairedReady && + !pairingStatusLoading && + pairingStatus !== null && + Boolean(url.trim()); + const pairedDeviceBootstrapHintVisible = pairedDeviceBootstrapVisible && isLoopbackGatewayUrl(url); + const authSecretRequired = + ((authMode === 'token' || authMode === 'password') || pairedDeviceBootstrapVisible) && + authSecret.trim().length === 0; + const authSecretRequiredMessage = authMode === 'password' ? t('setup.auth.requiredPassword') : t('setup.auth.requiredToken'); useEffect(() => { setMounted(true); @@ -70,8 +84,47 @@ export function SetupWizard() { setIsSaving(false); setTestResult('none'); setIsDetecting(false); + setAuthModeTouched(false); }, [shouldShowWizard, gatewayUrl, savedAuthMode, savedAuthSecret]); + useEffect(() => { + if (!shouldShowWizard || !url.trim()) { + setPairingStatus(null); + setPairingStatusLoading(false); + return; + } + + let cancelled = false; + setPairingStatus(null); + setPairingStatusLoading(true); + const timer = window.setTimeout(async () => { + try { + const next = await gatewayPairingStatusLookup(url); + if (cancelled) { + return; + } + setPairingStatus(next); + if (!authModeTouched && next.pairedReady) { + setAuthMode('paired_device'); + setAuthSecret(''); + } + } catch { + if (!cancelled) { + setPairingStatus(null); + } + } finally { + if (!cancelled) { + setPairingStatusLoading(false); + } + } + }, 220); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [authModeTouched, shouldShowWizard, url]); + const handleThemeToggle = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); setRipplePos({ @@ -99,6 +152,7 @@ export function SetupWizard() { setUrl(DEFAULT_GATEWAY_URL); setAuthMode('paired_device'); setAuthSecret(''); + setAuthModeTouched(false); setIsDetecting(false); }, 800); }; @@ -312,7 +366,10 @@ export function SetupWizard() { setUrl(e.target.value)} + onChange={(e) => { + setUrl(e.target.value); + setAuthModeTouched(false); + }} placeholder={DEFAULT_GATEWAY_URL} className={`w-full pl-10 pr-4 py-3 bg-white dark:bg-slate-900 border rounded-xl text-sm outline-none transition-all dark:text-slate-100 ${ !url @@ -328,6 +385,27 @@ export function SetupWizard() { )} + {url && (pairingStatusLoading || pairingStatus) ? ( +
+
+ {t('setup.pairing.statusTitle')} +
+ {pairingStatusLoading ? ( +
{t('setup.pairing.detecting')}
+ ) : pairingStatus?.pairedReady ? ( + <> +
{t('setup.pairing.readyTitle')}
+
{t('setup.pairing.readyDesc')}
+ + ) : pairingStatus ? ( + <> +
{t('setup.pairing.bootstrapTitle')}
+
{t('setup.pairing.bootstrapDesc')}
+ + ) : null} +
+ ) : null} +
- {authMode !== 'paired_device' && ( + {(authMode !== 'paired_device' || pairedDeviceBootstrapVisible) && ( + {pairedDeviceBootstrapVisible ? ( + + ) : null}
setAuthSecret(e.target.value)} - placeholder={authMode === 'token' ? t('setup.ph.token') : t('setup.ph.pwd')} + placeholder={authMode === 'password' ? t('setup.ph.pwd') : t('setup.ph.token')} className={`w-full pl-10 pr-4 py-3 bg-white dark:bg-slate-900 border rounded-xl text-sm outline-none transition-all dark:text-slate-100 ${ authSecretRequired ? 'border-red-300 dark:border-red-500/50 focus:ring-2 focus:ring-red-500/50 focus:border-red-500' @@ -375,7 +461,11 @@ export function SetupWizard() { {authSecretRequiredMessage}

)} -

{t('setup.hint.token1')} openclaw config get gateway.auth.token {t('setup.hint.token2')}

+ {pairedDeviceBootstrapHintVisible ? ( +

{t('setup.auth.pairedDeviceBootstrapHint')}

+ ) : ( +

{t('setup.hint.token1')} openclaw config get gateway.auth.token {t('setup.hint.token2')}

+ )} )} @@ -541,7 +631,3 @@ export function SetupWizard() { } - - - - diff --git a/src/app/contexts/I18nContext.tsx b/src/app/contexts/I18nContext.tsx index eafbc23..a74ff91 100644 --- a/src/app/contexts/I18nContext.tsx +++ b/src/app/contexts/I18nContext.tsx @@ -2867,9 +2867,89 @@ const BASE_DICT: Record = { "setup.auth.token": ["Token Auth", "Token认证", "Token認證"], "setup.auth.pwd": ["Password Auth", "Password认证", "Password認證"], "setup.auth.pairedDeviceHint": [ - "Use the device token issued to this paired device. First-time connection still requires pairing approval.", - "使用当前已配对设备签发的 device token 连接;首次连接仍需先完成配对批准。", - "使用目前已配對裝置簽發的 device token 連線;首次連線仍需先完成配對批准。", + "Use the device token issued to this paired device. For first pairing or re-pairing, provide the Gateway Token below as a bootstrap credential, approve on the host if prompted, then save again.", + "使用当前已配对设备签发的 device token 连接;首次配对或重配时,请在下方填写 Gateway Token 作为首次配对凭据,若宿主机提示批准,再批准后重新保存。", + "使用目前已配對裝置簽發的 device token 連線;首次配對或重配時,請在下方填寫 Gateway Token 作為首次配對憑據,若主機提示批准,再批准後重新儲存。", + ], + "setup.auth.pairedDeviceBootstrapLabel": [ + "Gateway Token For First Pairing", + "首次配对 Gateway Token", + "首次配對 Gateway Token", + ], + "setup.auth.pairedDeviceBootstrapHint": [ + "Optional after the device is already paired. Required only for first pairing or re-pairing.", + "设备已配对后可留空;仅首次配对或重配时需要填写。", + "裝置已配對後可留空;僅首次配對或重配時需要填寫。", + ], + "setup.pairing.statusTitle": [ + "Pairing Status", + "配对状态", + "配對狀態", + ], + "setup.pairing.readyTitle": [ + "This gateway is already paired on this device", + "当前地址已在此设备上完成配对", + "目前位址已在此裝置上完成配對", + ], + "setup.pairing.readyDesc": [ + "A cached device token is available for the current gateway address. Paired Device is recommended.", + "当前网关地址已有可复用的 device token,推荐直接使用“已配对设备”。", + "目前網關位址已有可重用的 device token,建議直接使用「已配對裝置」。", + ], + "setup.pairing.bootstrapTitle": [ + "This gateway still needs first pairing", + "当前地址尚未完成首次配对", + "目前位址尚未完成首次配對", + ], + "setup.pairing.bootstrapDesc": [ + "Use Paired Device together with the Gateway Token once. After the host approves and returns a device token, future reconnects no longer need the shared token.", + "使用“已配对设备”并填写一次 Gateway Token 完成首次配对。宿主机批准并签发 device token 后,后续重连将不再需要共享 token。", + "使用「已配對裝置」並填寫一次 Gateway Token 完成首次配對。主機批准並簽發 device token 後,後續重連將不再需要共享 token。", + ], + "setup.pairing.detecting": [ + "Detecting pairing status...", + "正在检测配对状态...", + "正在檢測配對狀態...", + ], + "setup.pairing.usePaired": [ + "Use Paired Device", + "使用已配对设备", + "使用已配對裝置", + ], + "setup.pairing.startBootstrap": [ + "Start First Pairing", + "开始首次配对", + "開始首次配對", + ], + "setup.pairing.knownTitle": [ + "Known Paired Gateways", + "已配对网关", + "已配對網關", + ], + "setup.pairing.knownEmpty": [ + "No paired gateways are known on this device yet.", + "此设备上还没有已知的已配对网关。", + "此裝置上尚無已知的已配對網關。", + ], + "setup.pairing.useEndpoint": [ + "Use this address", + "使用此地址", + "使用此位址", + ], + "setup.pairing.currentBadge": [ + "Current URL", + "当前地址", + "目前位址", + ], + "setup.pairing.savedBadge": [ + "Saved", + "已保存", + "已儲存", + ], + "setup.pairing.lastSuccess": [ + "Last success: {0}", + "最近成功: {0}", + "最近成功: {0}", ], "setup.ph.token": [ "Please enter Gateway Token", diff --git a/src/app/contexts/OpenClawContext.tsx b/src/app/contexts/OpenClawContext.tsx index 04643f2..e8a61c9 100644 --- a/src/app/contexts/OpenClawContext.tsx +++ b/src/app/contexts/OpenClawContext.tsx @@ -102,6 +102,34 @@ export interface GatewaySavedEndpoint { lastSuccessAtMs?: number | null; } +export type GatewayPairingStatusKind = 'paired_ready' | 'bootstrap_required'; + +export interface GatewayPairedEndpoint { + originKey: string; + label: string; + wsUrl: string; + httpUrl?: string | null; + role: string; + scopes: string[]; + updatedAtMs: number; + wasUserSelected: boolean; + lastSuccessAtMs?: number | null; + exactMatch: boolean; +} + +export interface GatewayPairingStatusResult { + originKey: string; + label: string; + wsUrl: string; + httpUrl?: string | null; + status: GatewayPairingStatusKind; + pairedReady: boolean; + bootstrapRequired: boolean; + savedEndpoint?: GatewaySavedEndpoint | null; + matchedEndpoint?: GatewayPairedEndpoint | null; + knownPairedEndpoints: GatewayPairedEndpoint[]; +} + interface GatewayAgentIdentitySummary { name?: string | null; theme?: string | null; @@ -891,6 +919,12 @@ export async function gatewaySavedEndpoints() { return invokeGateway('gateway_saved_endpoints'); } +export async function gatewayPairingStatusLookup(url: string) { + return invokeGateway('gateway_pairing_status_lookup', { + config: createConnectConfig(url, 'paired_device', ''), + }); +} + export async function gatewaySelectEndpoint(candidate: GatewayDiscoveredCandidate) { return invokeGateway('gateway_select_endpoint', { candidate, diff --git a/src/app/contexts/openClawConnectionPolicy.test.ts b/src/app/contexts/openClawConnectionPolicy.test.ts index e9bfa3b..3cf1360 100644 --- a/src/app/contexts/openClawConnectionPolicy.test.ts +++ b/src/app/contexts/openClawConnectionPolicy.test.ts @@ -78,6 +78,20 @@ describe('openClawConnectionPolicy', () => { }); }); + it('keeps paired_device bootstrap secret until pairing is completed', () => { + expect( + resolvePersistedAuthModeAfterConnect( + 'http://127.0.0.1:18789', + 'paired_device', + 'shared-token', + { isPaired: false }, + ), + ).toEqual({ + mode: 'paired_device', + secret: 'shared-token', + }); + }); + it('does not coerce remote token connections into paired device mode', () => { expect( resolvePersistedAuthModeAfterConnect( diff --git a/src/app/contexts/openClawConnectionPolicy.ts b/src/app/contexts/openClawConnectionPolicy.ts index f887a79..c2fab89 100644 --- a/src/app/contexts/openClawConnectionPolicy.ts +++ b/src/app/contexts/openClawConnectionPolicy.ts @@ -55,7 +55,7 @@ export function resolvePersistedAuthModeAfterConnect( requestedSecret: string, snapshot?: GatewayConnectionSnapshotLike | null, ) { - if (isLoopbackGatewayUrl(url) && requestedMode !== 'paired_device' && snapshot?.isPaired) { + if (isLoopbackGatewayUrl(url) && snapshot?.isPaired) { return { mode: 'paired_device' as const, secret: '', @@ -64,6 +64,6 @@ export function resolvePersistedAuthModeAfterConnect( return { mode: requestedMode, - secret: requestedMode === 'paired_device' ? '' : requestedSecret, + secret: requestedSecret, }; } diff --git a/src/app/contexts/openClawStorage.test.ts b/src/app/contexts/openClawStorage.test.ts index eb2f6b5..024cf4d 100644 --- a/src/app/contexts/openClawStorage.test.ts +++ b/src/app/contexts/openClawStorage.test.ts @@ -41,7 +41,15 @@ describe('openClawStorage migration', () => { expect(readStoredAuthSecret(localStorage)).toBe('gateway-password'); }); - it('normalizes paired_device secret to null', () => { - expect(normalizeAuthSecret('paired_device', ' should-be-ignored ')).toBeNull(); + it('restores paired_device bootstrap secret when present', () => { + localStorage.setItem(OPENCLAW_STORAGE_KEYS.authMode, 'paired_device'); + localStorage.setItem(OPENCLAW_STORAGE_KEYS.authSecret, 'bootstrap-token'); + + expect(readStoredAuthSecret(localStorage)).toBe('bootstrap-token'); + }); + + it('normalizes paired_device bootstrap secret like other auth secrets', () => { + expect(normalizeAuthSecret('paired_device', ' bootstrap-token ')).toBe('bootstrap-token'); + expect(normalizeAuthSecret('paired_device', ' ')).toBeNull(); }); }); diff --git a/src/app/contexts/openClawStorage.ts b/src/app/contexts/openClawStorage.ts index 6329142..3992d0a 100644 --- a/src/app/contexts/openClawStorage.ts +++ b/src/app/contexts/openClawStorage.ts @@ -25,18 +25,14 @@ export function readStoredAuthMode(storage: StorageReader): AuthMode { export function readStoredAuthSecret(storage: StorageReader): string { const mode = storage.getItem(OPENCLAW_STORAGE_KEYS.authMode); - if (mode === 'token' || mode === 'password') { + if (mode === 'token' || mode === 'password' || mode === 'paired_device') { return storage.getItem(OPENCLAW_STORAGE_KEYS.authSecret) || ''; } return ''; } -export function normalizeAuthSecret(mode: AuthMode, secret: string): string | null { - if (mode === 'paired_device') { - return null; - } - +export function normalizeAuthSecret(_mode: AuthMode, secret: string): string | null { const trimmedSecret = secret.trim(); return trimmedSecret.length > 0 ? trimmedSecret : null; } From 8dcf88f0e529cdfffecefa3398ab947fc61ef4c6 Mon Sep 17 00:00:00 2001 From: milome Date: Fri, 17 Apr 2026 05:25:07 +0800 Subject: [PATCH 02/17] =?UTF-8?q?fix(config):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E9=85=8D=E5=AF=B9=E4=BF=9D=E5=AD=98=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让首次配对直接触发测试,并在拿到 device token 后立即刷新状态与提示保存。 --- .../components/setup/OpenClawConfigModule.tsx | 95 +++++++++++++++---- src/app/components/setup/SetupWizard.tsx | 70 ++++++++++++-- src/app/contexts/I18nContext.tsx | 5 + 3 files changed, 144 insertions(+), 26 deletions(-) diff --git a/src/app/components/setup/OpenClawConfigModule.tsx b/src/app/components/setup/OpenClawConfigModule.tsx index c87d9b0..27e05ec 100644 --- a/src/app/components/setup/OpenClawConfigModule.tsx +++ b/src/app/components/setup/OpenClawConfigModule.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { gatewayPairingStatusLookup, useOpenClaw, type AuthMode, type GatewayAdvancedConnectionConfig, type GatewayPairingStatusResult, type GatewayPairedEndpoint } from '../../contexts/OpenClawContext'; import { CheckCircle2, Server, Shield, Globe, TerminalSquare, RefreshCw, XCircle, AlertCircle, RotateCcw, Trash2, Wifi } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; @@ -47,10 +47,11 @@ export function OpenClawConfigModule() { const [advancedTimeoutMs, setAdvancedTimeoutMs] = useState(String(advancedConnectionConfig.timeoutMs)); const [advancedHeartbeatMs, setAdvancedHeartbeatMs] = useState(String(advancedConnectionConfig.heartbeatMs)); const [advancedProxyUrl, setAdvancedProxyUrl] = useState(advancedConnectionConfig.proxyUrl ?? ''); - const [saveFeedback, setSaveFeedback] = useState<{ kind: 'success' | 'error'; message: string } | null>(null); + const [saveFeedback, setSaveFeedback] = useState<{ kind: 'success' | 'error' | 'info'; message: string } | null>(null); const [pairingStatus, setPairingStatus] = useState(null); const [pairingStatusLoading, setPairingStatusLoading] = useState(false); const [authModeTouched, setAuthModeTouched] = useState(false); + const bootstrapTokenInputRef = useRef(null); const pairedReady = pairingStatus?.pairedReady ?? false; const pairedDeviceBootstrapVisible = authMode === 'paired_device' && @@ -87,11 +88,7 @@ export function OpenClawConfigModule() { if (cancelled) { return; } - setPairingStatus(next); - if (!authModeTouched && next.pairedReady) { - setAuthMode('paired_device'); - setAuthSecret(''); - } + applyPairingStatus(next, !authModeTouched); } catch { if (!cancelled) { setPairingStatus(null); @@ -115,14 +112,57 @@ export function OpenClawConfigModule() { setAdvancedProxyUrl(advancedConnectionConfig.proxyUrl ?? ''); }, [advancedConnectionConfig]); - const handleTestConnection = async () => { - if (!url) { + const applyPairingStatus = ( + next: GatewayPairingStatusResult | null, + adoptPairedDevice = false, + ) => { + setPairingStatus(next); + + if (next?.pairedReady && adoptPairedDevice) { + setAuthMode('paired_device'); + setAuthSecret(''); + setAuthModeTouched(false); + } + }; + + const handleTestConnection = async (overrides?: { + mode?: AuthMode; + secret?: string; + }) => { + const targetUrl = url.trim(); + const effectiveMode = overrides?.mode ?? authMode; + const effectiveSecret = overrides?.secret ?? authSecret; + const usedBootstrapToken = + effectiveMode === 'paired_device' && effectiveSecret.trim().length > 0; + + if (!targetUrl) { return; } setIsTesting(true); setTestResult('none'); - const success = await testConnection(url, authMode, authSecret); + setSaveFeedback(null); + const success = await testConnection(targetUrl, effectiveMode, effectiveSecret); + + if (success) { + setPairingStatusLoading(true); + try { + const next = await gatewayPairingStatusLookup(targetUrl); + applyPairingStatus(next, true); + + if (next.pairedReady && usedBootstrapToken) { + setSaveFeedback({ + kind: 'info', + message: t('setup.pairing.deviceTokenReady'), + }); + } + } catch { + // Ignore refresh failures and keep the latest visible state. + } finally { + setPairingStatusLoading(false); + } + } + setIsTesting(false); setTestResult(success ? 'success' : 'fail'); @@ -131,6 +171,26 @@ export function OpenClawConfigModule() { } }; + const handleStartBootstrap = async () => { + setAuthMode('paired_device'); + setAuthModeTouched(false); + setSaveFeedback(null); + + const bootstrapToken = authSecret.trim(); + if (!bootstrapToken) { + setSaveFeedback({ kind: 'error', message: t('setup.auth.requiredToken') }); + window.requestAnimationFrame(() => { + bootstrapTokenInputRef.current?.focus(); + }); + return; + } + + await handleTestConnection({ + mode: 'paired_device', + secret: bootstrapToken, + }); + }; + const handleSave = async () => { if (!url) { return; @@ -467,13 +527,11 @@ export function OpenClawConfigModule() { ) : pairingStatus ? ( ) : null} @@ -562,6 +620,7 @@ export function OpenClawConfigModule() {
setAuthSecret(e.target.value)} @@ -765,6 +824,8 @@ export function OpenClawConfigModule() {
{saveFeedback.message} @@ -785,7 +846,7 @@ export function OpenClawConfigModule() { {t('config.setup.rerun')}
)} + {pairingActionHint ? ( +
+ {pairingActionHint} +
+ ) : null} ) : ( <> @@ -607,7 +646,22 @@ export function SetupWizard() { {testResult === 'fail' ? t('btn.back') : t('btn.prev')} {testResult === 'success' && ( - + )} )} @@ -629,5 +683,3 @@ export function SetupWizard() {
); } - - diff --git a/src/app/contexts/I18nContext.tsx b/src/app/contexts/I18nContext.tsx index a74ff91..bd702bd 100644 --- a/src/app/contexts/I18nContext.tsx +++ b/src/app/contexts/I18nContext.tsx @@ -2921,6 +2921,11 @@ const BASE_DICT: Record = { "开始首次配对", "開始首次配對", ], + "setup.pairing.deviceTokenReady": [ + "Device token received. Click Save to finish setup.", + "已拿到 device token,请点击保存完成配置。", + "已拿到 device token,請點擊儲存完成設定。", + ], "setup.pairing.knownTitle": [ "Known Paired Gateways", "已配对网关", From bb88216a950c6f7c48340e06f5dd9ae6cb8d4d3e Mon Sep 17 00:00:00 2001 From: milome Date: Fri, 17 Apr 2026 06:00:00 +0800 Subject: [PATCH 03/17] =?UTF-8?q?fix(config):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E9=85=8D=E5=AF=B9=E5=AE=A1=E6=89=B9=E4=B8=8E?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆分已配对设备与首次配对流程,补充审批提示、device token 状态和可保存闭环。 --- .../components/setup/OpenClawConfigModule.tsx | 172 ++++++++++++++---- src/app/components/setup/SetupWizard.tsx | 161 +++++++++++++--- src/app/contexts/I18nContext.tsx | 25 +++ 3 files changed, 292 insertions(+), 66 deletions(-) diff --git a/src/app/components/setup/OpenClawConfigModule.tsx b/src/app/components/setup/OpenClawConfigModule.tsx index 27e05ec..70ca535 100644 --- a/src/app/components/setup/OpenClawConfigModule.tsx +++ b/src/app/components/setup/OpenClawConfigModule.tsx @@ -3,7 +3,6 @@ import { gatewayPairingStatusLookup, useOpenClaw, type AuthMode, type GatewayAdv import { CheckCircle2, Server, Shield, Globe, TerminalSquare, RefreshCw, XCircle, AlertCircle, RotateCcw, Trash2, Wifi } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { useI18n } from '../../contexts/I18nContext'; -import { isLoopbackGatewayUrl } from '../../contexts/openClawConnectionPolicy'; import { buildAvailableOpenClawConfigSections, resolveSelectedOpenClawConfigSection, @@ -51,17 +50,18 @@ export function OpenClawConfigModule() { const [pairingStatus, setPairingStatus] = useState(null); const [pairingStatusLoading, setPairingStatusLoading] = useState(false); const [authModeTouched, setAuthModeTouched] = useState(false); - const bootstrapTokenInputRef = useRef(null); + const pairingBootstrapTokenInputRef = useRef(null); + const [pairingBootstrapToken, setPairingBootstrapToken] = useState( + savedAuthMode === 'token' ? savedAuthSecret : '', + ); + const [pairingAttempted, setPairingAttempted] = useState(false); + const [pairingCompletionPending, setPairingCompletionPending] = useState(false); const pairedReady = pairingStatus?.pairedReady ?? false; - const pairedDeviceBootstrapVisible = - authMode === 'paired_device' && - !pairedReady && - !pairingStatusLoading && - pairingStatus !== null && - Boolean(url.trim()); - const pairedDeviceBootstrapHintVisible = pairedDeviceBootstrapVisible && isLoopbackGatewayUrl(url); + const pairedDeviceAvailable = pairedReady; + const awaitingPairApproval = + pairingAttempted && lastError?.code === 'PAIRING_REQUIRED' && !pairedDeviceAvailable; const authSecretRequired = - ((authMode === 'token' || authMode === 'password') || pairedDeviceBootstrapVisible) && + (authMode === 'token' || authMode === 'password') && authSecret.trim().length === 0; const authSecretRequiredMessage = authMode === 'password' ? t('setup.auth.requiredPassword') : t('setup.auth.requiredToken'); @@ -70,6 +70,9 @@ export function OpenClawConfigModule() { setAuthMode(savedAuthMode); setAuthSecret(savedAuthSecret); setAuthModeTouched(false); + setPairingBootstrapToken(savedAuthMode === 'token' ? savedAuthSecret : ''); + setPairingAttempted(false); + setPairingCompletionPending(false); }, [gatewayUrl, savedAuthMode, savedAuthSecret]); useEffect(() => { @@ -122,6 +125,11 @@ export function OpenClawConfigModule() { setAuthMode('paired_device'); setAuthSecret(''); setAuthModeTouched(false); + return; + } + + if (!next?.pairedReady && !authModeTouched && authMode === 'paired_device') { + setAuthMode('token'); } }; @@ -142,6 +150,8 @@ export function OpenClawConfigModule() { setIsTesting(true); setTestResult('none'); setSaveFeedback(null); + setPairingAttempted(false); + setPairingCompletionPending(false); const success = await testConnection(targetUrl, effectiveMode, effectiveSecret); if (success) { @@ -171,24 +181,54 @@ export function OpenClawConfigModule() { } }; - const handleStartBootstrap = async () => { - setAuthMode('paired_device'); - setAuthModeTouched(false); + const handleStartPairing = async () => { + const bootstrapToken = pairingBootstrapToken.trim(); + setSaveFeedback(null); + setPairingAttempted(true); + setPairingCompletionPending(false); - const bootstrapToken = authSecret.trim(); if (!bootstrapToken) { setSaveFeedback({ kind: 'error', message: t('setup.auth.requiredToken') }); window.requestAnimationFrame(() => { - bootstrapTokenInputRef.current?.focus(); + pairingBootstrapTokenInputRef.current?.focus(); }); return; } - await handleTestConnection({ - mode: 'paired_device', - secret: bootstrapToken, - }); + const targetUrl = url.trim(); + if (!targetUrl) { + return; + } + + setIsTesting(true); + setTestResult('none'); + + const success = await testConnection(targetUrl, 'paired_device', bootstrapToken); + + if (success) { + setPairingStatusLoading(true); + try { + const next = await gatewayPairingStatusLookup(targetUrl); + applyPairingStatus(next, true); + + if (next.pairedReady) { + setPairingAttempted(false); + setPairingCompletionPending(true); + setSaveFeedback({ + kind: 'info', + message: t('setup.pairing.deviceTokenReady'), + }); + } + } catch { + // Ignore refresh failures and keep the latest visible state. + } finally { + setPairingStatusLoading(false); + } + } + + setIsTesting(false); + setTestResult(success ? 'success' : 'fail'); }; const handleSave = async () => { @@ -214,11 +254,20 @@ export function OpenClawConfigModule() { return; } - const success = hasBaseChanges - ? await updateConfig(url, authMode, authSecret) + const needsConnectionPersist = hasBaseChanges || pairingCompletionPending; + const success = needsConnectionPersist + ? await updateConfig( + url, + pairingCompletionPending ? 'paired_device' : authMode, + pairingCompletionPending ? '' : authSecret, + ) : true; setIsSaving(false); setTestResult(success ? 'none' : 'fail'); + if (success) { + setPairingAttempted(false); + setPairingCompletionPending(false); + } setSaveFeedback( success ? { kind: 'success', message: t('config.advanced.saveOk') } @@ -271,6 +320,8 @@ export function OpenClawConfigModule() { setAuthMode('paired_device'); setAuthSecret(''); setAuthModeTouched(false); + setPairingAttempted(false); + setPairingCompletionPending(false); }; const candidateConfidenceLabel = (confidence: (typeof discoveredGateways)[number]['confidence']) => { @@ -298,7 +349,7 @@ export function OpenClawConfigModule() { Number(advancedTimeoutMs || 0) !== advancedConnectionConfig.timeoutMs || Number(advancedHeartbeatMs || 0) !== advancedConnectionConfig.heartbeatMs || advancedProxyUrl.trim() !== (advancedConnectionConfig.proxyUrl ?? ''); - const hasChanges = hasBaseChanges || hasAdvancedChanges; + const hasChanges = hasBaseChanges || hasAdvancedChanges || pairingCompletionPending; const connectedLabel = connectedOrigin ?? gatewayUrl; const statusDescription = isConnected ? `${t('config.status.connected')} ${connectedLabel}` @@ -478,6 +529,9 @@ export function OpenClawConfigModule() { onChange={(e) => { setUrl(e.target.value); setAuthModeTouched(false); + setPairingAttempted(false); + setPairingCompletionPending(false); + setSaveFeedback(null); }} placeholder="http://127.0.0.1:18789" className={`w-full pl-10 pr-4 py-2.5 bg-slate-50 dark:bg-slate-950 border rounded-xl text-sm outline-none transition-all dark:text-slate-100 ${ @@ -503,6 +557,12 @@ export function OpenClawConfigModule() { <>
{t('setup.pairing.readyTitle')}
{t('setup.pairing.readyDesc')}
+
{t('setup.pairing.deviceTokenValid')}
+ + ) : awaitingPairApproval ? ( + <> +
{t('setup.pairing.awaitingApprovalTitle')}
+
{t('setup.pairing.awaitingApprovalDesc')}
) : pairingStatus ? ( <> @@ -527,16 +587,47 @@ export function OpenClawConfigModule() { ) : pairingStatus ? ( ) : null}
+ {!pairedDeviceAvailable && pairingStatus ? ( +
+ {awaitingPairApproval ? ( +
+

{t('setup.auth.deviceApprovalHint')}

+ + openclaw devices approve --latest + +
+ ) : ( +
+ +
+ + setPairingBootstrapToken(e.target.value)} + placeholder={t('setup.ph.token')} + className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-xl text-sm outline-none transition-all focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-slate-100" + /> +
+

{t('setup.auth.pairedDeviceBootstrapHint')}

+
+ )} +
+ ) : null} +
{t('setup.pairing.knownTitle')} @@ -594,14 +685,28 @@ export function OpenClawConfigModule() { ].map((modeOption) => { const Icon = modeOption.icon; const isActive = authMode === modeOption.id; + const isDisabled = modeOption.id === 'paired_device' && !pairedDeviceAvailable; return ( @@ -610,17 +715,11 @@ export function OpenClawConfigModule() {
- {(authMode !== 'paired_device' || pairedDeviceBootstrapVisible) && ( + {authMode !== 'paired_device' && ( - {pairedDeviceBootstrapVisible ? ( - - ) : null}
setAuthSecret(e.target.value)} @@ -638,15 +737,14 @@ export function OpenClawConfigModule() { {authSecretRequiredMessage}

)} - {pairedDeviceBootstrapHintVisible ? ( -

{t('setup.auth.pairedDeviceBootstrapHint')}

- ) : null} )} - {authMode === 'paired_device' && ( + {authMode === 'paired_device' && pairedDeviceAvailable ? (

{t('setup.auth.pairedDeviceHint')}

- )} + ) : !pairedDeviceAvailable ? ( +

{t('setup.pairing.pairedDeviceDisabled')}

+ ) : null}
) : null} diff --git a/src/app/components/setup/SetupWizard.tsx b/src/app/components/setup/SetupWizard.tsx index b3631fb..1091a8d 100644 --- a/src/app/components/setup/SetupWizard.tsx +++ b/src/app/components/setup/SetupWizard.tsx @@ -4,7 +4,6 @@ import { gatewayPairingStatusLookup, useOpenClaw, type AuthMode, type GatewayPai import { CheckCircle2, XCircle, RefreshCw, Server, Shield, Globe, TerminalSquare, LayoutGrid, Cpu, Check, AlertCircle, ChevronRight, Moon, Sun } from 'lucide-react'; import { useI18n, LANGUAGES } from '../../contexts/I18nContext'; import { useTheme } from 'next-themes'; -import { isLoopbackGatewayUrl } from '../../contexts/openClawConnectionPolicy'; import appLogo from '../../../assets/270226c058e3f12ad7bb9e96e3b029bc0e2c0461.png'; const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:18789'; @@ -33,7 +32,7 @@ export function SetupWizard() { const [ripplePos, setRipplePos] = useState({ x: 0, y: 0, active: false }); const [step, setStep] = useState(1); const [url, setUrl] = useState(DEFAULT_GATEWAY_URL); - const [authMode, setAuthMode] = useState('paired_device'); + const [authMode, setAuthMode] = useState('token'); const [authSecret, setAuthSecret] = useState(''); const [isTesting, setIsTesting] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -43,20 +42,20 @@ export function SetupWizard() { const [pairingStatusLoading, setPairingStatusLoading] = useState(false); const [authModeTouched, setAuthModeTouched] = useState(false); const [pairingActionHint, setPairingActionHint] = useState(null); + const [pairingBootstrapToken, setPairingBootstrapToken] = useState(''); + const [pairingAttempted, setPairingAttempted] = useState(false); + const [pairingCompletionPending, setPairingCompletionPending] = useState(false); + const pairingBootstrapTokenInputRef = useRef(null); const shouldShowWizard = isSetupWizardOpen || (!isConfigured && !hasSkippedSetup); const isPairingRequired = lastError?.code === 'PAIRING_REQUIRED'; const isTokenMismatch = lastError?.code === 'AUTH_TOKEN_MISMATCH'; const pairedReady = pairingStatus?.pairedReady ?? false; - const pairedDeviceBootstrapVisible = - authMode === 'paired_device' && - !pairedReady && - !pairingStatusLoading && - pairingStatus !== null && - Boolean(url.trim()); - const pairedDeviceBootstrapHintVisible = pairedDeviceBootstrapVisible && isLoopbackGatewayUrl(url); + const pairedDeviceAvailable = pairedReady; + const awaitingPairApproval = + pairingAttempted && isPairingRequired && !pairedDeviceAvailable; const authSecretRequired = - ((authMode === 'token' || authMode === 'password') || pairedDeviceBootstrapVisible) && + (authMode === 'token' || authMode === 'password') && authSecret.trim().length === 0; const authSecretRequiredMessage = authMode === 'password' ? t('setup.auth.requiredPassword') : t('setup.auth.requiredToken'); @@ -79,7 +78,7 @@ export function SetupWizard() { setStep(1); setUrl(gatewayUrl || DEFAULT_GATEWAY_URL); - setAuthMode(savedAuthMode); + setAuthMode(savedAuthMode === 'paired_device' ? 'token' : savedAuthMode); setAuthSecret(savedAuthSecret); setIsTesting(false); setIsSaving(false); @@ -87,6 +86,9 @@ export function SetupWizard() { setIsDetecting(false); setAuthModeTouched(false); setPairingActionHint(null); + setPairingBootstrapToken(savedAuthMode === 'token' ? savedAuthSecret : ''); + setPairingAttempted(false); + setPairingCompletionPending(false); }, [shouldShowWizard, gatewayUrl, savedAuthMode, savedAuthSecret]); useEffect(() => { @@ -151,6 +153,9 @@ export function SetupWizard() { setAuthMode('paired_device'); setAuthSecret(''); setAuthModeTouched(false); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingActionHint(null); setIsDetecting(false); }, 800); }; @@ -165,6 +170,11 @@ export function SetupWizard() { setAuthMode('paired_device'); setAuthSecret(''); setAuthModeTouched(false); + return; + } + + if (!next?.pairedReady && !authModeTouched && authMode === 'paired_device') { + setAuthMode('token'); } }; @@ -172,12 +182,12 @@ export function SetupWizard() { const targetUrl = url.trim(); const effectiveMode = authMode; const effectiveSecret = authSecret; - const usedBootstrapToken = - effectiveMode === 'paired_device' && effectiveSecret.trim().length > 0; setIsTesting(true); setTestResult('none'); setPairingActionHint(null); + setPairingAttempted(false); + setPairingCompletionPending(false); const success = await testConnection(targetUrl, effectiveMode, effectiveSecret); @@ -186,8 +196,49 @@ export function SetupWizard() { try { const next = await gatewayPairingStatusLookup(targetUrl); applyPairingStatus(next, true); + } catch { + // Ignore refresh failures and keep the test result visible. + } finally { + setPairingStatusLoading(false); + } + } - if (next.pairedReady && usedBootstrapToken) { + setIsTesting(false); + setTestResult(success ? 'success' : 'fail'); + setStep(3); + }; + + const handleStartPairing = async () => { + const targetUrl = url.trim(); + const bootstrapToken = pairingBootstrapToken.trim(); + + setPairingActionHint(null); + setPairingAttempted(true); + setPairingCompletionPending(false); + + if (!targetUrl) { + return; + } + + if (!bootstrapToken) { + window.requestAnimationFrame(() => pairingBootstrapTokenInputRef.current?.focus()); + return; + } + + setIsTesting(true); + setTestResult('none'); + + const success = await testConnection(targetUrl, 'paired_device', bootstrapToken); + + if (success) { + setPairingStatusLoading(true); + try { + const next = await gatewayPairingStatusLookup(targetUrl); + applyPairingStatus(next, true); + + if (next.pairedReady) { + setPairingAttempted(false); + setPairingCompletionPending(true); setPairingActionHint(t('setup.pairing.deviceTokenReady')); } } catch { @@ -204,13 +255,21 @@ export function SetupWizard() { const handleSaveAndFinish = async () => { setIsSaving(true); - const success = await updateConfig(url, authMode, authSecret); + const success = await updateConfig( + url, + pairingCompletionPending ? 'paired_device' : authMode, + pairingCompletionPending ? '' : authSecret, + ); setIsSaving(false); if (!success) { setTestResult('fail'); setStep(3); + return; } + + setPairingAttempted(false); + setPairingCompletionPending(false); }; if (!shouldShowWizard) { @@ -403,6 +462,9 @@ export function SetupWizard() { onChange={(e) => { setUrl(e.target.value); setAuthModeTouched(false); + setPairingAttempted(false); + setPairingCompletionPending(false); + setPairingActionHint(null); }} placeholder={DEFAULT_GATEWAY_URL} className={`w-full pl-10 pr-4 py-3 bg-white dark:bg-slate-900 border rounded-xl text-sm outline-none transition-all dark:text-slate-100 ${ @@ -430,11 +492,45 @@ export function SetupWizard() { <>
{t('setup.pairing.readyTitle')}
{t('setup.pairing.readyDesc')}
+
{t('setup.pairing.deviceTokenValid')}
+ + ) : awaitingPairApproval ? ( + <> +
{t('setup.pairing.awaitingApprovalTitle')}
+
{t('setup.pairing.awaitingApprovalDesc')}
+ + openclaw devices approve --latest + ) : pairingStatus ? ( <>
{t('setup.pairing.bootstrapTitle')}
{t('setup.pairing.bootstrapDesc')}
+
+ +
+ + setPairingBootstrapToken(e.target.value)} + placeholder={t('setup.ph.token')} + className="w-full pl-10 pr-4 py-3 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-xl text-sm outline-none transition-all focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-slate-100" + /> +
+

{t('setup.auth.pairedDeviceBootstrapHint')}

+ +
) : null}
@@ -452,14 +548,28 @@ export function SetupWizard() { ].map((modeOption) => { const Icon = modeOption.icon; const isActive = authMode === modeOption.id; + const isDisabled = modeOption.id === 'paired_device' && !pairedDeviceAvailable; return ( @@ -468,13 +578,8 @@ export function SetupWizard() { - {(authMode !== 'paired_device' || pairedDeviceBootstrapVisible) && ( + {authMode !== 'paired_device' && ( - {pairedDeviceBootstrapVisible ? ( - - ) : null}
)} - {pairedDeviceBootstrapHintVisible ? ( -

{t('setup.auth.pairedDeviceBootstrapHint')}

- ) : ( -

{t('setup.hint.token1')} openclaw config get gateway.auth.token {t('setup.hint.token2')}

- )} +

{t('setup.hint.token1')} openclaw config get gateway.auth.token {t('setup.hint.token2')}

)} - {authMode === 'paired_device' && ( + {authMode === 'paired_device' && pairedDeviceAvailable ? (

{t('setup.auth.pairedDeviceHint')}

- )} + ) : !pairedDeviceAvailable ? ( +

{t('setup.pairing.pairedDeviceDisabled')}

+ ) : null}
diff --git a/src/app/contexts/I18nContext.tsx b/src/app/contexts/I18nContext.tsx index bd702bd..61b86cb 100644 --- a/src/app/contexts/I18nContext.tsx +++ b/src/app/contexts/I18nContext.tsx @@ -2926,6 +2926,31 @@ const BASE_DICT: Record = { "已拿到 device token,请点击保存完成配置。", "已拿到 device token,請點擊儲存完成設定。", ], + "setup.pairing.deviceTokenValid": [ + "Device token status: valid", + "device token 状态:有效", + "device token 狀態:有效", + ], + "setup.pairing.awaitingApprovalTitle": [ + "Pairing request sent. Waiting for host approval", + "已发起配对请求,等待宿主机批准", + "已發起配對請求,等待主機批准", + ], + "setup.pairing.awaitingApprovalDesc": [ + "Approve the latest device request on the OpenClaw host, then retry pairing here.", + "请先在 OpenClaw 宿主机执行 approve latest,再回到这里重试配对。", + "請先在 OpenClaw 主機執行 approve latest,再回到這裡重試配對。", + ], + "setup.pairing.retryAfterApproval": [ + "Retry After Approval", + "批准后重试配对", + "批准後重試配對", + ], + "setup.pairing.pairedDeviceDisabled": [ + "Paired Device becomes available after first pairing is completed.", + "完成首次配对后,“已配对设备”才会可用。", + "完成首次配對後,「已配對裝置」才會可用。", + ], "setup.pairing.knownTitle": [ "Known Paired Gateways", "已配对网关", From 5f2600f24124c0be8957fe017aaca38846afeca1 Mon Sep 17 00:00:00 2001 From: milome Date: Fri, 17 Apr 2026 07:12:39 +0800 Subject: [PATCH 04/17] =?UTF-8?q?fix(setup):=20=E4=BF=AE=E6=AD=A3=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E9=85=8D=E5=AF=B9=E8=AF=AF=E8=B7=B3=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分连接成功与完成配对;未拿到 device token 时停留在配对步骤并补充引导。 --- .../components/setup/OpenClawConfigModule.tsx | 82 +++++++++++- src/app/components/setup/SetupWizard.tsx | 87 ++++++++++++- .../setup/openClawPairingState.test.ts | 122 ++++++++++++++++++ .../components/setup/openClawPairingState.ts | 97 ++++++++++++++ src/app/contexts/I18nContext.tsx | 20 +++ 5 files changed, 396 insertions(+), 12 deletions(-) create mode 100644 src/app/components/setup/openClawPairingState.test.ts create mode 100644 src/app/components/setup/openClawPairingState.ts diff --git a/src/app/components/setup/OpenClawConfigModule.tsx b/src/app/components/setup/OpenClawConfigModule.tsx index 70ca535..e5807cb 100644 --- a/src/app/components/setup/OpenClawConfigModule.tsx +++ b/src/app/components/setup/OpenClawConfigModule.tsx @@ -8,6 +8,11 @@ import { resolveSelectedOpenClawConfigSection, type OpenClawConfigSectionId, } from './openClawConfigSectionState'; +import { + resolveOpenClawPairingFollowup, + resolveOpenClawStartPairingTransition, + shouldShowNoPendingPairingHint, +} from './openClawPairingState'; export function OpenClawConfigModule() { const { t } = useI18n(); @@ -56,10 +61,18 @@ export function OpenClawConfigModule() { ); const [pairingAttempted, setPairingAttempted] = useState(false); const [pairingCompletionPending, setPairingCompletionPending] = useState(false); + const [pairingSucceededWithoutDeviceToken, setPairingSucceededWithoutDeviceToken] = useState(false); const pairedReady = pairingStatus?.pairedReady ?? false; const pairedDeviceAvailable = pairedReady; - const awaitingPairApproval = - pairingAttempted && lastError?.code === 'PAIRING_REQUIRED' && !pairedDeviceAvailable; + const pairingFollowup = resolveOpenClawPairingFollowup({ + pairedReady, + pairingAttempted, + pairingCompletionPending, + pairingSucceededWithoutDeviceToken, + lastError, + }); + const awaitingPairApproval = pairingFollowup === 'awaiting_host_approval'; + const showNoPendingPairingHint = shouldShowNoPendingPairingHint(pairingFollowup); const authSecretRequired = (authMode === 'token' || authMode === 'password') && authSecret.trim().length === 0; @@ -73,6 +86,7 @@ export function OpenClawConfigModule() { setPairingBootstrapToken(savedAuthMode === 'token' ? savedAuthSecret : ''); setPairingAttempted(false); setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); }, [gatewayUrl, savedAuthMode, savedAuthSecret]); useEffect(() => { @@ -152,6 +166,7 @@ export function OpenClawConfigModule() { setSaveFeedback(null); setPairingAttempted(false); setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); const success = await testConnection(targetUrl, effectiveMode, effectiveSecret); if (success) { @@ -187,6 +202,7 @@ export function OpenClawConfigModule() { setSaveFeedback(null); setPairingAttempted(true); setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); if (!bootstrapToken) { setSaveFeedback({ kind: 'error', message: t('setup.auth.requiredToken') }); @@ -211,14 +227,25 @@ export function OpenClawConfigModule() { try { const next = await gatewayPairingStatusLookup(targetUrl); applyPairingStatus(next, true); + const transition = resolveOpenClawStartPairingTransition({ + connectSucceeded: true, + pairedReady: next.pairedReady, + }); - if (next.pairedReady) { + setPairingSucceededWithoutDeviceToken( + transition.pairingSucceededWithoutDeviceToken, + ); + setPairingCompletionPending(transition.pairingCompletionPending); + + if (transition.pairingCompletionPending) { setPairingAttempted(false); - setPairingCompletionPending(true); setSaveFeedback({ kind: 'info', message: t('setup.pairing.deviceTokenReady'), }); + setTestResult('success'); + } else { + setTestResult('none'); } } catch { // Ignore refresh failures and keep the latest visible state. @@ -228,7 +255,9 @@ export function OpenClawConfigModule() { } setIsTesting(false); - setTestResult(success ? 'success' : 'fail'); + if (!success) { + setTestResult('fail'); + } }; const handleSave = async () => { @@ -531,6 +560,7 @@ export function OpenClawConfigModule() { setAuthModeTouched(false); setPairingAttempted(false); setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); setSaveFeedback(null); }} placeholder="http://127.0.0.1:18789" @@ -578,6 +608,7 @@ export function OpenClawConfigModule() { setAuthMode('paired_device'); setAuthSecret(''); setAuthModeTouched(false); + setPairingSucceededWithoutDeviceToken(false); }} className="inline-flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-300" > @@ -608,6 +639,41 @@ export function OpenClawConfigModule() { ) : (
+ {pairingAttempted && lastError ? ( +
+
+ + {lastError.message} +
+ {lastError.hint ? ( +

+ {lastError.hint} +

+ ) : null} + {showNoPendingPairingHint ? ( +

+ {pairingFollowup === 'connected_without_pairing' + ? t('setup.pairing.connectedWithoutPairingDesc') + : t('setup.pairing.requestNotQueuedDesc')} +

+ ) : null} + {pairingFollowup === 'token_mismatch' || pairingFollowup === 'token_required' ? ( + + openclaw config get gateway.auth.token + + ) : null} +
+ ) : null} + {pairingFollowup === 'connected_without_pairing' ? ( +
+

+ {t('setup.pairing.connectedWithoutPairingTitle')} +

+

+ {t('setup.pairing.connectedWithoutPairingDesc')} +

+
+ ) : null} @@ -617,7 +683,10 @@ export function OpenClawConfigModule() { ref={pairingBootstrapTokenInputRef} type="password" value={pairingBootstrapToken} - onChange={(e) => setPairingBootstrapToken(e.target.value)} + onChange={(e) => { + setPairingBootstrapToken(e.target.value); + setPairingSucceededWithoutDeviceToken(false); + }} placeholder={t('setup.ph.token')} className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-xl text-sm outline-none transition-all focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-slate-100" /> @@ -695,6 +764,7 @@ export function OpenClawConfigModule() { } setPairingAttempted(false); setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); setSaveFeedback(null); setAuthMode(modeOption.id as AuthMode); setAuthModeTouched(true); diff --git a/src/app/components/setup/SetupWizard.tsx b/src/app/components/setup/SetupWizard.tsx index 1091a8d..6e3e3c3 100644 --- a/src/app/components/setup/SetupWizard.tsx +++ b/src/app/components/setup/SetupWizard.tsx @@ -5,6 +5,11 @@ import { CheckCircle2, XCircle, RefreshCw, Server, Shield, Globe, TerminalSquare import { useI18n, LANGUAGES } from '../../contexts/I18nContext'; import { useTheme } from 'next-themes'; import appLogo from '../../../assets/270226c058e3f12ad7bb9e96e3b029bc0e2c0461.png'; +import { + resolveOpenClawPairingFollowup, + resolveOpenClawStartPairingTransition, + shouldShowNoPendingPairingHint, +} from './openClawPairingState'; const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:18789'; @@ -45,6 +50,7 @@ export function SetupWizard() { const [pairingBootstrapToken, setPairingBootstrapToken] = useState(''); const [pairingAttempted, setPairingAttempted] = useState(false); const [pairingCompletionPending, setPairingCompletionPending] = useState(false); + const [pairingSucceededWithoutDeviceToken, setPairingSucceededWithoutDeviceToken] = useState(false); const pairingBootstrapTokenInputRef = useRef(null); const shouldShowWizard = isSetupWizardOpen || (!isConfigured && !hasSkippedSetup); @@ -52,8 +58,15 @@ export function SetupWizard() { const isTokenMismatch = lastError?.code === 'AUTH_TOKEN_MISMATCH'; const pairedReady = pairingStatus?.pairedReady ?? false; const pairedDeviceAvailable = pairedReady; - const awaitingPairApproval = - pairingAttempted && isPairingRequired && !pairedDeviceAvailable; + const pairingFollowup = resolveOpenClawPairingFollowup({ + pairedReady, + pairingAttempted, + pairingCompletionPending, + pairingSucceededWithoutDeviceToken, + lastError, + }); + const awaitingPairApproval = pairingFollowup === 'awaiting_host_approval'; + const showNoPendingPairingHint = shouldShowNoPendingPairingHint(pairingFollowup); const authSecretRequired = (authMode === 'token' || authMode === 'password') && authSecret.trim().length === 0; @@ -89,6 +102,7 @@ export function SetupWizard() { setPairingBootstrapToken(savedAuthMode === 'token' ? savedAuthSecret : ''); setPairingAttempted(false); setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); }, [shouldShowWizard, gatewayUrl, savedAuthMode, savedAuthSecret]); useEffect(() => { @@ -155,6 +169,7 @@ export function SetupWizard() { setAuthModeTouched(false); setPairingAttempted(false); setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); setPairingActionHint(null); setIsDetecting(false); }, 800); @@ -188,6 +203,7 @@ export function SetupWizard() { setPairingActionHint(null); setPairingAttempted(false); setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); const success = await testConnection(targetUrl, effectiveMode, effectiveSecret); @@ -215,6 +231,7 @@ export function SetupWizard() { setPairingActionHint(null); setPairingAttempted(true); setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); if (!targetUrl) { return; @@ -235,11 +252,25 @@ export function SetupWizard() { try { const next = await gatewayPairingStatusLookup(targetUrl); applyPairingStatus(next, true); + const transition = resolveOpenClawStartPairingTransition({ + connectSucceeded: true, + pairedReady: next.pairedReady, + }); + + setPairingSucceededWithoutDeviceToken( + transition.pairingSucceededWithoutDeviceToken, + ); - if (next.pairedReady) { + if (transition.pairingCompletionPending) { setPairingAttempted(false); setPairingCompletionPending(true); setPairingActionHint(t('setup.pairing.deviceTokenReady')); + setTestResult('success'); + if (transition.shouldAdvanceWizard) { + setStep(3); + } + } else { + setTestResult('none'); } } catch { // Ignore refresh failures and keep the test result visible. @@ -249,8 +280,9 @@ export function SetupWizard() { } setIsTesting(false); - setTestResult(success ? 'success' : 'fail'); - setStep(3); + if (!success) { + setTestResult('fail'); + } }; const handleSaveAndFinish = async () => { @@ -464,6 +496,7 @@ export function SetupWizard() { setAuthModeTouched(false); setPairingAttempted(false); setPairingCompletionPending(false); + setPairingSucceededWithoutDeviceToken(false); setPairingActionHint(null); }} placeholder={DEFAULT_GATEWAY_URL} @@ -516,12 +549,25 @@ export function SetupWizard() { ref={pairingBootstrapTokenInputRef} type="password" value={pairingBootstrapToken} - onChange={(e) => setPairingBootstrapToken(e.target.value)} + onChange={(e) => { + setPairingBootstrapToken(e.target.value); + setPairingSucceededWithoutDeviceToken(false); + }} placeholder={t('setup.ph.token')} className="w-full pl-10 pr-4 py-3 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-xl text-sm outline-none transition-all focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-slate-100" />

{t('setup.auth.pairedDeviceBootstrapHint')}

+ {pairingFollowup === 'connected_without_pairing' ? ( +
+

+ {t('setup.pairing.connectedWithoutPairingTitle')} +

+

+ {t('setup.pairing.connectedWithoutPairingDesc')} +

+
+ ) : null} +
{`task-phase:${reindexActivity?.phase ?? "none"}`}
+ + ), +})); + +import { MemoryView } from "./MemoryView"; + +describe("MemoryView reindex persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("keeps lifted reindex state after switching away from knowledge and back", async () => { + render(); + const user = userEvent.setup(); + + await user.click(await screen.findByText("memory.tab.knowledge")); + await user.click(screen.getByText("start-reindex")); + + await waitFor(() => { + expect(screen.getByText(/task-phase:(starting|running|syncing)/)).toBeTruthy(); + }); + + await user.click(screen.getByText("memory.tab.documents")); + expect(screen.getByText("documents-desktop")).toBeTruthy(); + + await user.click(screen.getByText("memory.tab.knowledge")); + + await waitFor(() => { + expect(screen.getByText(/task-phase:(starting|running|syncing)/)).toBeTruthy(); + }); + }); +}); diff --git a/src/app/components/views/MemoryView.tsx b/src/app/components/views/MemoryView.tsx index 162a631..036e94e 100644 --- a/src/app/components/views/MemoryView.tsx +++ b/src/app/components/views/MemoryView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Search, Calendar, Network, Cpu, BrainCircuit, ChevronDown, BookOpen, FileText, Info, LibraryBig } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { toast } from "sonner"; @@ -59,7 +59,19 @@ import { resolveSelectedMemoryAgentIdForNode, resolveSelectedMemoryNodeId, } from "./memoryNodeState"; -import type { MemoryKnowledgeRefreshResult } from "./memoryKnowledgeReindexState"; +import { + captureMemoryKnowledgeReindexSnapshot, + describeMemoryKnowledgeReindexDelta, + hasMemoryKnowledgeReindexProgress, + isMemoryKnowledgeReindexSettled, + type MemoryKnowledgeRefreshResult, + type MemoryKnowledgeReindexActivityState, + type ReindexTimelineEntry, +} from "./memoryKnowledgeReindexState"; +import { + runExternalKnowledgeReindex, + type MemoryKnowledgeActionFailure, +} from "./memoryKnowledgeActions"; import { canRunSemanticMemorySearch, resolveSemanticMemorySearchGroup, @@ -377,6 +389,14 @@ export function MemoryView() { const [_timelineEntryContent, setTimelineEntryContent] = useState(""); const [_timelineEntryLoading, setTimelineEntryLoading] = useState(false); const [_timelineEntryError, setTimelineEntryError] = useState(null); + const [knowledgeReindexActivity, setKnowledgeReindexActivity] = + useState(null); + const [knowledgeReindexFeedback, setKnowledgeReindexFeedback] = useState(null); + const [knowledgeReindexDetailsExpanded, setKnowledgeReindexDetailsExpanded] = useState(true); + const knowledgeReindexTokenRef = useRef(0); + const knowledgeReindexPollTimerRef = useRef(null); + const knowledgeReindexPollInFlightRef = useRef(false); + const knowledgeReindexCommandCompletedRef = useRef(false); const selectedNodeEntry = useMemo( () => memoryNodeEntries.find((entry) => entry.id === selectedNodeId) ?? null, @@ -959,6 +979,360 @@ export function MemoryView() { ? memoryConfigStatus.providerAvailabilityReasonKey : memoryConfigStatus.searchAvailabilityReasonKey; const searchPrimaryReason = searchPrimaryReasonKey ? t(searchPrimaryReasonKey) : null; + const currentKnowledgeReindexSnapshot = useMemo( + () => + captureMemoryKnowledgeReindexSnapshot({ + statusSummary: memoryConfigStatus, + runtimeStatus: memoryRuntimeStatus, + }), + [memoryConfigStatus, memoryRuntimeStatus], + ); + + const createKnowledgeReindexEntry = ( + tone: ReindexTimelineEntry["tone"], + title: string, + detail?: string | null, + ): ReindexTimelineEntry => ({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + tone, + title, + detail, + atMs: Date.now(), + }); + + const stopKnowledgeReindexPolling = () => { + if (knowledgeReindexPollTimerRef.current !== null) { + window.clearInterval(knowledgeReindexPollTimerRef.current); + knowledgeReindexPollTimerRef.current = null; + } + knowledgeReindexPollInFlightRef.current = false; + }; + + const applyKnowledgeReindexRefresh = ( + result: MemoryKnowledgeRefreshResult, + options: { + token: number; + afterCommand: boolean; + addIssueEntry?: boolean; + }, + ) => { + if (knowledgeReindexTokenRef.current !== options.token) { + return; + } + + const nextStatusSummary = buildMemoryConfigStatusSummary({ + selectedAgentId, + isLocalGatewaySession, + memoryResult: result.memoryResult, + memoryStatus: result.memoryStatus, + runtimeStatus: result.runtimeStatus, + }); + const nextSnapshot = captureMemoryKnowledgeReindexSnapshot({ + statusSummary: nextStatusSummary, + runtimeStatus: result.runtimeStatus, + }); + + let shouldStopPolling = false; + + setKnowledgeReindexActivity((current) => { + if (!current) { + return current; + } + + const afterCommandPolls = current.afterCommandPolls + (options.afterCommand ? 1 : 0); + const observedProgress = hasMemoryKnowledgeReindexProgress(current.latest, nextSnapshot); + const nextEntries = [...current.entries]; + let nextPhase: MemoryKnowledgeReindexActivityState["phase"] = options.afterCommand ? "syncing" : "running"; + let finishedAtMs = current.finishedAtMs; + + if (observedProgress && !current.progressObserved) { + nextEntries.push( + createKnowledgeReindexEntry( + "info", + t("memory.knowledge.reindexLive.event.progress"), + describeMemoryKnowledgeReindexDelta(current.before, nextSnapshot), + ), + ); + } + + if (options.addIssueEntry && current.syncIssue) { + nextEntries.push( + createKnowledgeReindexEntry( + "warn", + t("memory.knowledge.reindexLive.event.refreshFailed"), + current.syncIssue, + ), + ); + } + + if (options.afterCommand && isMemoryKnowledgeReindexSettled(nextSnapshot)) { + nextPhase = "settled"; + finishedAtMs = Date.now(); + nextEntries.push( + createKnowledgeReindexEntry( + "info", + t("memory.knowledge.reindexLive.event.settled"), + describeMemoryKnowledgeReindexDelta(current.before, nextSnapshot), + ), + ); + shouldStopPolling = true; + } else if (options.afterCommand && afterCommandPolls >= 6) { + nextPhase = "warning"; + finishedAtMs = Date.now(); + nextEntries.push( + createKnowledgeReindexEntry( + "warn", + t("memory.knowledge.reindexLive.event.warning"), + describeMemoryKnowledgeReindexDelta(current.before, nextSnapshot) || t("memory.knowledge.reindexLive.noDelta"), + ), + ); + shouldStopPolling = true; + } + + return { + ...current, + phase: nextPhase, + finishedAtMs, + polls: current.polls + 1, + afterCommandPolls, + lastPolledAtMs: Date.now(), + latest: nextSnapshot, + syncIssue: null, + progressObserved: current.progressObserved || observedProgress, + entries: nextEntries, + }; + }); + + if (shouldStopPolling) { + stopKnowledgeReindexPolling(); + } + }; + + const refreshKnowledgeReindexProgress = async ( + token: number, + options: { + afterCommand: boolean; + addIssueEntry?: boolean; + }, + ) => { + if (knowledgeReindexPollInFlightRef.current || knowledgeReindexTokenRef.current !== token) { + return; + } + + knowledgeReindexPollInFlightRef.current = true; + try { + const result = await handleRefreshKnowledge(); + if (result) { + applyKnowledgeReindexRefresh(result, { ...options, token }); + } else { + let shouldStopPolling = false; + setKnowledgeReindexActivity((current) => { + if (!current) { + return current; + } + const afterCommandPolls = current.afterCommandPolls + (options.afterCommand ? 1 : 0); + const finishedAtMs = + options.afterCommand && afterCommandPolls >= 6 ? Date.now() : current.finishedAtMs; + const nextEntries = + options.afterCommand && afterCommandPolls >= 6 + ? [ + ...current.entries, + createKnowledgeReindexEntry( + "warn", + t("memory.knowledge.reindexLive.event.warning"), + null, + ), + ] + : current.entries; + shouldStopPolling = finishedAtMs !== null; + return { + ...current, + phase: finishedAtMs !== null ? "warning" : current.phase, + polls: current.polls + 1, + afterCommandPolls, + lastPolledAtMs: Date.now(), + finishedAtMs, + syncIssue: t("memory.knowledge.reindexLive.noRefreshPayload"), + entries: nextEntries, + }; + }); + if (shouldStopPolling) { + stopKnowledgeReindexPolling(); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + let shouldStopPolling = false; + setKnowledgeReindexActivity((current) => { + if (!current) { + return current; + } + const afterCommandPolls = current.afterCommandPolls + (options.afterCommand ? 1 : 0); + const finishedAtMs = + options.afterCommand && afterCommandPolls >= 6 ? Date.now() : current.finishedAtMs; + const nextEntries = + options.afterCommand && afterCommandPolls >= 6 + ? [ + ...current.entries, + createKnowledgeReindexEntry( + "warn", + t("memory.knowledge.reindexLive.event.refreshFailed"), + null, + ), + ] + : current.entries; + shouldStopPolling = finishedAtMs !== null; + return { + ...current, + phase: finishedAtMs !== null ? "warning" : current.phase, + polls: current.polls + 1, + afterCommandPolls, + lastPolledAtMs: Date.now(), + finishedAtMs, + syncIssue: message, + entries: nextEntries, + }; + }); + if (shouldStopPolling) { + stopKnowledgeReindexPolling(); + } + } finally { + knowledgeReindexPollInFlightRef.current = false; + } + }; + + const startKnowledgeReindexPolling = (token: number) => { + stopKnowledgeReindexPolling(); + knowledgeReindexPollTimerRef.current = window.setInterval(() => { + void refreshKnowledgeReindexProgress(token, { + afterCommand: knowledgeReindexCommandCompletedRef.current, + addIssueEntry: knowledgeReindexCommandCompletedRef.current, + }); + }, 1500); + }; + + useEffect(() => stopKnowledgeReindexPolling, []); + + useEffect(() => { + if (!knowledgeReindexActivity || knowledgeReindexActivity.finishedAtMs === null) { + return; + } + + if (knowledgeReindexActivity.phase === "settled") { + setKnowledgeReindexFeedback(t("memory.knowledge.reindexLive.settledFeedback")); + return; + } + + if (knowledgeReindexActivity.phase === "warning") { + setKnowledgeReindexFeedback( + knowledgeReindexActivity.syncIssue + ? null + : t("memory.knowledge.reindexLive.warningFeedback"), + ); + return; + } + + if (knowledgeReindexActivity.phase === "failed") { + setKnowledgeReindexFeedback(null); + } + }, [knowledgeReindexActivity, t]); + + const runKnowledgeReindex = async ({ auto }: { auto: boolean }) => { + if (!memoryConfigStatus.localWritable || !selectedAgentId) { + return; + } + + const token = Date.now(); + knowledgeReindexTokenRef.current = token; + knowledgeReindexCommandCompletedRef.current = false; + setKnowledgeReindexDetailsExpanded(true); + setKnowledgeReindexFeedback(auto ? t("memory.knowledge.reindexAutoRunning") : null); + setKnowledgeReindexActivity({ + phase: "starting", + startedAtMs: Date.now(), + finishedAtMs: null, + polls: 0, + afterCommandPolls: 0, + lastPolledAtMs: null, + before: currentKnowledgeReindexSnapshot, + latest: currentKnowledgeReindexSnapshot, + commandStdout: null, + syncIssue: null, + progressObserved: false, + entries: [ + createKnowledgeReindexEntry( + "info", + auto + ? t("memory.knowledge.reindexLive.event.autoSubmitted") + : t("memory.knowledge.reindexLive.event.submitted"), + t("memory.knowledge.reindexLive.event.submittedDetail"), + ), + ], + }); + startKnowledgeReindexPolling(token); + void refreshKnowledgeReindexProgress(token, { afterCommand: false }); + + try { + const result = await runExternalKnowledgeReindex( + selectedAgentId, + memoryConfigStatus.reindexStrategy, + t, + selectedSessionId ?? undefined, + ); + if (knowledgeReindexTokenRef.current !== token) { + return; + } + + knowledgeReindexCommandCompletedRef.current = true; + setKnowledgeReindexActivity((current) => + current + ? { + ...current, + phase: "syncing", + commandStdout: result.stdout || t("memory.knowledge.reindexDone"), + entries: [ + ...current.entries, + createKnowledgeReindexEntry( + "info", + t("memory.knowledge.reindexLive.event.commandDone"), + result.stdout || t("memory.knowledge.reindexDone"), + ), + ], + } + : current, + ); + await refreshKnowledgeReindexProgress(token, { afterCommand: true }); + setKnowledgeReindexFeedback(t("memory.knowledge.reindexLive.syncingFeedback")); + if (!auto) { + toast.success(t("memory.knowledge.reindexLive.commandAccepted")); + } + } catch (error) { + const failure = error as MemoryKnowledgeActionFailure; + stopKnowledgeReindexPolling(); + setKnowledgeReindexActivity((current) => + current + ? { + ...current, + phase: "failed", + finishedAtMs: Date.now(), + syncIssue: failure.message, + entries: [ + ...current.entries, + createKnowledgeReindexEntry( + "error", + t("memory.knowledge.reindexLive.event.failed"), + null, + ), + ], + } + : current, + ); + toast.error(failure.message); + } + }; + + const isKnowledgeReindexBusy = + knowledgeReindexActivity !== null && knowledgeReindexActivity.finishedAtMs === null; const getAgentBadge = (agentId: string) => { const agent = agents.find(a => a.id === agentId); @@ -1549,6 +1923,13 @@ export function MemoryView() { openHint={mindMapOpenHint} onRefreshKnowledge={handleRefreshKnowledge} onOpenDiagnostics={() => setDiagnosticsDrawer({ open: true, source: "knowledge" })} + reindexActivity={knowledgeReindexActivity} + reindexDetailsExpanded={knowledgeReindexDetailsExpanded} + reindexFeedback={knowledgeReindexFeedback} + isReindexBusy={isKnowledgeReindexBusy} + onToggleReindexDetails={() => setKnowledgeReindexDetailsExpanded((current) => !current)} + onRunReindex={() => runKnowledgeReindex({ auto: false })} + onRunAutoReindex={() => runKnowledgeReindex({ auto: true })} /> ), diff --git a/src/app/components/views/memoryKnowledgeReindexState.ts b/src/app/components/views/memoryKnowledgeReindexState.ts index 1f672d3..2f8cd79 100644 --- a/src/app/components/views/memoryKnowledgeReindexState.ts +++ b/src/app/components/views/memoryKnowledgeReindexState.ts @@ -28,6 +28,29 @@ export type MemoryKnowledgeReindexPhase = | "warning" | "failed"; +export type ReindexTimelineEntry = { + id: string; + tone: "info" | "warn" | "error"; + title: string; + detail?: string | null; + atMs: number; +}; + +export type MemoryKnowledgeReindexActivityState = { + phase: MemoryKnowledgeReindexPhase; + startedAtMs: number; + finishedAtMs: number | null; + polls: number; + afterCommandPolls: number; + lastPolledAtMs: number | null; + before: MemoryKnowledgeReindexSnapshot; + latest: MemoryKnowledgeReindexSnapshot; + commandStdout: string | null; + syncIssue: string | null; + progressObserved: boolean; + entries: ReindexTimelineEntry[]; +}; + export function captureMemoryKnowledgeReindexSnapshot({ statusSummary, runtimeStatus, From 5e226b9c0d8fd4d9853a6e74e9581edc78485a41 Mon Sep 17 00:00:00 2001 From: milome Date: Mon, 20 Apr 2026 10:56:17 +0800 Subject: [PATCH 17/17] =?UTF-8?q?chore(release):=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=88=B00.1.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 56f6225..26bad66 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claw-scope", "private": true, - "version": "0.1.3", + "version": "0.1.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0aa3c7b..0753b88 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claw-scope" -version = "0.1.3" +version = "0.1.4" description = "ClawScope - 记忆可见,进化可期" authors = ["milome"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6621937..c403f77 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ClawScope", - "version": "0.1.3", + "version": "0.1.4", "identifier": "com.claw.scope", "build": { "beforeDevCommand": "npm run dev",